diff --git a/.github/workflows/build_pages.yml b/.github/workflows/build_pages.yml new file mode 100644 index 00000000..66a328e5 --- /dev/null +++ b/.github/workflows/build_pages.yml @@ -0,0 +1,52 @@ +name: Pages Build + +on: + push: + branches: [ "master" ] +jobs: + pages_build: + name: Build Pages + runs-on: "ubuntu-latest" + steps: + - name: "Checkout the repository" + uses: actions/checkout@v4 + + - name: "Set up Python" + uses: actions/setup-python@v5 + with: + python-version-file: "pyproject.toml" + + - name: Install uv + uses: astral-sh/setup-uv@v1 + with: + version: "latest" + + - name: Install dependencies + run: uv sync + + - name: "Build pages" + run: uv run sphinx-build -b html -c ./docs/source/ ./docs/source/ ./docs/latest/ + + - name: "Pull any updates" + shell: bash + run: git pull + + - name: "Check for changes" + shell: bash + run: git status + + - name: "Stage changed files" + shell: bash + run: git add ./docs/latest + + - name: "Commit changed files" + shell: bash + run: | + git config --local user.email "action@github.com" + git config --local user.name "GitHub Action" + git commit -m "Update the docs" || true + + - name: Push changes + uses: ad-m/github-push-action@master + with: + github_token: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 7bf20bbe..27029e49 100644 --- a/.gitignore +++ b/.gitignore @@ -49,6 +49,7 @@ coverage.xml # Sphinx documentation docs/_build/ +.doctrees/ # PyBuilder target/ @@ -57,3 +58,27 @@ target/ *.pw *bookings.json */pid + +.idea/ + +trail.py +sample_run.py + +# Dev tooling +.python-version +Pipfile +Pipfile.lock +.vscode/* + +# config.py file that contains secrets +config.py + +# virtualenvironments +venv/ + +# O365 specific +o365_token\.txt +local_tests/ + +# Mac Specifoc +.DS_Store \ No newline at end of file diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 00000000..e1e1909e --- /dev/null +++ b/.travis.yml @@ -0,0 +1,8 @@ +language: python +python: +- '3.5' +- '3.6' +install: +- pip install -r requirements-dev.txt +- python setup.py install +script: pytest diff --git a/CHANGES.md b/CHANGES.md new file mode 100644 index 00000000..271aab2e --- /dev/null +++ b/CHANGES.md @@ -0,0 +1,480 @@ +# O365 Library Changelog + +Almost every release features a lot of bugfixes but those are not listed here. + + +## Versions 2.1.9 (2026-01-30) + +- Message: You can now delay the delivery of a message (Thanks @yeyeric) +- Tasks: Add support for the delta endpoint (Thanks @RogerSelwyn) +- Directory: Added `invite_user` method to create a guest user in the directory (Thanks @neilkatin) +- Subscriptions: Added list, get, and update methods to subscriptions (Thanks @sdelgadoc) + +## Versions 2.1.8 (2025-11-28) + +- NEW: Added Subscriptions api support (Thanks @sdelgadoc) + +## Version 2.1.7 (2025-09-05) + +> [!IMPORTANT] +> **Breaking Change:** Removed support for Python 3.9 +> **Breaking Change:** Removed Old Query in favor of the new QueryBuilder (both are interchangeable for other methods to use it, but are build differently) + +- Tasks: Add population of checklist_items on Task (Thanks @RogerSelwyn) +- Excel: Added `append_rows` and `update_cells` to `WorkSheet` (Thanks @luissantosHCIT) + +## Version 2.1.6 (2025-09-05) + +- Version Yanked + +## Version 2.1.5 (2025-08-04) +- Bug fixing release + +## Version 2.1.4 (2025-06-03) +- Calendar: Schedule.get_calendar method can now use query objects with select, expand and order by (Thanks @RogerSelwyn) + +## Version 2.1.3 (2025-06-03) +- Calendar: Added the recurrence type (Thanks @RogerSelwyn) +- Calendar: Added the transaction id (Thanks @RogerSelwyn) +- Calendar: Breaking change! Calendar and Schedule get_events method now requires params start_recurring and end_recurring when include_recurring is True. +- Calendar: list_calendars method can now use query objects with select, expand and order by. +- Groups: Added pagination to get_user_groups (Thanks @RogerSelwyn) +- Tasks: Added support for check list items (Thanks @RogerSelwyn) +- Removed Office365 protocol + +## Version 2.1.2 (2025-04-08) +- Calendar: list_calendars now allows pagination (Thanks @RogerSelwyn) +- Query: added new experimental Query object that will replace the current Query object in the future. Available in utils.query. +- Message: non-draft messages can be saved. This allows to edit non-draft messages. +- Connection: proxies, verify_ssl and timeout are now honored in the msal http client. +- Message: new method `get_eml_as_object` to retrieve attached eml as Message objects. + +## Version 2.1.1 (2025-03-20) +- Tasks: support unsetting tasks due and reminder (Thanks @RogerSelwyn) +- Removed Office 365 tasks file (api was deprecated on november 2024) + +## Version 2.1.0 (2025-02-11) + +> [!IMPORTANT] +> **Breaking Change:** Removed custom authentication in favour of msal. Old tokens will not work with this version and will require a new authentication flow. + +- Account: you can now work with multiple users by changing `account.username` when using auth flow type authorization. +- Account: The username of the logged in use was previously held in `current_username`, it is now in `username` as per the previous bullet +- Connection methods `get_authorization_url` and `request_token` are now present in the `Account`object. You will no longer need to use the ones from the `Connection` object unless doing something fancy. +- Account and Connection: the authentication flow has changed and now returns different objects which need to be stored from and passed into `get_authorization_url` and `request_token` (if using those calls). +- TokenBackend: they now inherit from the msal cache system. You can now remove tokens, get access scopes from tokens, add a cryptography manager to encrypt and decrypt and much more. +- Scopes are now longer stored into the connection. Scopes are only needed when authenticating and will be stored inside the token data on the token backend. +- Scopes: You should no longer supply 'offline_access' as part of your requested scopes, this is added automatically by MSAL. +- Scopes are now passed in as `requested_scopes` rather than `scopes` +- Token: The token layout has substantially changes, so if you were interrogating it at all, you will need to adjust for the change. + + +## Version 2.0.38 (2024-11-19) +- Added 'on_premises_sam_account_name' to directory.py (Thanks @danpoltawski) +- TokenBackend: Added DjangoTokenBackend (Thanks @sdelgadoc) + +## Version 2.0.37 (2024-10-23) +- TokenBackend: Added BitwardenSecretsManagerBackend (Thanks @wnagele) + +## Version 2.0.36 (2024-07-04) + +Removed dependency: stringcase +Upgraded requirement requests-oauthlib +Added classifier python 3.12 + +## Version 2.0.35 (2024-06-29) + +###Features: +- Tasks: Exposed status property (Thanks @RogerSelwyn) +- Tasks: Added bucket_id to allowed update-attributes of Task (Thanks @dekiesel) +- Drive: Added "hashes" attribute to File (Thanks @Chrisrdouglas) +- Drive: get_item_by_path now prepends a slash if it's missing (Thanks @dekiesel) +- Excel: Added "only_values" to "get_used_range" method (Thanks @zstrathe) +- Query: Added negate to iterables inside Query +- Protocol: Added 'Europe/Kyiv' as valid Iana timezone (Thanks @jackill88) +- Message: Added ability to add custom headers (Thanks @ted-mey) + + +## Version 2.0.34 (2024-02-29) + +###Features: +- Calendar: Added weblink property (Thanks @Invincibear) + + +## Version 2.0.33 (2024-02-01) + +###Features: +- Connection: Add support for multiple Prefer headers in Connection class (Thanks @Invincibear) +- MailBox: Added timezone & workinghours to MailboxSettings class (Thanks @sdelgadoc) + + +## Version 2.0.32 (2024-01-11) + +###Features: +- Connection: Allow default headers to be set for GET request (see #1021) +- Teams: Add ability to set user presence status and get another users presence status (Thanks @RogerSelwyn) + + +## Version 2.0.31 (2023-09-27) + +###Features: +- AddressBook: Added fileAs attribute (Thanks @LarsK1) +- Fixed critical bug in 2.0.30 release + + +## Version 2.0.30 (2023-09-27) + +###Features: +- Dropped support for python <3.9 because of the need to use zoneinfo (dropped pytz). If you need support for older versions use version 2.0.28. + + +## Version 2.0.29 (2023-09-27) + +###Features: +- Calendar: no forwarding events (Thanks @Gregorek85) +- Account: removed pytz (Thanks @ponquersohn) + +## Version 2.0.28 (2023-08-29) + +###Features: +- Bug fixing release + + +## Version 2.0.27 (2023-05-30) + +###Features: +- Added hex_color to Calendar (Thanks @Invincibear) +- Add support for filter by due datetime in Tasks (Thanks @RogerSelwyn) +- Adding option to set file created and last modified time while uploading in drive (Thanks @yeyeric) +- Add access to singleValueExtendedProperties in Message (Thanks @svmcaro) + + +## Version 2.0.26 (2023-02-02) + +###Features: +- Connection now allows setting default headers (Thanks @yeyeric) +- Now it's possible to request inmutable Ids to the MS Graph protocol (Thanks @yeyeric and @NielsDebrier) +- Added more Well Known Folder Names (Thanks @ponquersohn) + + +## Version 2.0.25 (2023-01-13) + +###Features: +- Added get and set of mailbox settings (Thanks @RogerSelwyn) + + +## Version 2.0.24 (2022-12-13) + +###Features: +- Added externalAudience to automatic replies (Thanks @RogerSelwyn) + + +## Version 2.0.23 (2022-11-26) + +###Features: +- Bug fixing release + + +## Version 2.0.22 (2022-11-17) + +###Features: +- NEW: Added Tasks for MS GRAPH Protocol(Thanks @RogerSelwyn) +- NEW: Mailbox can now set auto reply (Thanks @lodesmets) +- Planner: Added pagination to Plan.list_tasks (Thanks @hcallen) + + +## Version 2.0.21 (2022-09-23) + +###Features: +- Bug fixing release + +## Version 2.0.20 (2022-08-26) + +### Features: +- Teams: added pagination to `get_all_chats` (Thanks @jhoult). +- Message: added access to inferenceClassification in msg object (Thanks @BlueSideStrongSide). +- Connection: added proxy_http_only flag (Thanks @senor-vu). +- Connection: added ROPC authentication flow (Thanks @pierfrancesto). +- Connection: added new `EnvTokenBackend` (Thanks @pierfrancesto). + + +## Version 2.0.19 (2022-05-26) + +### Features: +- Drive: added password and expiration date to share_with_link method (Thanks @MagestryMark). +- Drive: support uploading large attachments from memory (Thanks @sebastiant). +- Directory: added new methods: `get_user_manager` and `get_user_direct_reports` (Thanks @dionm). +- Groups: Improvements to `Group` class (Thanks @Krukosz). + + +## Version 2.0.18 (2022-02-03) + +### Features: +- Updated requirements to use tzlocal >=4.0 + + +## Version 2.0.17 (2022-02-01) +### Features: + - Groups: Added groups.py with some read functionality in Office 365 Groups. Thanks @Krukosz* + - Teams Chats and Chat Messages: Added to teams.py. Thanks @hcallen. + + +## Version 2.0.16 (2021-09-12) +### Features: + - Calendar: Added 'cancel_event' method + - Message: attachment existance is checked lazily + + +## Version 2.0.15 (2021-05-25) +### Features: + - Mailbox: upload attachments bigger than 4MB using MS Graph Protocol + - Account: added dynamic consent process using functions + - Drive: allow pulling DriveItems external to tenant + - Sharepoint: added support for list item fields + - Tasks: added Task.importance and Task.is_starred + + +## Version 2.0.14 (2021-01-28) +### Features: + - NEW: added MS Teams Presence class + + +## Version 2.0.13 (2020-12-02) + +### Features: +- Bug fixing release + + +## Version 2.0.12 (2020-12-02) + +### Features: +- NEW: added MS Office 365 Tasks (only available using Office365 protocol) +- Connection: init now accepts params for the default FileSystemToken +- Token: added AWS token backend + + +## Version 2.0.11 (2020-08-25) + +### Features: +- Drive: added streamable upload and download +- Drive: added conflict handling flag on uploads (only simple uploads < 4MB) +- Connection: added `verify_ssl` flag +- Calendar: added online meeting methods to change providers (teams, etc.) + + +## Version 2.0.10 (2020-06-04) + +### Features: +- Account: added public client auth flow +- Directory: added query params to retrieve users +- Calendar: now adapted to teams online meetings +- Contact: added personal notes + + +## Version 2.0.9 (2020-04-21) + +### Features: +- Bug fixing release + + +## Version 2.0.8 (2020-04-15) + +### Features: +- NEW: MS Teams available +- Drive: new method "get_drive" in DriveItems + + +## Version 2.0.7 (2020-02-06) + +### Features: +- Connection: now allows to pass a custom Json Encoder ("json_encoder" param). +- Added WorkBookApplication on excel.py that can run manual calculations on workbooks. + + +## Version 2.0.6 (2019-12-13) + +### Features: +- NEW: Outlook Categories. Modified Message, Event and Contact to accept Category instances +- TokenBackends: Implementation of 'should_refresh_token' for environments where multiple account instances are racing against each other to refresh the token. The BaseTokenBackend 'get_token' is now a default and it's not intended to be subclassed. Instead a new 'load_token' is defined to be subclassed. +- User: Profile photo implemented +- Contact: Profile photo implemented +- Drive: can set a custom name when using 'upload_file' +- Utils: updated timezones + + +## Version 2.0.5 (2019-10-23) + +### Features: +- NEW: Directory and User objects +- Removed the GAL from address_book.py. Now the users are queried from the Directory object +- Account: Added 'directory' and 'get_current_user' methods +- Message: Constructor now loads present attachments + + +## Version 2.0.4 (2019-10-18) + +### Features: +- Connection: When using the credentials auth_flow_type the tenant_id is now required +- Message: added 'unique_body' property +- Calendar: added 'get_schedule' (get_availability) + + +## Version 2.0.3 (2019-09-20) + +### Features: +- Message: You can now save Messages and attached messages as EML files. + + +## Version 2.0.2 (2019-09-18) + +### Features: +- The library now features two different authentication flows: + - 'authorization': Authenticate on behalf of a user + - 'credentials': Authenticate with your own identity (the app) +- Drive: Added Drive.get_item_by_path(item_path) +- Drive: Now get_drives accepts limit, batch order_by and query parameters +- Mailbox: Now get_message allows to specify an object_id and expands or selects as well +- Account: scopes param on account.authenticate are now optional +- Sharepoint: some enhancements + + +## Version 2.0.1 (2019-08-02) + +### Features: +- Bug fixing release + + +## Version 2.0.0 (2019-07-29) + +### Features: +- It is now posible to authenticate from a web environment with the changes on Connection. +- Attachment: Added attribute size to attachments +- Attachment: You can now add in memory files to attachments. Pass a tuple (BytesIO instance, 'file name.png') +- Account: the resource ME_RESOURCE is now the default +- Message: Added new method 'mark_as_unread' to mark the message as unread. +- Message: Added Body Preview +- Query: Added Precedence Grouping +- Query: Now it's possible to pass attribute=None to the iterable method so you can iterate on the object itself. See [#271](https://github.com/O365/python-o365/issues/271) +- Connection: If timezone is unkknown default to UTC +- Connection: self.naive_session is now lazy loaded +- OutlookWellKnowFolderNames: Added ARCHIVE + + +## Version 1.1.10 (2019-05-31) + +### Features: +- Optimized library startup time by moving imports into methods + + +## Version 1.1.9 (2019-05-08) + +### Features: +- Calendar: Included start/end check on get_events +- Attachments: Allow inline attachments +- Scope Helpers updated + + +## Version 1.1.8 (2019-04-22) + +### Features: +- Bug fixing release + + +## Version 1.1.7 (2019-04-22) + +### Features: +- Excel: Added Excel capabilities to Drive Files +- When returning a potentially big list of instances, the library now returns a generator instead a list + + +## Version 1.1.6 (2019-04-15) + +### Features: +- Message: Added Headers, internet message id and weblink + + +## Version 1.1.5 (2019-04-08) + +### Features: +- Query: Added search capabilities + + +## Version 1.1.4 (2019-04-03) + +### Features: +- Sharepoint: Ability to create, and edit listitems in sharepoint +- New default oauth redirect url + + +## Version 1.1.3 (2019-03-04) + +### Features: +- Message now recognizes EventMessages: an EventMessage can retrieve the related Event +- Added isReadReceiptRequested and isDeliveryReceiptRequested to Messag + + +## Version 1.1.2 (2019-02-19) + +### Features: +- Message can now handle flags +- ApiComponent now stores the logic to convert to/from dateTimeTimeZone resource + + +## Version 1.1.1 (2019-02-19) + +### Features: +- Connection: add tenant_id parameter +- Mailbox: Folder allows to get a message by id ('get_message'). +- Message: constructor now accepts a object_id parameter +- Message: new method 'save_message' now allows to save draft-independent properties of a message: is_read and categories for the moment. + + +## Version 1.1.0 (2019-02-04) + +### Features: +- Added Token Backends: Now tokens can be stored anywhere with a concise api +- TokenBackends available: FileSystemTokenBackend and FirestoreTokenBackend +- Token dict: tokens expose new properties like: "expiration_datetime", "is_expired" and "is_long_lived" +- Account: New property "account.is_authenticated": Checks if the token exists and if it is expired +- Connection: The "refresh_token" method now detects if the token can indeed be refreshed. + + +## Version 1.0.5 (2019-01-16) + +### Features: +- Bug fixing release + + +## Version 1.0.4 (2019-01-10) + +### Features: +- Calendar: `get_events` method now includes a new param 'include_recurring' to include all recurring events. Internally will request a calendarView if 'include_recurring' is True (this is the default behaviour). + + +## Version 1.0.3 (2019-01-10) + +### Features: +- Connection: HttpErrors now include the json error Message the server respond with +- Sharepoint: added new features +- Planner capabilites (Tasks) +- Event: Added method 'get_occurrences' to retrieve the recurring events of a seriesMaster event type. + + +## Version 1.0.2 (2018-11-29) + +### Features: +- Contact: now tracks it's inner state +- ContactFolders: New method get_contact_by_email on ContactFolders + + +## Version 1.0.1 (2018-11-27) + +### Features: +- Sharepoint capabilities + + +## Version 1.0 (2018-11-06) + +Library updated from the previous implementation. +Merged from [pyo365](https://github.com/janscas/pyo365). +[Merge pull request #135 from O365/rewrite](https://github.com/O365/python-o365/commit/a3d2b038a91c3954fb8f02502e5abd429be85d3c) diff --git a/MANIFEST b/MANIFEST deleted file mode 100644 index 65f111a7..00000000 --- a/MANIFEST +++ /dev/null @@ -1,12 +0,0 @@ -# file GENERATED by distutils, do NOT edit -setup.cfg -setup.py -O365/__init__.py -O365/attachment.py -O365/cal.py -O365/contact.py -O365/event.py -O365/group.py -O365/inbox.py -O365/message.py -O365/schedule.py diff --git a/O365/__init__.py b/O365/__init__.py index 6ac8c633..ab1cf987 100644 --- a/O365/__init__.py +++ b/O365/__init__.py @@ -1,30 +1,16 @@ -# Copyright 2015 by Toben "Narcolapser" Archer. All Rights Reserved. -# -# Permission to use, copy, modify, and distribute this software and its documentation for any purpose -# and without fee is hereby granted, provided that the above copyright notice appear in all copies and -# that both that copyright notice and this permission notice appear in supporting documentation, and -# that the name of Toben Archer not be used in advertising or publicity pertaining to distribution of -# the software without specific, written prior permission. TOBEN ARCHER DISCLAIMS ALL WARRANTIES WITH -# REGARD TO THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT -# SHALL TOBEN ARCHER BE LIABLE FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES -# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE -# OR OTHER TORTIOUS ACTION, ARISING OUT -# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +""" +A simple python library to interact with Microsoft Graph and other MS api +""" -''' -Python library for interfacing with the Microsoft Office 365 online. -''' -#__all__ = ['attachment','cal','contact','event','group','inbox','message','schedule'] +import warnings +import sys -# This imports all the libraries into the local namespace. This makes it easy to work with. +from .account import Account +from .connection import Connection, Protocol, MSGraphProtocol +from .utils import FileSystemTokenBackend, EnvTokenBackend +from .message import Message -from contact import Contact -from group import Group -from cal import Calendar -from event import Event -from attachment import Attachment -from inbox import Inbox -from message import Message -from schedule import Schedule -#To the King! +if sys.warnoptions: + # allow Deprecation warnings to appear + warnings.simplefilter("always", DeprecationWarning) diff --git a/O365/account.py b/O365/account.py new file mode 100644 index 00000000..16a8d50c --- /dev/null +++ b/O365/account.py @@ -0,0 +1,374 @@ +import warnings +from typing import Callable, List, Optional, Tuple, Type + +from .connection import Connection, MSGraphProtocol, Protocol +from .utils import ME_RESOURCE, consent_input_token + + +class Account: + connection_constructor: Type = Connection #: :meta private: + + def __init__(self, credentials: str | tuple[str, str], *, + username: Optional[str] = None, + protocol: Optional[Protocol] = None, + main_resource: Optional[str] = None, **kwargs): + """ Creates an object which is used to access resources related to the specified credentials. + + :param credentials: a tuple containing the client_id and client_secret + :param username: the username to be used by this account + :param protocol: the protocol to be used in this account + :param main_resource: the resource to be used by this account ('me' or 'users', etc.) + :param kwargs: any extra args to be passed to the Connection instance + :raises ValueError: if an invalid protocol is passed + """ + + protocol = protocol or MSGraphProtocol # Defaults to Graph protocol + if isinstance(protocol, type): + protocol = protocol(default_resource=main_resource, **kwargs) + # The protocol to use for the account. Defaults ot MSGraphProtocol. |br| **Type:** Protocol + self.protocol: Protocol = protocol + + if not isinstance(self.protocol, Protocol): + raise ValueError("'protocol' must be a subclass of Protocol") + + auth_flow_type = kwargs.get('auth_flow_type', 'authorization') + + if auth_flow_type not in ['authorization', 'public', 'credentials', 'password']: + raise ValueError('"auth_flow_type" must be "authorization", "credentials", "password" or "public"') + + scopes = kwargs.get('scopes', None) + if scopes: + del kwargs['scopes'] + warnings.warn("Since 2.1 scopes are only needed during authentication.", DeprecationWarning) + + if auth_flow_type == 'credentials': + # set main_resource to blank when it's the 'ME' resource + if self.protocol.default_resource == ME_RESOURCE: + self.protocol.default_resource = '' + if main_resource == ME_RESOURCE: + main_resource = '' + + elif auth_flow_type == 'password': + # set main_resource to blank when it's the 'ME' resource + if self.protocol.default_resource == ME_RESOURCE: + self.protocol.default_resource = '' + if main_resource == ME_RESOURCE: + main_resource = '' + + kwargs['username'] = username + + self.con = self.connection_constructor(credentials, **kwargs) + #: The resource in use for the account. |br| **Type:** str + self.main_resource: str = main_resource or self.protocol.default_resource + + def __repr__(self): + if self.con.auth: + return f'Account Client Id: {self.con.auth[0]}' + else: + return 'Unidentified Account' + + @property + def is_authenticated(self) -> bool: + """ + Checks whether the library has the authentication data and that is not expired for the current username. + This will try to load the token from the backend if not already loaded. + Return True if authenticated, False otherwise. + """ + if self.con.token_backend.has_data is False: + # try to load the token from the backend + if self.con.load_token_from_backend() is False: + return False + + return ( + self.con.token_backend.token_is_long_lived(username=self.con.username) + or not self.con.token_backend.token_is_expired(username=self.con.username) + ) + + def authenticate(self, *, requested_scopes: Optional[list] = None, redirect_uri: Optional[str] = None, + handle_consent: Callable = consent_input_token, **kwargs) -> bool: + """ Performs the console authentication flow resulting in a stored token. + It uses the credentials passed on instantiation. + Returns True if succeeded otherwise False. + + :param list[str] requested_scopes: list of protocol user scopes to be converted + by the protocol or scope helpers or raw scopes + :param str redirect_uri: redirect url configured in registered app + :param handle_consent: a function to handle the consent process by default just input for the token url + :param kwargs: other configurations to be passed to the + Connection.get_authorization_url and Connection.request_token methods + """ + + if self.con.auth_flow_type in ('authorization', 'public'): + consent_url, flow = self.get_authorization_url(requested_scopes, redirect_uri=redirect_uri, **kwargs) + + token_url = handle_consent(consent_url) + + if token_url: + result = self.request_token(token_url, flow=flow, **kwargs) + if result: + print('Authentication Flow Completed. Oauth Access Token Stored. You can now use the API.') + else: + print('Something go wrong. Please try again.') + + return result + else: + print('Authentication Flow aborted.') + return False + + elif self.con.auth_flow_type in ('credentials', 'password'): + return self.request_token(None, requested_scopes=requested_scopes, **kwargs) + + else: + raise ValueError('"auth_flow_type" must be "authorization", "public", "password" or "credentials"') + + def get_authorization_url(self, + requested_scopes: List[str], + redirect_uri: Optional[str] = None, + **kwargs) -> Tuple[str, dict]: + """ Initializes the oauth authorization flow, getting the + authorization url that the user must approve. + + :param list[str] requested_scopes: list of scopes to request access for + :param str redirect_uri: redirect url configured in registered app + :param kwargs: allow to pass unused params in conjunction with Connection + :return: authorization url and the flow dict + """ + + # convert request scopes based on the defined protocol + requested_scopes = self.protocol.get_scopes_for(requested_scopes) + + return self.con.get_authorization_url(requested_scopes, redirect_uri=redirect_uri, **kwargs) + + def request_token(self, authorization_url: Optional[str], *, + flow: dict = None, + requested_scopes: Optional[List[str]] = None, + store_token: bool = True, + **kwargs) -> bool: + """ Authenticates for the specified url and gets the oauth token data. Saves the + token in the backend if store_token is True. This will replace any other tokens stored + for the same username and scopes requested. + If the token data is successfully requested, then this method will try to set the username if + not previously set. + + :param str or None authorization_url: url given by the authorization flow or None if it's client credentials + :param dict flow: dict object holding the data used in get_authorization_url + :param list[str] requested_scopes: list of scopes to request access for + :param bool store_token: True to store the token in the token backend, + so you don't have to keep opening the auth link and + authenticating every time + :param kwargs: allow to pass unused params in conjunction with Connection + :return: Success/Failure + :rtype: bool + """ + if self.con.auth_flow_type == 'credentials': + if not requested_scopes: + requested_scopes = [self.protocol.prefix_scope('.default')] + else: + if len(requested_scopes) > 1 or requested_scopes[0] != self.protocol.prefix_scope('.default'): + raise ValueError('Provided scope for auth flow type "credentials" does not match ' + 'default scope for the current protocol') + elif self.con.auth_flow_type == 'password': + if requested_scopes: + requested_scopes = self.protocol.get_scopes_for(requested_scopes) + else: + requested_scopes = [self.protocol.prefix_scope('.default')] + else: + if requested_scopes: + raise ValueError(f'Auth flow type "{self.con.auth_flow_type}" does not require scopes') + + return self.con.request_token(authorization_url, + flow=flow, + requested_scopes=requested_scopes, + store_token=store_token, **kwargs) + + @property + def username(self) -> Optional[str]: + """ Returns the username in use for the account""" + return self.con.username + + def get_authenticated_usernames(self) -> list[str]: + """ Returns a list of usernames that are authenticated and have a valid access token or a refresh token.""" + usernames = [] + tb = self.con.token_backend + for account in self.con.token_backend.get_all_accounts(): + username = account.get('username') + if username and (tb.token_is_long_lived(username=username) or not tb.token_is_expired(username=username)): + usernames.append(username) + + return usernames + + @username.setter + def username(self, username: Optional[str]) -> None: + """ + Sets the username in use for this account + The username can be None, meaning the first user account retrieved from the token_backend + """ + self.con.username = username + + def get_current_user_data(self): + """ Returns the current user data from the active directory """ + if self.con.auth_flow_type in ('authorization', 'public'): + directory = self.directory(resource=ME_RESOURCE) + return directory.get_current_user() + else: + return None + + @property + def connection(self): + """ Alias for self.con + + :rtype: type(self.connection_constructor) + """ + return self.con + + def new_message(self, resource: Optional[str] = None): + """ Creates a new message to be sent or stored + + :param str resource: Custom resource to be used in this message + (Defaults to parent main_resource) + :return: New empty message + :rtype: Message + """ + from .message import Message + return Message(parent=self, main_resource=resource, is_draft=True) + + def mailbox(self, resource: Optional[str] = None): + """ Get an instance to the mailbox for the specified account resource + + :param resource: Custom resource to be used in this mailbox + (Defaults to parent main_resource) + :return: a representation of account mailbox + :rtype: O365.mailbox.MailBox + """ + from .mailbox import MailBox + return MailBox(parent=self, main_resource=resource, name='MailBox') + + def address_book(self, *, resource: Optional[str] = None, address_book: str = 'personal'): + """ Get an instance to the specified address book for the + specified account resource + + :param resource: Custom resource to be used in this address book + (Defaults to parent main_resource) + :param address_book: Choose from 'Personal' or 'Directory' + :return: a representation of the specified address book + :rtype: AddressBook or GlobalAddressList + :raises RuntimeError: if invalid address_book is specified + """ + if address_book.lower() == 'personal': + from .address_book import AddressBook + + return AddressBook(parent=self, main_resource=resource, + name='Personal Address Book') + elif address_book.lower() in ('gal', 'directory'): + # for backwards compatibility only + from .directory import Directory + + return Directory(parent=self, main_resource=resource) + else: + raise RuntimeError( + 'address_book must be either "Personal" ' + '(resource address book) or "Directory" (Active Directory)') + + def directory(self, resource: Optional[str] = None): + """ Returns the active directory instance""" + from .directory import USERS_RESOURCE, Directory + + return Directory(parent=self, main_resource=resource or USERS_RESOURCE) + + def schedule(self, *, resource: Optional[str] = None): + """ Get an instance to work with calendar events for the + specified account resource + + :param resource: Custom resource to be used in this schedule object + (Defaults to parent main_resource) + :return: a representation of calendar events + :rtype: Schedule + """ + from .calendar import Schedule + return Schedule(parent=self, main_resource=resource) + + def storage(self, *, resource: Optional[str] = None): + """ Get an instance to handle file storage (OneDrive / Sharepoint) + for the specified account resource + + :param resource: Custom resource to be used in this drive object + (Defaults to parent main_resource) + :return: a representation of OneDrive File Storage + :rtype: Storage + :raises RuntimeError: if protocol doesn't support the feature + """ + if not isinstance(self.protocol, MSGraphProtocol): + # TODO: Custom protocol accessing OneDrive/Sharepoint Api fails here + raise RuntimeError( + 'Drive options only works on Microsoft Graph API') + from .drive import Storage + return Storage(parent=self, main_resource=resource) + + def sharepoint(self, *, resource: str = ''): + """ Get an instance to read information from Sharepoint sites for the + specified account resource + + :param resource: Custom resource to be used in this sharepoint + object (Defaults to parent main_resource) + :return: a representation of Sharepoint Sites + :rtype: Sharepoint + :raises RuntimeError: if protocol doesn't support the feature + """ + + if not isinstance(self.protocol, MSGraphProtocol): + # TODO: Custom protocol accessing OneDrive/Sharepoint Api fails here + raise RuntimeError( + 'Sharepoint api only works on Microsoft Graph API') + + from .sharepoint import Sharepoint + return Sharepoint(parent=self, main_resource=resource) + + def planner(self, *, resource: str = ''): + """ Get an instance to read information from Microsoft planner """ + + if not isinstance(self.protocol, MSGraphProtocol): + # TODO: Custom protocol accessing OneDrive/Sharepoint Api fails here + raise RuntimeError( + 'planner api only works on Microsoft Graph API') + + from .planner import Planner + return Planner(parent=self, main_resource=resource) + + def tasks(self, *, resource: str = ''): + """ Get an instance to read information from Microsoft ToDo """ + + from .tasks import ToDo + + return ToDo(parent=self, main_resource=resource) + + def teams(self, *, resource: str = ''): + """ Get an instance to read information from Microsoft Teams """ + + if not isinstance(self.protocol, MSGraphProtocol): + raise RuntimeError( + 'teams api only works on Microsoft Graph API') + + from .teams import Teams + return Teams(parent=self, main_resource=resource) + + def outlook_categories(self, *, resource: str = ''): + """ Returns a Categories object to handle the available Outlook Categories """ + from .category import Categories + + return Categories(parent=self, main_resource=resource) + + def groups(self, *, resource: str = ''): + """ Get an instance to read information from Microsoft Groups """ + + if not isinstance(self.protocol, MSGraphProtocol): + raise RuntimeError( + 'groups api only works on Microsoft Graph API') + + from .groups import Groups + return Groups(parent=self, main_resource=resource) + + def subscriptions(self, *, resource: str = ''): + """ Get an instance to manage MS Graph subscriptions """ + + from .subscriptions import Subscriptions + return Subscriptions(parent=self, main_resource=resource) diff --git a/O365/address_book.py b/O365/address_book.py new file mode 100644 index 00000000..dc9e4d3a --- /dev/null +++ b/O365/address_book.py @@ -0,0 +1,1026 @@ +import datetime as dt +import logging + +from dateutil.parser import parse +from requests.exceptions import HTTPError + +from .category import Category +from .message import Message, RecipientType +from .utils import ( + NEXT_LINK_KEYWORD, + ApiComponent, + AttachableMixin, + Pagination, + Recipients, + TrackerSet, +) + +log = logging.getLogger(__name__) + + +class Contact(ApiComponent, AttachableMixin): + """ Contact manages lists of events on associated contact on Microsoft 365. """ + + _endpoints = { + 'contact': '/contacts', + 'root_contact': '/contacts/{id}', + 'child_contact': '/contactFolders/{folder_id}/contacts', + 'photo': '/contacts/{id}/photo/$value', + 'photo_size': '/contacts/{id}/photos/{size}/$value', + } + + message_constructor = Message #: :meta private: + + def __init__(self, *, parent=None, con=None, **kwargs): + """ Create a contact API component + + :param parent: parent account for this folder + :type parent: Account + :param Connection con: connection to use if no parent specified + :param Protocol protocol: protocol to use if no parent specified + (kwargs) + :param str main_resource: use this resource instead of parent resource + (kwargs) + """ + if parent and con: + raise ValueError('Need a parent or a connection but not both') + self.con = parent.con if parent else con + + # Choose the main_resource passed in kwargs over parent main_resource + main_resource = kwargs.pop('main_resource', None) or ( + getattr(parent, 'main_resource', None) if parent else None) + + super().__init__( + protocol=parent.protocol if parent else kwargs.get('protocol'), + main_resource=main_resource) + + cloud_data = kwargs.get(self._cloud_data_key, {}) + cc = self._cc # alias to shorten the code + + # internal to know which properties need to be updated on the server + self._track_changes = TrackerSet(casing=cc) + + #: The contact's unique identifier. |br| **Type:** str + self.object_id = cloud_data.get(cc('id'), None) + self.__created = cloud_data.get(cc('createdDateTime'), None) + self.__modified = cloud_data.get(cc('lastModifiedDateTime'), None) + + local_tz = self.protocol.timezone + self.__created = parse(self.__created).astimezone( + local_tz) if self.__created else None + self.__modified = parse(self.__modified).astimezone( + local_tz) if self.__modified else None + + self.__display_name = cloud_data.get(cc('displayName'), '') + self.__fileAs = cloud_data.get(cc('fileAs'), '') + self.__name = cloud_data.get(cc('givenName'), '') + self.__surname = cloud_data.get(cc('surname'), '') + + self.__title = cloud_data.get(cc('title'), '') + self.__job_title = cloud_data.get(cc('jobTitle'), '') + self.__company_name = cloud_data.get(cc('companyName'), '') + self.__department = cloud_data.get(cc('department'), '') + self.__office_location = cloud_data.get(cc('officeLocation'), '') + self.__business_phones = cloud_data.get(cc('businessPhones'), []) or [] + self.__mobile_phone = cloud_data.get(cc('mobilePhone'), '') + self.__home_phones = cloud_data.get(cc('homePhones'), []) or [] + + emails = cloud_data.get(cc('emailAddresses'), []) + self.__emails = Recipients( + recipients=[(rcp.get(cc('name'), ''), rcp.get(cc('address'), '')) + for rcp in emails], + parent=self, field=cc('emailAddresses')) + email = cloud_data.get(cc('email')) + self.__emails.untrack = True + if email and email not in self.__emails: + # a Contact from OneDrive? + self.__emails.add(email) + self.__business_address = cloud_data.get(cc('businessAddress'), {}) + self.__home_address = cloud_data.get(cc('homeAddress'), {}) + self.__other_address = cloud_data.get(cc('otherAddress'), {}) + self.__preferred_language = cloud_data.get(cc('preferredLanguage'), + None) + + self.__categories = cloud_data.get(cc('categories'), []) + self.__folder_id = cloud_data.get(cc('parentFolderId'), None) + + self.__personal_notes = cloud_data.get(cc('personalNotes'), '') + + # When using Users endpoints (GAL) + # Missing keys: ['mail', 'userPrincipalName'] + mail = cloud_data.get(cc('mail'), None) + user_principal_name = cloud_data.get(cc('userPrincipalName'), None) + if mail and mail not in self.emails: + self.emails.add(mail) + if user_principal_name and user_principal_name not in self.emails: + self.emails.add(user_principal_name) + self.__emails.untrack = False + + def __str__(self): + return self.__repr__() + + def __repr__(self): + return self.display_name or self.full_name or 'Unknown Name' + + def __eq__(self, other): + return self.object_id == other.object_id + + @property + def created(self): + """ Created Time + + :rtype: datetime + """ + return self.__created + + @property + def modified(self): + """ Last Modified Time + + :rtype: datetime + """ + return self.__modified + + @property + def display_name(self): + """ Display Name + + :getter: Get the display name of the contact + :setter: Update the display name + :type: str + """ + return self.__display_name + + @display_name.setter + def display_name(self, value): + self.__display_name = value + self._track_changes.add(self._cc('displayName')) + + @property + def fileAs(self): + """ File As + + :getter: Get the fileAs of the contact + :setter: Update the fileAs + :type: str + """ + return self.__fileAs + + @fileAs.setter + def fileAs(self, value): + self.__fileAs = value + self._track_changes.add(self._cc('fileAs')) + + @property + def name(self): + """ First Name + + :getter: Get the name of the contact + :setter: Update the name + :type: str + """ + return self.__name + + @name.setter + def name(self, value): + self.__name = value + self._track_changes.add(self._cc('givenName')) + + @property + def surname(self): + """ Surname of Contact + + :getter: Get the surname of the contact + :setter: Update the surname + :type: str + """ + return self.__surname + + @surname.setter + def surname(self, value): + self.__surname = value + self._track_changes.add(self._cc('surname')) + + @property + def full_name(self): + """ Full Name (Name + Surname) + + :rtype: str + """ + return '{} {}'.format(self.name, self.surname).strip() + + @property + def title(self): + """ Title (Mr., Ms., etc..) + + :getter: Get the title of the contact + :setter: Update the title + :type: str + """ + return self.__title + + @title.setter + def title(self, value): + self.__title = value + self._track_changes.add(self._cc('title')) + + @property + def job_title(self): + """ Job Title + + :getter: Get the job title of contact + :setter: Update the job title + :type: str + """ + return self.__job_title + + @job_title.setter + def job_title(self, value): + self.__job_title = value + self._track_changes.add(self._cc('jobTitle')) + + @property + def company_name(self): + """ Name of the company + + :getter: Get the company name of contact + :setter: Update the company name + :type: str + """ + return self.__company_name + + @company_name.setter + def company_name(self, value): + self.__company_name = value + self._track_changes.add(self._cc('companyName')) + + @property + def department(self): + """ Department + + :getter: Get the department of contact + :setter: Update the department + :type: str + """ + return self.__department + + @department.setter + def department(self, value): + self.__department = value + self._track_changes.add(self._cc('department')) + + @property + def office_location(self): + """ Office Location + + :getter: Get the office location of contact + :setter: Update the office location + :type: str + """ + return self.__office_location + + @office_location.setter + def office_location(self, value): + self.__office_location = value + self._track_changes.add(self._cc('officeLocation')) + + @property + def business_phones(self): + """ Business Contact numbers + + :getter: Get the contact numbers of contact + :setter: Update the contact numbers + :type: list[str] + """ + return self.__business_phones + + @business_phones.setter + def business_phones(self, value): + if isinstance(value, tuple): + value = list(value) + if not isinstance(value, list): + value = [value] + self.__business_phones = value + self._track_changes.add(self._cc('businessPhones')) + + @property + def mobile_phone(self): + """ Personal Contact numbers + + :getter: Get the contact numbers of contact + :setter: Update the contact numbers + :type: list[str] + """ + return self.__mobile_phone + + @mobile_phone.setter + def mobile_phone(self, value): + self.__mobile_phone = value + self._track_changes.add(self._cc('mobilePhone')) + + @property + def home_phones(self): + """ Home Contact numbers + + :getter: Get the contact numbers of contact + :setter: Update the contact numbers + :type: list[str] + """ + return self.__home_phones + + @home_phones.setter + def home_phones(self, value): + if isinstance(value, list): + self.__home_phones = value + elif isinstance(value, str): + self.__home_phones = [value] + elif isinstance(value, tuple): + self.__home_phones = list(value) + else: + raise ValueError('home_phones must be a list') + self._track_changes.add(self._cc('homePhones')) + + @property + def emails(self): + """ List of email ids of the Contact + + :rtype: Recipients + """ + return self.__emails + + @property + def main_email(self): + """ Primary(First) email id of the Contact + + :rtype: str + """ + if not self.emails: + return None + return self.emails[0].address + + @property + def business_address(self): + """ Business Address + + :getter: Get the address of contact + :setter: Update the address + :type: dict + """ + return self.__business_address + + @business_address.setter + def business_address(self, value): + if not isinstance(value, dict): + raise ValueError('"business_address" must be dict') + self.__business_address = value + self._track_changes.add(self._cc('businessAddress')) + + @property + def home_address(self): + """ Home Address + + :getter: Get the address of contact + :setter: Update the address + :type: dict + """ + return self.__home_address + + @home_address.setter + def home_address(self, value): + if not isinstance(value, dict): + raise ValueError('"home_address" must be dict') + self.__home_address = value + self._track_changes.add(self._cc('homeAddress')) + + @property + def other_address(self): + """ Other Address + + :getter: Get the address of contact + :setter: Update the address + :type: dict + """ + return self.__other_address + + @other_address.setter + def other_address(self, value): + if not isinstance(value, dict): + raise ValueError('"other_address" must be dict') + self.__other_address = value + self._track_changes.add(self._cc('otherAddress')) + + @property + def preferred_language(self): + """ Preferred Language + + :getter: Get the language of contact + :setter: Update the language + :type: str + """ + return self.__preferred_language + + @preferred_language.setter + def preferred_language(self, value): + self.__preferred_language = value + self._track_changes.add(self._cc('preferredLanguage')) + + @property + def categories(self): + """ Assigned Categories + + :getter: Get the categories + :setter: Update the categories + :type: list[str] + """ + return self.__categories + + @categories.setter + def categories(self, value): + if isinstance(value, list): + self.__categories = [] + for val in value: + if isinstance(val, Category): + self.__categories.append(val.name) + else: + self.__categories.append(val) + elif isinstance(value, str): + self.__categories = [value] + elif isinstance(value, Category): + self.__categories = [value.name] + else: + raise ValueError('categories must be a list') + self._track_changes.add(self._cc('categories')) + + @property + def personal_notes(self): + return self.__personal_notes + + @personal_notes.setter + def personal_notes(self, value): + self.__personal_notes = value + self._track_changes.add(self._cc('personalNotes')) + + @property + def folder_id(self): + """ID of the containing folder + + :rtype: str + """ + return self.__folder_id + + def to_api_data(self, restrict_keys=None): + """ Returns a dictionary in cloud format + + :param restrict_keys: a set of keys to restrict the returned data to. + """ + cc = self._cc # alias + + data = { + cc('displayName'): self.__display_name, + cc('fileAs'): self.__fileAs, + cc('givenName'): self.__name, + cc('surname'): self.__surname, + cc('title'): self.__title, + cc('jobTitle'): self.__job_title, + cc('companyName'): self.__company_name, + cc('department'): self.__department, + cc('officeLocation'): self.__office_location, + cc('businessPhones'): self.__business_phones, + cc('mobilePhone'): self.__mobile_phone, + cc('homePhones'): self.__home_phones, + cc('emailAddresses'): [{self._cc('name'): recipient.name or '', + self._cc('address'): recipient.address} + for recipient in self.emails], + cc('businessAddress'): self.__business_address, + cc('homeAddress'): self.__home_address, + cc('otherAddress'): self.__other_address, + cc('categories'): self.__categories, + cc('personalNotes'): self.__personal_notes, + } + + if restrict_keys: + restrict_keys.add(cc( + 'givenName')) # GivenName is required by the api all the time. + for key in list(data.keys()): + if key not in restrict_keys: + del data[key] + return data + + def delete(self): + """ Deletes this contact + + :return: Success or Failure + :rtype: bool + :raises RuntimeError: if contact is not yet saved to cloud + """ + if not self.object_id: + raise RuntimeError('Attempting to delete an unsaved Contact') + + url = self.build_url( + self._endpoints.get('root_contact').format(id=self.object_id)) + + response = self.con.delete(url) + + return bool(response) + + def save(self): + """ Saves this contact to the cloud (create or update existing one + based on what values have changed) + + :return: Saved or Not + :rtype: bool + """ + if self.object_id: + # Update Contact + if not self._track_changes: + return True # there's nothing to update + url = self.build_url( + self._endpoints.get('root_contact').format(id=self.object_id)) + method = self.con.patch + data = self.to_api_data(restrict_keys=self._track_changes) + else: + # Save new Contact + if self.__folder_id: + url = self.build_url( + self._endpoints.get('child_contact').format( + folder_id=self.__folder_id)) + else: + url = self.build_url(self._endpoints.get('contact')) + method = self.con.post + data = self.to_api_data(restrict_keys=self._track_changes) + response = method(url, data=data) + + if not response: + return False + + if not self.object_id: + # New Contact + contact = response.json() + + self.object_id = contact.get(self._cc('id'), None) + + self.__created = contact.get(self._cc('createdDateTime'), None) + self.__modified = contact.get(self._cc('lastModifiedDateTime'), + None) + + local_tz = self.protocol.timezone + self.__created = parse(self.created).astimezone( + local_tz) if self.__created else None + self.__modified = parse(self.modified).astimezone( + local_tz) if self.__modified else None + else: + self.__modified = dt.datetime.now().replace(tzinfo=self.protocol.timezone) + + return True + + def new_message(self, recipient=None, *, recipient_type=RecipientType.TO): + """ This method returns a new draft Message instance with + contacts first email as a recipient + + :param Recipient recipient: a Recipient instance where to send this + message. If None first email of this contact will be used + :param RecipientType recipient_type: section to add recipient into + :return: newly created message + :rtype: Message or None + """ + + if isinstance(recipient_type, str): + recipient_type = RecipientType(recipient_type) + + recipient = recipient or self.emails.get_first_recipient_with_address() + if not recipient: + return None + + new_message = self.message_constructor(parent=self, is_draft=True) + + target_recipients = getattr(new_message, str(recipient_type.value)) + target_recipients.add(recipient) + + return new_message + + def get_profile_photo(self, size=None): + """Returns this contact profile photo + + :param str size: 48x48, 64x64, 96x96, 120x120, 240x240, + 360x360, 432x432, 504x504, and 648x648 + """ + if size is None: + url = self.build_url(self._endpoints.get('photo').format(id=self.object_id)) + else: + url = self.build_url(self._endpoints.get('photo_size').format(id=self.object_id, size=size)) + + try: + response = self.con.get(url) + except HTTPError as e: + log.debug('Error while retrieving the contact profile photo. Error: {}'.format(e)) + return None + + if not response: + return None + + return response.content + + def update_profile_photo(self, photo): + """ Updates this contact profile photo + :param bytes photo: the photo data in bytes + """ + + url = self.build_url(self._endpoints.get('photo').format(id=self.object_id)) + response = self.con.patch(url, data=photo, headers={'Content-type': 'image/jpeg'}) + + return bool(response) + + +class BaseContactFolder(ApiComponent): + """ Base Contact Folder Grouping Functionality """ + + _endpoints = { + 'root_contacts': '/contacts', + 'folder_contacts': '/contactFolders/{id}/contacts', + 'get_folder': '/contactFolders/{id}', + 'root_folders': '/contactFolders', + 'child_folders': '/contactFolders/{id}/childFolders' + } + + contact_constructor = Contact #: :meta private: + message_constructor = Message #: :meta private: + + def __init__(self, *, parent=None, con=None, **kwargs): + """ Create a contact folder component + + :param parent: parent folder/account for this folder + :type parent: BaseContactFolder or Account + :param Connection con: connection to use if no parent specified + :param Protocol protocol: protocol to use if no parent specified + (kwargs) + :param str main_resource: use this resource instead of parent resource + (kwargs) + """ + if parent and con: + raise ValueError('Need a parent or a connection but not both') + self.con = parent.con if parent else con + + # Choose the main_resource passed in kwargs over parent main_resource + main_resource = kwargs.pop('main_resource', None) or ( + getattr(parent, 'main_resource', None) if parent else None) + + super().__init__( + protocol=parent.protocol if parent else kwargs.get('protocol'), + main_resource=main_resource) + + # This folder has no parents if root = True. + #: Indicates if this is the root folder. |br| **Type:** bool + self.root = kwargs.pop('root', False) + + cloud_data = kwargs.get(self._cloud_data_key, {}) + + # Fallback to manual folder if nothing available on cloud data + #: The folder's display name. |br| **Type:** str + self.name = cloud_data.get(self._cc('displayName'), + kwargs.get('name', + '')) + # TODO: Most of above code is same as mailbox.Folder __init__ + + #: Unique identifier of the contact folder. |br| **Type:** str + self.folder_id = cloud_data.get(self._cc('id'), None) + #: The ID of the folder's parent folder. |br| **Type:** str + self.parent_id = cloud_data.get(self._cc('parentFolderId'), None) + + def __str__(self): + return self.__repr__() + + def __repr__(self): + return 'Contact Folder: {}'.format(self.name) + + def __eq__(self, other): + return self.folder_id == other.folder_id + + def get_contacts(self, limit=100, *, query=None, order_by=None, batch=None): + """ Gets a list of contacts from this address book + + To use query an order_by check the OData specification here: + http://docs.oasis-open.org/odata/odata/v4.0/errata03/os/complete/ + part2-url-conventions/odata-v4.0-errata03-os-part2-url-conventions + -complete.html + + :param limit: max no. of contacts to get. Over 999 uses batch. + :type limit: int or None + :param query: applies a OData filter to the request + :type query: Query or str + :param order_by: orders the result set based on this condition + :type order_by: Query or str + :param int batch: batch size, retrieves items in + batches allowing to retrieve more items than the limit. + :return: list of contacts + :rtype: list[Contact] or Pagination + """ + + if self.root: + url = self.build_url(self._endpoints.get('root_contacts')) + else: + url = self.build_url( + self._endpoints.get('folder_contacts').format( + id=self.folder_id)) + + if limit is None or limit > self.protocol.max_top_value: + batch = self.protocol.max_top_value + + params = {'$top': batch if batch else limit} + + if order_by: + params['$orderby'] = order_by + + if query: + if isinstance(query, str): + params['$filter'] = query + else: + params.update(query.as_params()) + + response = self.con.get(url, params=params) + if not response: + return iter(()) + + data = response.json() + + # Everything received from cloud must be passed as self._cloud_data_key + contacts = (self.contact_constructor(parent=self, + **{self._cloud_data_key: contact}) + for contact in data.get('value', [])) + + next_link = data.get(NEXT_LINK_KEYWORD, None) + + if batch and next_link: + return Pagination(parent=self, data=contacts, + constructor=self.contact_constructor, + next_link=next_link, limit=limit) + else: + return contacts + + def get_contact_by_email(self, email): + """ Returns a Contact by it's email + + :param email: email to get contact for + :return: Contact for specified email + :rtype: Contact + """ + if not email: + return None + + query = self.q().any(collection='email_addresses', attribute='address', + word=email.strip(), operation='eq') + contacts = list(self.get_contacts(limit=1, query=query)) + return contacts[0] if contacts else None + + +class ContactFolder(BaseContactFolder): + """ A Contact Folder representation """ + + def get_folder(self, folder_id=None, folder_name=None): + """ Returns a Contact Folder by it's id or child folders by name + + :param folder_id: the folder_id to be retrieved. + Can be any folder Id (child or not) + :param folder_name: the folder name to be retrieved. + Must be a child of this folder + :return: a single contact folder + :rtype: ContactFolder + """ + + if folder_id and folder_name: + raise RuntimeError('Provide only one of the options') + + if not folder_id and not folder_name: + raise RuntimeError('Provide one of the options') + + if folder_id: + # get folder by it's id, independent of the parent of this folder_id + url = self.build_url( + self._endpoints.get('get_folder').format(id=folder_id)) + params = None + else: + # get folder by name. Only looks up in child folders. + if self.root: + url = self.build_url(self._endpoints.get('root_folders')) + else: + url = self.build_url( + self._endpoints.get('child_folders').format( + id=self.folder_id)) + + params = {'$filter': "{} eq '{}'".format(self._cc('displayName'), + folder_name), '$top': 1} + + response = self.con.get(url, params=params) + if not response: + return None + + if folder_id: + folder = response.json() + else: + folder = response.json().get('value') + folder = folder[0] if folder else None + if folder is None: + return None + + # Everything received from cloud must be passed as self._cloud_data_key + # we don't pass parent, as this folder may not be a child of self. + return self.__class__(con=self.con, protocol=self.protocol, + main_resource=self.main_resource, + **{self._cloud_data_key: folder}) + + def get_folders(self, limit=None, *, query=None, order_by=None): + """ Returns a list of child folders + + :param int limit: max no. of folders to get. Over 999 uses batch. + :param query: applies a OData filter to the request + :type query: Query or str + :param order_by: orders the result set based on this condition + :type order_by: Query or str + :return: list of folders + :rtype: list[ContactFolder] + """ + if self.root: + url = self.build_url(self._endpoints.get('root_folders')) + else: + url = self.build_url( + self._endpoints.get('child_folders').format(id=self.folder_id)) + + params = {} + + if limit: + params['$top'] = limit + + if order_by: + params['$orderby'] = order_by + + if query: + if isinstance(query, str): + params['$filter'] = query + else: + params.update(query.as_params()) + + response = self.con.get(url, params=params or None) + if not response: + return [] + + data = response.json() + + return [self.__class__(parent=self, **{self._cloud_data_key: folder}) + for folder in data.get('value', [])] + + def create_child_folder(self, folder_name): + """ Creates a new child folder + + :param str folder_name: name of the new folder to create + :return: newly created folder + :rtype: ContactFolder or None + """ + + if not folder_name: + return None + + if self.root: + url = self.build_url(self._endpoints.get('root_folders')) + else: + url = self.build_url( + self._endpoints.get('child_folders').format(id=self.folder_id)) + + response = self.con.post(url, + data={self._cc('displayName'): folder_name}) + if not response: + return None + + folder = response.json() + + # Everything received from cloud must be passed as self._cloud_data_key + return self.__class__(parent=self, **{self._cloud_data_key: folder}) + + def update_folder_name(self, name): + """ Change this folder name + + :param str name: new name to change to + :return: Updated or Not + :rtype: bool + """ + if self.root: + return False + if not name: + return False + + url = self.build_url( + self._endpoints.get('get_folder').format(id=self.folder_id)) + + response = self.con.patch(url, data={self._cc('displayName'): name}) + if not response: + return False + + folder = response.json() + + self.name = folder.get(self._cc('displayName'), '') + self.parent_id = folder.get(self._cc('parentFolderId'), None) + + return True + + def move_folder(self, to_folder): + """ Change this folder name + + :param to_folder: folder_id/ContactFolder to move into + :type to_folder: str or ContactFolder + :return: Moved or Not + :rtype: bool + """ + if self.root: + return False + if not to_folder: + return False + + url = self.build_url( + self._endpoints.get('get_folder').format(id=self.folder_id)) + + if isinstance(to_folder, ContactFolder): + folder_id = to_folder.folder_id + elif isinstance(to_folder, str): + folder_id = to_folder + else: + return False + + response = self.con.patch(url, + data={self._cc('parentFolderId'): folder_id}) + if not response: + return False + + folder = response.json() + + self.name = folder.get(self._cc('displayName'), '') + self.parent_id = folder.get(self._cc('parentFolderId'), None) + + return True + + def delete(self): + """ Deletes this folder + + :return: Deleted or Not + :rtype: bool + """ + + if self.root or not self.folder_id: + return False + + url = self.build_url( + self._endpoints.get('get_folder').format(id=self.folder_id)) + + response = self.con.delete(url) + if not response: + return False + + self.folder_id = None + + return True + + def new_contact(self): + """ Creates a new contact to be saved into it's parent folder + + :return: newly created contact + :rtype: Contact + """ + contact = self.contact_constructor(parent=self) + if not self.root: + contact.__folder_id = self.folder_id + return contact + + def new_message(self, recipient_type=RecipientType.TO, *, query=None): + """ This method returns a new draft Message instance with all the + contacts first email as a recipient + + :param RecipientType recipient_type: section to add recipient into + :param query: applies a OData filter to the request + :type query: Query or str + :return: newly created message + :rtype: Message or None + """ + + if isinstance(recipient_type, str): + recipient_type = RecipientType(recipient_type) + + recipients = [contact.emails[0] + for contact in self.get_contacts(limit=None, query=query) + if contact.emails and contact.emails[0].address] + + if not recipients: + return None + + new_message = self.message_constructor(parent=self, is_draft=True) + target_recipients = getattr(new_message, str(recipient_type.value)) + target_recipients.add(recipients) + + return new_message + + +class AddressBook(ContactFolder): + """ A class representing an address book """ + + def __init__(self, *, parent=None, con=None, **kwargs): + # Set instance to be a root instance + super().__init__(parent=parent, con=con, root=True, **kwargs) + + def __repr__(self): + return 'Address Book resource: {}'.format(self.main_resource) diff --git a/O365/attachment.py b/O365/attachment.py deleted file mode 100644 index 25d1e65a..00000000 --- a/O365/attachment.py +++ /dev/null @@ -1,177 +0,0 @@ -# Copyright 2015 by Toben "Narcolapser" Archer. All Rights Reserved. -# -# Permission to use, copy, modify, and distribute this software and its documentation for any purpose -# and without fee is hereby granted, provided that the above copyright notice appear in all copies and -# that both that copyright notice and this permission notice appear in supporting documentation, and -# that the name of Toben Archer not be used in advertising or publicity pertaining to distribution of -# the software without specific, written prior permission. TOBEN ARCHER DISCLAIMS ALL WARRANTIES WITH -# REGARD TO THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT -# SHALL TOBEN ARCHER BE LIABLE FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES -# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE -# OR OTHER TORTIOUS ACTION, ARISING OUT -# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. - -''' -This file contains the functions for working with attachments. Including the ability to work with the -binary of the file directly. The file is stored locally as a string using base64 encoding. -''' - -#from O365 import Message - -import base64 -import logging -import json -import requests - -#from O365 import Message - -logging.basicConfig(filename='o365.log',level=logging.DEBUG) - -log = logging.getLogger(__name__) - -class Attachment( object ): - ''' - Attachment class is the object for dealing with attachments in your messages. To add one to - a message, simply append it to the message's attachment list (message.attachments). - - these are stored locally in base64 encoded strings. You can pass either a byte string or a - base64 encoded string tot he appropriate set function to bring your attachment into the - instance, which will of course need to happen before it could be mailed. - - Methods: - isType - compares file extension to extension given. not case sensative. - getType - returns file extension. - save - save attachment locally. - getByteString - returns the attached file as a byte string. - setByteString - set the attached file using a byte string. - getBase64 - returns the attached file as a base64 encoded string. - setBase64 - set the attached file using a base64 encoded string. - ''' - - create_url = 'https://outlook.office365.com/api/v1.0/me/messages/{0}/attachments' - - def __init__(self,json=None,path=None): - ''' - Creates a new attachment class, optionally from existing JSON. - - Keyword Arguments: - json -- json to create the class from. this is mostly used by the class internally when an - attachment is downloaded from the cloud. If you want to create a new attachment, leave this - empty. (default = None) - path -- a string giving the path to a file. it is cross platform as long as you break - windows convention and use '/' instead of '\'. Passing this argument will tend to - the rest of the process of making an attachment. Note that passing in json as well - will cause this argument to be ignored. - ''' - if json: - self.json = json - self.isPDF = '.pdf' in self.json['Name'].lower() - elif path: - with open(path,'rb') as val: - self.json = {'@odata.type':'#Microsoft.OutlookServices.FileAttachment'} - self.isPDF = '.pdf' in path.lower() - - self.setByteString(val.read()) - try: - self.setName(path[path.rindex('/')+1:]) - except: - self.setName(path) - else: - self.json = {'@odata.type':'#Microsoft.OutlookServices.FileAttachment'} - - def isType(self,typeString): - '''Test to if the attachment is the same type as you are seeking. Do not include a period.''' - return '.'+typeString.lower() in self.json['Name'].lower() - - def getType(self): - '''returns the file extension''' - return self.json['Name'][self.json['Name'].rindex('.'):] - - def save(self,location): - '''Save the attachment locally to disk. - - location -- path to where the file is to be saved. - ''' - try: - outs = open(location+'/'+self.json['Name'],'wb') - outs.write(base64.b64decode(self.json['ContentBytes'])) - outs.close() - log.debug('file saved locally.') - - except Exception as e: - log.debug('file failed to be saved: %s',str(e)) - return False - - log.debug('file saving successful') - return True - - def attach(self,message): - ''' - This does the actual creating of the attachment as well as attaching to a message. - - message -- a Message type, the message to be attached to. - ''' - mid = message.json['Id'] - - headers = {'Content-type': 'application/json', 'Accept': 'application/json'} - - data = json.dumps(self.json) - - response = requests.post(self.create_url.format(mid),data,header=headers,auth=message.auth) - log.debug('Response from server for attaching: {0}'.format(str(response))) - - return response - - def getByteString(self): - '''Fetch the binary representation of the file. useful for times you want to - skip the step of saving before sending it to another program. This allows - you to make scripts that use linux pipe lines in their execution. - ''' - try: - return base64.b64decode(self.json['ContentBytes']) - - except Exception as e: - log.debug('what? no clue what went wrong here. cannot decode attachment.') - - return False - - def getBase64(self): - '''Returns the base64 encoding representation of the attachment.''' - try: - return self.json['ContentBytes'] - except Exception as e: - log.debug('what? no clue what went wrong here. probably no attachment.') - return False - - def getName(self): - '''Returns the file name.''' - try: - return self.json['Name'] - except Exception as e: - log.error('The attachment does not appear to have a name.') - return False - - def setName(self,val): - '''Set the name for the file.''' - self.json['Name'] = val - - def setByteString(self,val): - '''Sets the file for this attachment from a byte string.''' - try: - self.json['ContentBytes'] = base64.encodestring(val) - except: - log.debug('error encoding attachment.') - return False - return True - - def setBase64(self,val): - '''Sets the file for this attachment from a base64 encoding.''' - try: - base64.decodestring(val) - except: - log.error('tried to give me an attachment as a base64 and it is not.') - raise - self.json['ContentBytes'] = val - return true - -#To the King! diff --git a/O365/cal.py b/O365/cal.py deleted file mode 100644 index 2a6cd277..00000000 --- a/O365/cal.py +++ /dev/null @@ -1,127 +0,0 @@ -# Copyright 2015 by Toben "Narcolapser" Archer. All Rights Reserved. -# -# Permission to use, copy, modify, and distribute this software and its documentation for any purpose -# and without fee is hereby granted, provided that the above copyright notice appear in all copies and -# that both that copyright notice and this permission notice appear in supporting documentation, and -# that the name of Toben Archer not be used in advertising or publicity pertaining to distribution of -# the software without specific, written prior permission. TOBEN ARCHER DISCLAIMS ALL WARRANTIES WITH -# REGARD TO THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT -# SHALL TOBEN ARCHER BE LIABLE FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES -# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE -# OR OTHER TORTIOUS ACTION, ARISING OUT -# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. - -import requests -import base64 -import json -import logging -import time - -from O365.event import Event - -logging.basicConfig(filename='o365.log',level=logging.DEBUG) - -log = logging.getLogger(__name__) - -class Calendar( object ): - ''' - Calendar manages lists of events on an associated calendar on office365. - - Methods: - getName - Returns the name of the calendar. - getCalendarId - returns the GUID that identifies the calendar on office365 - getId - synonym of getCalendarId - getEvents - kicks off the process of fetching events. - fetchEvents - legacy duplicate of getEvents - - Variable: - events_url - the url that is actually called to fetch events. takes an ID, start, and end. - time_string - used for converting between struct_time and json's time format. - ''' - events_url = 'https://outlook.office365.com/api/v1.0/me/calendars/{0}/calendarview?startDateTime={1}&endDateTime={2}' - time_string = '%Y-%m-%dT%H:%M:%SZ' - - def __init__(self, json=None, auth=None): - ''' - Wraps all the informaiton for managing calendars. - ''' - self.json = json - self.auth = auth - self.events = [] - - if json: - log.debug('translating calendar information into local variables.') - self.calendarId = json['Id'] - self.name = json['Name'] - - def getName(self): - '''Get the calendar's Name.''' - return self.json['Name'] - - def getCalendarId(self): - '''Get calendar's GUID for office 365. mostly used interally in this library.''' - return self.json['Id'] - - def getId(self): - '''Get calendar's GUID for office 365. mostly used interally in this library.''' - return self.getCalendarId() - - def fetchEvents(self,start=None,end=None): - ''' - So I originally made this function "fetchEvents" which was a terrible idea. Everything else - is "getX" except events which were appearenty to good for that. So this function is just a - pass through for legacy sake. - ''' - return self.getEvents(start,end) - - - def getEvents(self,start=None,end=None): - ''' - Pulls events in for this calendar. default range is today to a year now. - - Keyword Arguments: - start -- The starting date from where you want to begin requesting events. The expected - type is a struct_time. Default is today. - end -- The ending date to where you want to end requesting events. The expected - type is a struct_time. Default is a year from start. - ''' - - #If no start time has been supplied, it is assumed you want to start as of now. - if not start: - start = time.strftime(self.time_string) - - #If no end time has been supplied, it is assumed you want the end time to be a year - #from what ever the start date was. - if not end: - end = time.time() - end += 3600*24*365 - end = time.gmtime(end) - end = time.strftime(self.time_string,end) - - #This is where the actual call to Office365 happens. - response = requests.get(self.events_url.format(self.json['Id'],start,end),auth=self.auth) - log.info('Response from O365: %s', str(response)) - - #This takes that response and then parses it into individual calendar events. - for event in response.json()['value']: - try: - duplicate = False - - #checks to see if the event is a duplicate. if it is local changes are clobbered. - for i,e in enumerate(self.events): - if e.json['Id'] == event['Id']: - self.events[i] = Event(event,self.auth,self) - duplicate = True - break - - if not duplicate: - self.events.append(Event(event,self.auth,self)) - - log.debug('appended event: %s',event['Subject']) - except Exception as e: - log.info('failed to append calendar: %',str(e)) - - log.debug('all events retrieved and put in to the list.') - return True - -#To the King! diff --git a/O365/calendar.py b/O365/calendar.py new file mode 100644 index 00000000..527d1d93 --- /dev/null +++ b/O365/calendar.py @@ -0,0 +1,2199 @@ +import calendar +import datetime as dt +import logging +from zoneinfo import ZoneInfo + +# noinspection PyPep8Naming +from bs4 import BeautifulSoup as bs +from dateutil.parser import parse + +from .category import Category +from .utils import ( + NEXT_LINK_KEYWORD, + ApiComponent, + AttachableMixin, + BaseAttachment, + BaseAttachments, + CaseEnum, + HandleRecipientsMixin, + ImportanceLevel, + Pagination, + TrackerSet, +) +from .utils.windows_tz import get_windows_tz + +log = logging.getLogger(__name__) + +MONTH_NAMES = [calendar.month_name[x] for x in range(1, 13)] + + +class EventResponse(CaseEnum): + Organizer = 'organizer' + TentativelyAccepted = 'tentativelyAccepted' + Accepted = 'accepted' + Declined = 'declined' + NotResponded = 'notResponded' + + +class AttendeeType(CaseEnum): + Required = 'required' + Optional = 'optional' + Resource = 'resource' + + +class EventSensitivity(CaseEnum): + Normal = 'normal' + Personal = 'personal' + Private = 'private' + Confidential = 'confidential' + + +class EventShowAs(CaseEnum): + Free = 'free' + Tentative = 'tentative' + Busy = 'busy' + Oof = 'oof' + WorkingElsewhere = 'workingElsewhere' + Unknown = 'unknown' + + +class CalendarColor(CaseEnum): + LightBlue = 'lightBlue' + LightGreen = 'lightGreen' + LightOrange = 'lightOrange' + LightGray = 'lightGray' + LightYellow = 'lightYellow' + LightTeal = 'lightTeal' + LightPink = 'lightPink' + LightBrown = 'lightBrown' + LightRed = 'lightRed' + MaxColor = 'maxColor' + Auto = 'auto' + + +class EventType(CaseEnum): + SingleInstance = 'singleInstance' # a normal (non-recurring) event + Occurrence = 'occurrence' # all the other recurring events that is not the first one (seriesMaster) + Exception = 'exception' # ? + SeriesMaster = 'seriesMaster' # the first recurring event of the series + + +class OnlineMeetingProviderType(CaseEnum): + Unknown = 'unknown' + TeamsForBusiness = 'teamsForBusiness' + SkypeForBusiness = 'skypeForBusiness' + SkypeForConsumer = 'skypeForConsumer' + + +class EventAttachment(BaseAttachment): + _endpoints = {'attach': '/events/{id}/attachments'} + + +class EventAttachments(BaseAttachments): + _endpoints = { + 'attachments': '/events/{id}/attachments', + 'attachment': '/events/{id}/attachments/{ida}', + 'create_upload_session': '/events/{id}/attachments/createUploadSession' + } + + _attachment_constructor = EventAttachment + + +class DailyEventFrequency: + def __init__(self, recurrence_type, interval): + self.recurrence_type = recurrence_type + self.interval = interval + + +# noinspection PyAttributeOutsideInit +class EventRecurrence(ApiComponent): + def __init__(self, event, recurrence=None): + """ A representation of an event recurrence properties + + :param Event event: event object + :param dict recurrence: recurrence information + """ + super().__init__(protocol=event.protocol, + main_resource=event.main_resource) + + self._event = event + recurrence = recurrence or {} + # recurrence pattern + recurrence_pattern = recurrence.get(self._cc('pattern'), {}) + + self.__interval = recurrence_pattern.get(self._cc('interval'), None) + self.__days_of_week = recurrence_pattern.get(self._cc('daysOfWeek'), + set()) + self.__first_day_of_week = recurrence_pattern.get( + self._cc('firstDayOfWeek'), None) + self.__recurrence_type = recurrence_pattern.get("type", None) + if self.__recurrence_type: + if "weekly" not in recurrence_pattern["type"].lower(): + self.__first_day_of_week = None + + self.__day_of_month = recurrence_pattern.get(self._cc('dayOfMonth'), + None) + self.__month = recurrence_pattern.get(self._cc('month'), None) + self.__index = recurrence_pattern.get(self._cc('index'), 'first') + + # recurrence range + recurrence_range = recurrence.get(self._cc('range'), {}) + + self.__occurrences = recurrence_range.get( + self._cc('numberOfOccurrences'), None) + self.__start_date = recurrence_range.get(self._cc('startDate'), None) + self.__end_date = recurrence_range.get(self._cc('endDate'), None) + self.__recurrence_time_zone = recurrence_range.get( + self._cc('recurrenceTimeZone'), + get_windows_tz(self.protocol.timezone)) + # time and time zones are not considered in recurrence ranges... + # I don't know why 'recurrenceTimeZone' is present here + # Sending a startDate datetime to the server results in an Error: + # Cannot convert the literal 'datetime' to the expected type 'Edm.Date' + if recurrence_range: + self.__start_date = parse( + self.__start_date).date() if self.__start_date else None + self.__end_date = parse( + self.__end_date).date() if self.__end_date else None + + def __repr__(self): + if not self.__interval: + return 'No recurrence enabled' + + pattern = 'Daily: every {} day{}'.format( + self.__interval, + 's' if self.__interval != 1 else '') + if self.__days_of_week: + days = ' or '.join(list(self.__days_of_week)) + pattern = 'Relative Monthly: {} {} every {} month{}'.format( + self.__index, + days, + self.__interval, + 's' if self.__interval != 1 else '') + if self.__first_day_of_week: + pattern = 'Weekly: every {} week{} on {}'.format( + self.__interval, + 's' if self.__interval != 1 else '', + days) + elif self.__month: + pattern = ('Relative Yearly: {} {} every {} year{} on {}' + ''.format( + self.__index, + days, + self.__interval, + 's' if self.__interval != 1 else '', + MONTH_NAMES[self.__month - 1])) + elif self.__day_of_month: + pattern = ('Absolute Monthly: on day {} every {} month{}' + ''.format( + self.__day_of_month, + self.__interval, + 's' if self.__interval != 1 else '')) + if self.__month: + pattern = ('Absolute Yearly: on {} {} every {} year/s' + ''.format(MONTH_NAMES[self.__month - 1], + self.__day_of_month, + self.__interval)) + + r_range = '' + if self.__start_date: + r_range = 'Starting on {}'.format(self.__start_date) + ends_on = 'with no end' + if self.__end_date: + ends_on = 'ending on {}'.format(self.__end_date) + elif self.__occurrences: + ends_on = 'up to {} occurrence{}'.format( + self.__occurrences, + 's' if self.__occurrences != 1 else '') + r_range = '{} {}'.format(r_range, ends_on) + return '{}. {}'.format(pattern, r_range) + + def __str__(self): + return self.__repr__() + + def __bool__(self): + return bool(self.__interval) + + def _track_changes(self): + """ Update the track_changes on the event to reflect a needed + update on this field """ + self._event._track_changes.add('recurrence') + + @property + def interval(self): + """ Repeat interval for the event + + :getter: Get the current interval + :setter: Update to a new interval + :type: int + """ + return self.__interval + + @interval.setter + def interval(self, value): + self.__interval = value + self._track_changes() + + @property + def days_of_week(self): + """ Days in week to repeat + + :getter: Get the current list of days + :setter: Set the list of days to repeat + :type: set(str) + """ + return self.__days_of_week + + @days_of_week.setter + def days_of_week(self, value): + self.__days_of_week = value + self._track_changes() + + @property + def first_day_of_week(self): + """ Which day to consider start of the week + + :getter: Get the current start of week + :setter: Set the start day of week + :type: str + """ + return self.__first_day_of_week + + @first_day_of_week.setter + def first_day_of_week(self, value): + self.__first_day_of_week = value + self._track_changes() + + @property + def day_of_month(self): + """ Repeat on this day of month + + :getter: Get the repeat day of month + :setter: Set the repeat day of month + :type: int + """ + return self.__day_of_month + + @day_of_month.setter + def day_of_month(self, value): + self.__day_of_month = value + self._track_changes() + + @property + def month(self): + """ Month of the event + + :getter: Get month + :setter: Update month + :type: int + """ + return self.__month + + @month.setter + def month(self, value): + self.__month = value + self._track_changes() + + @property + def index(self): + """ Index + + :getter: Get index + :setter: Set index + :type: str + """ + return self.__index + + @index.setter + def index(self, value): + self.__index = value + self._track_changes() + + @property + def occurrences(self): + """ No. of occurrences + + :getter: Get the no. of occurrences + :setter: Set the no. of occurrences + :type: int + """ + return self.__occurrences + + @occurrences.setter + def occurrences(self, value): + self.__occurrences = value + self._track_changes() + + @property + def recurrence_time_zone(self): + """ Timezone to consider for repeating + + :getter: Get the timezone + :setter: Set the timezone + :type: str + """ + return self.__recurrence_time_zone + + @recurrence_time_zone.setter + def recurrence_time_zone(self, value): + self.__recurrence_time_zone = value + self._track_changes() + + @property + def recurrence_type(self): + """Type of the recurrence pattern + + :getter: Get the type + :type: str + """ + return self.__recurrence_type + + @property + def start_date(self): + """ Start date of repetition + + :getter: get the start date + :setter: set the start date + :type: date + """ + return self.__start_date + + @start_date.setter + def start_date(self, value): + if not isinstance(value, dt.date): + raise ValueError('start_date value must be a valid date object') + if isinstance(value, dt.datetime): + value = value.date() + self.__start_date = value + self._track_changes() + + @property + def end_date(self): + """ End date of repetition + + :getter: get the end date + :setter: set the end date + :type: date + """ + return self.__end_date + + @end_date.setter + def end_date(self, value): + if not isinstance(value, dt.date): + raise ValueError('end_date value must be a valid date object') + if isinstance(value, dt.datetime): + value = value.date() + self.__end_date = value + self._track_changes() + + def to_api_data(self): + """ Returns a dict to communicate with the server + + :rtype: dict + """ + data = {} + # recurrence pattern + if self.__interval and isinstance(self.__interval, int): + recurrence_pattern = data[self._cc('pattern')] = {} + recurrence_pattern[self._cc('type')] = 'daily' + recurrence_pattern[self._cc('interval')] = self.__interval + if self.__days_of_week and isinstance(self.__days_of_week, + (list, tuple, set)): + recurrence_pattern[self._cc('type')] = 'relativeMonthly' + recurrence_pattern[self._cc('daysOfWeek')] = list( + self.__days_of_week) + if self.__first_day_of_week: + recurrence_pattern[self._cc('type')] = 'weekly' + recurrence_pattern[ + self._cc('firstDayOfWeek')] = self.__first_day_of_week + elif self.__month and isinstance(self.__month, int): + recurrence_pattern[self._cc('type')] = 'relativeYearly' + recurrence_pattern[self._cc('month')] = self.__month + if self.__index: + recurrence_pattern[self._cc('index')] = self.__index + else: + if self.__index: + recurrence_pattern[self._cc('index')] = self.__index + + elif self.__day_of_month and isinstance(self.__day_of_month, int): + recurrence_pattern[self._cc('type')] = 'absoluteMonthly' + recurrence_pattern[self._cc('dayOfMonth')] = self.__day_of_month + if self.__month and isinstance(self.__month, int): + recurrence_pattern[self._cc('type')] = 'absoluteYearly' + recurrence_pattern[self._cc('month')] = self.__month + + # recurrence range + if self.__start_date: + recurrence_range = data[self._cc('range')] = {} + recurrence_range[self._cc('type')] = 'noEnd' + recurrence_range[ + self._cc('startDate')] = self.__start_date.isoformat() + recurrence_range[ + self._cc('recurrenceTimeZone')] = self.__recurrence_time_zone + + if self.__end_date: + recurrence_range[self._cc('type')] = 'endDate' + recurrence_range[ + self._cc('endDate')] = self.__end_date.isoformat() + elif self.__occurrences is not None and isinstance( + self.__occurrences, + int): + recurrence_range[self._cc('type')] = 'numbered' + recurrence_range[ + self._cc('numberOfOccurrences')] = self.__occurrences + + return data + + def _clear_pattern(self): + """ Clears this event recurrence """ + # pattern group + self.__interval = None + self.__days_of_week = set() + self.__first_day_of_week = None + self.__day_of_month = None + self.__month = None + self.__index = 'first' + # range group + self.__start_date = None + self.__end_date = None + self.__occurrences = None + + def set_range(self, start=None, end=None, occurrences=None): + """ Set the range of recurrence + + :param date start: Start date of repetition + :param date end: End date of repetition + :param int occurrences: no of occurrences + """ + if start is None: + if self.__start_date is None: + self.__start_date = dt.date.today() + else: + self.start_date = start + + if end: + self.end_date = end + elif occurrences: + self.__occurrences = occurrences + self._track_changes() + + def set_daily(self, interval, **kwargs): + """ Set to repeat every x no. of days + + :param int interval: no. of days to repeat at + :keyword date start: Start date of repetition (kwargs) + :keyword date end: End date of repetition (kwargs) + :keyword int occurrences: no of occurrences (kwargs) + """ + self._clear_pattern() + self.__interval = interval + self.set_range(**kwargs) + + def set_weekly(self, interval, *, days_of_week, first_day_of_week, + **kwargs): + """ Set to repeat every week on specified days for every x no. of days + + :param int interval: no. of days to repeat at + :param str first_day_of_week: starting day for a week + :param list[str] days_of_week: list of days of the week to repeat + :keyword date start: Start date of repetition (kwargs) + :keyword date end: End date of repetition (kwargs) + :keyword int occurrences: no of occurrences (kwargs) + """ + self.set_daily(interval, **kwargs) + self.__days_of_week = set(days_of_week) + self.__first_day_of_week = first_day_of_week + + def set_monthly(self, interval, *, day_of_month=None, days_of_week=None, + index=None, **kwargs): + """ Set to repeat every month on specified days for every x no. of days + + :param int interval: no. of days to repeat at + :param int day_of_month: repeat day of a month + :param list[str] days_of_week: list of days of the week to repeat + :param index: index + :keyword date start: Start date of repetition (kwargs) + :keyword date end: End date of repetition (kwargs) + :keyword int occurrences: no of occurrences (kwargs) + """ + if not day_of_month and not days_of_week: + raise ValueError('Must provide day_of_month or days_of_week values') + if day_of_month and days_of_week: + raise ValueError('Must provide only one of the two options') + self.set_daily(interval, **kwargs) + if day_of_month: + self.__day_of_month = day_of_month + elif days_of_week: + self.__days_of_week = set(days_of_week) + if index: + self.__index = index + + def set_yearly(self, interval, month, *, day_of_month=None, + days_of_week=None, index=None, **kwargs): + """ Set to repeat every month on specified days for every x no. of days + + :param int interval: no. of days to repeat at + :param int month: month to repeat + :param int day_of_month: repeat day of a month + :param list[str] days_of_week: list of days of the week to repeat + :param index: index + :keyword date start: Start date of repetition (kwargs) + :keyword date end: End date of repetition (kwargs) + :keyword int occurrences: no of occurrences (kwargs) + """ + self.set_monthly(interval, day_of_month=day_of_month, + days_of_week=days_of_week, index=index, **kwargs) + self.__month = month + + +class ResponseStatus(ApiComponent): + """ An event response status (status, time) """ + + def __init__(self, parent, response_status): + """ An event response status (status, time) + + :param parent: parent of this + :type parent: Attendees or Event + :param dict response_status: status info frm cloud + """ + super().__init__(protocol=parent.protocol, + main_resource=parent.main_resource) + #: The status of the response |br| **Type:** str + self.status = (response_status or {}).get( + self._cc("response"), "none" + ) # Deals with private events with None response_status's + self.status = None if self.status == 'none' else EventResponse.from_value(self.status) + if self.status: + #: The time the response was received |br| **Type:** datetime + self.response_time = response_status.get(self._cc('time'), None) + if self.response_time == '0001-01-01T00:00:00Z': + # consider there's no response time + # this way we don't try to convert this Iso 8601 datetime to the + # local timezone which generated parse errors + self.response_time = None + if self.response_time: + try: + self.response_time = parse(self.response_time).astimezone(self.protocol.timezone) + except OverflowError: + log.debug(f"Couldn't parse event response time: {self.response_time}") + self.response_time = None + else: + self.response_time = None + + def __repr__(self): + return self.status or 'None' + + def __str__(self): + return self.__repr__() + + +class Attendee: + """ A Event attendee """ + + def __init__(self, address, *, name=None, attendee_type=None, + response_status=None, event=None): + """ Create a event attendee + + :param str address: email address of the attendee + :param str name: name of the attendee + :param AttendeeType attendee_type: requirement of attendee + :param Response response_status: response status requirement + :param Event event: event for which to assign the attendee + """ + self._untrack = True + self._address = address + self._name = name + self._event = event + if isinstance(response_status, ResponseStatus): + self.__response_status = response_status + else: + self.__response_status = None + self.__attendee_type = AttendeeType.Required + if attendee_type: + self.attendee_type = attendee_type + self._untrack = False + + def __repr__(self): + if self.name: + return '{}: {} ({})'.format(self.attendee_type.name, self.name, + self.address) + else: + return '{}: {}'.format(self.attendee_type.name, self.address) + + def __str__(self): + return self.__repr__() + + @property + def address(self): + """ Email address + + :getter: Get the email address of attendee + :setter: Set the email address of attendee + :type: str + """ + return self._address + + @address.setter + def address(self, value): + self._address = value + self._name = '' + self._track_changes() + + @property + def name(self): + """ Name + + :getter: Get the name of attendee + :setter: Set the name of attendee + :type: str + """ + return self._name + + @name.setter + def name(self, value): + self._name = value + self._track_changes() + + def _track_changes(self): + """ Update the track_changes on the event to reflect a + needed update on this field """ + if self._untrack is False: + self._event._track_changes.add('attendees') + + @property + def response_status(self): + """ Response status of the attendee + + :type: ResponseStatus + """ + return self.__response_status + + @property + def attendee_type(self): + """ Requirement of the attendee + + :getter: Get the requirement of attendee + :setter: Set the requirement of attendee + :type: AttendeeType + """ + return self.__attendee_type + + @attendee_type.setter + def attendee_type(self, value): + if isinstance(value, AttendeeType): + self.__attendee_type = value + else: + self.__attendee_type = AttendeeType.from_value(value) + self._track_changes() + + +class Attendees(ApiComponent): + """ A Collection of Attendees """ + + def __init__(self, event, attendees=None): + """ Create a collection of attendees + + :param Event event: event for which to assign the attendees + :param attendees: list of attendees to add + :type attendees: str or tuple(str, str) or Attendee or list[str] or + list[tuple(str,str)] or list[Attendee] + """ + super().__init__(protocol=event.protocol, + main_resource=event.main_resource) + self._event = event + self.__attendees = [] + self.untrack = True + if attendees: + self.add(attendees) + self.untrack = False + + def __iter__(self): + return iter(self.__attendees) + + def __getitem__(self, key): + return self.__attendees[key] + + def __contains__(self, item): + return item in {attendee.address for attendee in self.__attendees} + + def __len__(self): + return len(self.__attendees) + + def __str__(self): + return self.__repr__() + + def __repr__(self): + return 'Attendees Count: {}'.format(len(self.__attendees)) + + def clear(self): + """ Clear the attendees list """ + self.__attendees = [] + self._track_changes() + + def _track_changes(self): + """ Update the track_changes on the event to reflect a needed + update on this field """ + if self.untrack is False: + self._event._track_changes.add('attendees') + + def add(self, attendees): + """ Add attendees to the parent event + + :param attendees: list of attendees to add + :type attendees: str or tuple(str, str) or Attendee or list[str] or + list[tuple(str,str)] or list[Attendee] + """ + if attendees: + if isinstance(attendees, str): + self.__attendees.append( + Attendee(address=attendees, event=self._event)) + self._track_changes() + elif isinstance(attendees, Attendee): + self.__attendees.append(attendees) + self._track_changes() + elif isinstance(attendees, tuple): + name, address = attendees + if address: + self.__attendees.append( + Attendee(address=address, name=name, event=self._event)) + self._track_changes() + elif isinstance(attendees, list): + for attendee in attendees: + self.add(attendee) + elif isinstance(attendees, + dict) and self._cloud_data_key in attendees: + attendees = attendees.get(self._cloud_data_key) + for attendee in attendees: + email = attendee.get(self._cc('emailAddress'), {}) + address = email.get(self._cc('address'), None) + if address: + name = email.get(self._cc('name'), None) + # default value + attendee_type = attendee.get(self._cc('type'), + 'required') + self.__attendees.append( + Attendee(address=address, name=name, + attendee_type=attendee_type, + event=self._event, + response_status= + ResponseStatus(parent=self, + response_status= + attendee.get( + self._cc('status'), + {})))) + else: + raise ValueError('Attendees must be an address string, an ' + 'Attendee instance, a (name, address) ' + 'tuple or a list') + + def remove(self, attendees): + """ Remove the provided attendees from the event + + :param attendees: list of attendees to add + :type attendees: str or tuple(str, str) or Attendee or list[str] or + list[tuple(str,str)] or list[Attendee] + """ + if isinstance(attendees, (list, tuple)): + attendees = { + attendee.address if isinstance(attendee, Attendee) else attendee + for + attendee in attendees} + elif isinstance(attendees, str): + attendees = {attendees} + elif isinstance(attendees, Attendee): + attendees = {attendees.address} + else: + raise ValueError('Incorrect parameter type for attendees') + + new_attendees = [] + for attendee in self.__attendees: + if attendee.address not in attendees: + new_attendees.append(attendee) + self.__attendees = new_attendees + self._track_changes() + + def to_api_data(self): + """ Returns a dict to communicate with the server + + :rtype: dict + """ + data = [] + for attendee in self.__attendees: + if attendee.address: + att_data = { + self._cc('emailAddress'): { + self._cc('address'): attendee.address, + self._cc('name'): attendee.name + }, + self._cc('type'): self._cc(attendee.attendee_type.value) + } + data.append(att_data) + return data + + +# noinspection PyAttributeOutsideInit +class Event(ApiComponent, AttachableMixin, HandleRecipientsMixin): + """ A Calendar event """ + + _endpoints = { + 'calendar': '/calendars/{id}', + 'event': '/events/{id}', + 'event_default': '/calendar/events', + 'event_calendar': '/calendars/{id}/events', + 'occurrences': '/events/{id}/instances', + } + + def __init__(self, *, parent=None, con=None, **kwargs): + """ Create a calendar event representation + + :param parent: parent for this operation + :type parent: Calendar or Schedule or ApiComponent + :param Connection con: connection to use if no parent specified + :param Protocol protocol: protocol to use if no parent specified + (kwargs) + :param str main_resource: use this resource instead of parent resource + (kwargs) + :param str calendar_id: id of the calender to add this event in + (kwargs) + :param bool download_attachments: whether or not to download attachments + (kwargs) + :param str subject: subject of the event (kwargs) + """ + if parent and con: + raise ValueError('Need a parent or a connection but not both') + self.con = parent.con if parent else con + + # Choose the main_resource passed in kwargs over parent main_resource + main_resource = kwargs.pop('main_resource', None) or ( + getattr(parent, 'main_resource', None) if parent else None) + + super().__init__( + protocol=parent.protocol if parent else kwargs.get('protocol'), + main_resource=main_resource) + + cc = self._cc # alias + # internal to know which properties need to be updated on the server + self._track_changes = TrackerSet(casing=cc) + #: The calendar's unique identifier. |br| **Type:** str + self.calendar_id = kwargs.get('calendar_id', None) + download_attachments = kwargs.get('download_attachments') + cloud_data = kwargs.get(self._cloud_data_key, {}) + + #: Unique identifier for the event. |br| **Type:** str + self.object_id = cloud_data.get(cc('id'), None) + self.__transaction_id = cloud_data.get(cc("transactionId"), None) + self.__subject = cloud_data.get(cc('subject'), + kwargs.get('subject', '') or '') + body = ( + cloud_data.get(cc("body"), {}) or {} + ) # Deals with private events with None body's + self.__body = body.get(cc('content'), '') + #: The type of the content. Possible values are text and html. |br| **Type:** bodyType + self.body_type = body.get(cc('contentType'), + 'HTML') # default to HTML for new messages + + self.__attendees = Attendees(event=self, attendees={ + self._cloud_data_key: cloud_data.get(cc('attendees'), [])}) + self.__categories = cloud_data.get(cc('categories'), []) + + self.__created = cloud_data.get(cc('createdDateTime'), None) + self.__modified = cloud_data.get(cc('lastModifiedDateTime'), None) + + local_tz = self.protocol.timezone + self.__created = parse(self.__created).astimezone( + local_tz) if self.__created else None + self.__modified = parse(self.__modified).astimezone( + local_tz) if self.__modified else None + + self.__is_all_day = cloud_data.get(cc('isAllDay'), False) + + start_obj = cloud_data.get(cc('start'), {}) + self.__start = self._parse_date_time_time_zone(start_obj, self.__is_all_day) + + end_obj = cloud_data.get(cc('end'), {}) + self.__end = self._parse_date_time_time_zone(end_obj, self.__is_all_day) + + #: Set to true if the event has attachments. |br| **Type:** bool + self.has_attachments = cloud_data.get(cc('hasAttachments'), False) + self.__attachments = EventAttachments(parent=self, attachments=[]) + if self.has_attachments and download_attachments: + self.attachments.download_attachments() + self.__categories = cloud_data.get(cc('categories'), []) + #: A unique identifier for an event across calendars. This ID is different for each occurrence in a recurring series. |br| **Type:** str + self.ical_uid = cloud_data.get(cc('iCalUId'), None) + self.__importance = ImportanceLevel.from_value( + cloud_data.get(cc('importance'), 'normal') or 'normal') + #: Set to true if the event has been cancelled. |br| **Type:** bool + self.is_cancelled = cloud_data.get(cc('isCancelled'), False) + #: Set to true if the calendar owner (specified by the owner property of the calendar) is the organizer of the event + #: (specified by the organizer property of the event). It also applies if a delegate organized the event on behalf of the owner. + #: |br| **Type:** bool + self.is_organizer = cloud_data.get(cc('isOrganizer'), True) + self.__location = cloud_data.get(cc('location'), {}) + #: The locations where the event is held or attended from. |br| **Type:** list + self.locations = cloud_data.get(cc('locations'), []) # TODO + + #: A URL for an online meeting. |br| **Type:** str + self.online_meeting_url = cloud_data.get(cc('onlineMeetingUrl'), None) + self.__is_online_meeting = cloud_data.get(cc('isOnlineMeeting'), False) + self.__online_meeting_provider = OnlineMeetingProviderType.from_value( + cloud_data.get(cc('onlineMeetingProvider'), 'teamsForBusiness')) + #: Details for an attendee to join the meeting online. The default is null. |br| **Type:** OnlineMeetingInfo + self.online_meeting = cloud_data.get(cc('onlineMeeting'), None) + if not self.online_meeting_url and self.is_online_meeting: + self.online_meeting_url = self.online_meeting.get(cc('joinUrl'), None) \ + if self.online_meeting else None + + self.__organizer = self._recipient_from_cloud( + cloud_data.get(cc('organizer'), None), field=cc('organizer')) + self.__recurrence = EventRecurrence(event=self, + recurrence=cloud_data.get( + cc('recurrence'), None)) + self.__is_reminder_on = cloud_data.get(cc('isReminderOn'), True) + self.__remind_before_minutes = cloud_data.get( + cc('reminderMinutesBeforeStart'), 15) + self.__response_requested = cloud_data.get(cc('responseRequested'), + True) + self.__response_status = ResponseStatus(parent=self, + response_status=cloud_data.get( + cc('responseStatus'), {})) + self.__sensitivity = EventSensitivity.from_value( + cloud_data.get(cc('sensitivity'), 'normal')) + #: The ID for the recurring series master item, if this event is part of a recurring series. |br| **Type:** str + self.series_master_id = cloud_data.get(cc('seriesMasterId'), None) + self.__show_as = EventShowAs.from_value(cloud_data.get(cc('showAs'), 'busy')) + self.__event_type = EventType.from_value(cloud_data.get(cc('type'), 'singleInstance')) + self.__no_forwarding = False + #: The URL to open the event in Outlook on the web. |br| **Type:** str + self.web_link = cloud_data.get(cc('webLink'), None) + + def __str__(self): + return self.__repr__() + + def __repr__(self): + if self.start.date() == self.end.date(): + return 'Subject: {} (on: {} from: {} to: {})'.format(self.subject, self.start.date(), self.start.time(), + self.end.time()) + else: + return 'Subject: {} (starts: {} {} and ends: {} {})'.format(self.subject, self.start.date(), + self.start.time(), self.end.date(), + self.end.time()) + + def __eq__(self, other): + return self.object_id == other.object_id + + def to_api_data(self, restrict_keys=None): + """ Returns a dict to communicate with the server + + :param restrict_keys: a set of keys to restrict the returned data to + :rtype: dict + """ + cc = self._cc # alias + if self.__location: + if isinstance(self.__location, dict): + location = self.__location + else: + location = {cc('displayName'): self.__location} + else: + location = {cc('displayName'): ''} + + data = { + cc("transactionId"): self.__transaction_id, + cc('subject'): self.__subject, + cc('body'): { + cc('contentType'): self.body_type, + cc('content'): self.__body}, + cc('start'): self._build_date_time_time_zone(self.__start), + cc('end'): self._build_date_time_time_zone(self.__end), + cc('attendees'): self.__attendees.to_api_data(), + cc('location'): location, + cc('categories'): self.__categories, + cc('isAllDay'): self.__is_all_day, + cc('importance'): cc(self.__importance.value), + cc('isReminderOn'): self.__is_reminder_on, + cc('reminderMinutesBeforeStart'): self.__remind_before_minutes, + cc('responseRequested'): self.__response_requested, + cc('sensitivity'): cc(self.__sensitivity.value), + cc('showAs'): cc(self.__show_as.value), + cc('isOnlineMeeting'): cc(self.__is_online_meeting), + cc('onlineMeetingProvider'): cc(self.__online_meeting_provider.value), + cc("SingleValueExtendedProperties"): [ + { + "id": "Boolean {00020329-0000-0000-C000-000000000046} Name DoNotForward", + "value": cc(self.__no_forwarding), + } + ], + } + + if self.__recurrence: + data[cc('recurrence')] = self.__recurrence.to_api_data() + + if self.has_attachments: + data[cc('attachments')] = self.__attachments.to_api_data() + + if restrict_keys: + if 'attachments' in restrict_keys: + self.attachments._update_attachments_to_cloud() + + for key in list(data.keys()): + if key not in restrict_keys: + del data[key] + return data + + @property + def created(self): + """ Created time of the event + + :rtype: datetime + """ + return self.__created + + @property + def modified(self): + """ Last modified time of the event + + :rtype: datetime + """ + return self.__modified + + @property + def body(self): + """ Body of the event + + :getter: Get body text + :setter: Set body of event + :type: str + """ + return self.__body + + @body.setter + def body(self, value): + self.__body = value + self._track_changes.add(self._cc('body')) + + @property + def subject(self): + """ Subject of the event + + :getter: Get subject + :setter: Set subject of event + :type: str + """ + return self.__subject + + @subject.setter + def subject(self, value): + self.__subject = value + self._track_changes.add(self._cc('subject')) + + @property + def transaction_id(self): + """Transaction Id of the event + + :getter: Get transaction_id + :setter: Set transaction_id of event - can only be set for event creation + :type: str + """ + return self.__transaction_id + + @transaction_id.setter + def transaction_id(self, value): + if self.object_id and value != self.__transaction_id: + raise ValueError("Cannot change transaction_id after event creation") + self.__transaction_id = value + self._track_changes.add(self._cc("transactionId")) + + @property + def start(self): + """ Start Time of event + + :getter: get the start time + :setter: set the start time + :type: datetime + """ + return self.__start + + @start.setter + def start(self, value): + if not isinstance(value, dt.date): + raise ValueError("'start' must be a valid datetime object") + if not isinstance(value, dt.datetime): + # force datetime + value = dt.datetime(value.year, value.month, value.day) + if value.tzinfo is None: + # localize datetime + value = value.replace(tzinfo=self.protocol.timezone) + else: + if not isinstance(value.tzinfo, ZoneInfo): + raise ValueError('TimeZone data must be set using ZoneInfo objects') + self.__start = value + if not self.end: + self.end = self.__start + dt.timedelta(minutes=30) + self._track_changes.add(self._cc('start')) + + @property + def end(self): + """ End Time of event + + :getter: get the end time + :setter: set the end time + :type: datetime + """ + return self.__end + + @end.setter + def end(self, value): + if not isinstance(value, dt.date): + raise ValueError("'end' must be a valid datetime object") + if not isinstance(value, dt.datetime): + # force datetime + value = dt.datetime(value.year, value.month, value.day) + if value.tzinfo is None: + # localize datetime + value = value.replace(tzinfo=self.protocol.timezone) + else: + if not isinstance(value.tzinfo, ZoneInfo): + raise ValueError('TimeZone data must be set using ZoneInfo objects') + self.__end = value + self._track_changes.add(self._cc('end')) + + @property + def importance(self): + """ Event Priority + + :getter: get importance of event + :setter: set the importance of event + :type: ImportanceLevel + """ + return self.__importance + + @importance.setter + def importance(self, value): + self.__importance = (value if isinstance(value, ImportanceLevel) + else ImportanceLevel.from_value(value)) + self._track_changes.add(self._cc('importance')) + + @property + def is_all_day(self): + """ Is the event for whole day + + :getter: get the current status of is_all_day property + :setter: set if the event is all day or not + :type: bool + """ + return self.__is_all_day + + @is_all_day.setter + def is_all_day(self, value): + self.__is_all_day = value + if value: + # Api requirement: start and end must be set to midnight + # is_all_day needs event.start included in the request on updates + # is_all_day needs event.end included in the request on updates + start = self.__start or dt.date.today() + end = self.__end or dt.date.today() + + if (start + dt.timedelta(hours=24)) > end: + # Api requires that under is_all_day=True start and + # end must be at least 24 hours away + end = start + dt.timedelta(hours=24) + + # set to midnight + start = dt.datetime(start.year, start.month, start.day) + end = dt.datetime(end.year, end.month, end.day) + + self.start = start + self.end = end + self._track_changes.add(self._cc('isAllDay')) + + @property + def location(self): + """ Location of event + + :getter: get current location configured for the event + :setter: set a location for the event + :type: str + """ + return self.__location + + @location.setter + def location(self, value): + self.__location = value + self._track_changes.add(self._cc('location')) + + @property + def is_reminder_on(self): + """ Status of the Reminder + + :getter: check is reminder enabled or not + :setter: enable or disable reminder option + :type: bool + """ + return self.__is_reminder_on + + @is_reminder_on.setter + def is_reminder_on(self, value): + self.__is_reminder_on = value + self._track_changes.add(self._cc('isReminderOn')) + self._track_changes.add(self._cc('reminderMinutesBeforeStart')) + + @property + def remind_before_minutes(self): + """ No. of minutes to remind before the meeting + + :getter: get current minutes + :setter: set to remind before new x minutes + :type: int + """ + return self.__remind_before_minutes + + @remind_before_minutes.setter + def remind_before_minutes(self, value): + self.__is_reminder_on = True + self.__remind_before_minutes = int(value) + self._track_changes.add(self._cc('isReminderOn')) + self._track_changes.add(self._cc('reminderMinutesBeforeStart')) + + @property + def response_requested(self): + """ Is response requested or not + + :getter: Is response requested or not + :setter: set the event to request response or not + :type: bool + """ + return self.__response_requested + + @response_requested.setter + def response_requested(self, value): + self.__response_requested = value + self._track_changes.add(self._cc('responseRequested')) + + @property + def recurrence(self): + """ Recurrence information of the event + + :rtype: EventRecurrence + """ + return self.__recurrence + + @property + def organizer(self): + """ Organizer of the meeting event + + :rtype: Recipient + """ + return self.__organizer + + @property + def show_as(self): + """ Show as "busy" or any other status during the event + + :getter: Current status during the event + :setter: update show as status + :type: EventShowAs + """ + return self.__show_as + + @show_as.setter + def show_as(self, value): + self.__show_as = (value if isinstance(value, EventShowAs) + else EventShowAs.from_value(value)) + self._track_changes.add(self._cc('showAs')) + + @property + def sensitivity(self): + """ Sensitivity of the Event + + :getter: Get the current sensitivity + :setter: Set a new sensitivity + :type: EventSensitivity + """ + return self.__sensitivity + + @sensitivity.setter + def sensitivity(self, value): + self.__sensitivity = (value if isinstance(value, EventSensitivity) + else EventSensitivity.from_value(value)) + self._track_changes.add(self._cc('sensitivity')) + + @property + def response_status(self): + """ Your response + + :rtype: ResponseStatus + """ + return self.__response_status + + @property + def attachments(self): + """ List of attachments + + :rtype: EventAttachments + """ + return self.__attachments + + @property + def attendees(self): + """ List of meeting attendees + + :rtype: Attendees + """ + return self.__attendees + + @property + def categories(self): + """ Categories of the event + + :getter: get the list of categories + :setter: set the list of categories + :type: list[str] + """ + return self.__categories + + @categories.setter + def categories(self, value): + if isinstance(value, list): + self.__categories = [] + for val in value: + if isinstance(val, Category): + self.__categories.append(val.name) + else: + self.__categories.append(val) + elif isinstance(value, str): + self.__categories = [value] + elif isinstance(value, Category): + self.__categories = [value.name] + else: + raise ValueError('categories must be a list') + self._track_changes.add(self._cc('categories')) + + @property + def event_type(self): + return self.__event_type + + @property + def is_online_meeting(self): + """ Status of the online_meeting + + :getter: check is online_meeting enabled or not + :setter: enable or disable online_meeting option + :type: bool + """ + return self.__is_online_meeting + + @is_online_meeting.setter + def is_online_meeting(self, value): + self.__is_online_meeting = value + self._track_changes.add(self._cc('isOnlineMeeting')) + + @property + def online_meeting_provider(self): + """ online_meeting_provider of event + + :getter: get current online_meeting_provider configured for the event + :setter: set a online_meeting_provider for the event + :type: OnlineMeetingProviderType + """ + return self.__online_meeting_provider + + @online_meeting_provider.setter + def online_meeting_provider(self, value): + self.__online_meeting_provider = (value if isinstance(value, OnlineMeetingProviderType) + else OnlineMeetingProviderType.from_value(value)) + self._track_changes.add(self._cc('onlineMeetingProvider')) + + @property + def no_forwarding(self): + return self.__no_forwarding + + @no_forwarding.setter + def no_forwarding(self, value): + self.__no_forwarding = value + self._track_changes.add('SingleValueExtendedProperties') + + def get_occurrences(self, start, end, *, limit=None, query=None, order_by=None, batch=None): + """ + Returns all the occurrences of a seriesMaster event for a specified time range. + + :type start: datetime + :param start: the start of the time range + :type end: datetime + :param end: the end of the time range + :param int limit: ax no. of events to get. Over 999 uses batch. + :type query: Query or str + :param query: optional. extra filters or ordes to apply to this query + :type order_by: str + :param order_by: orders the result set based on this condition + :param int batch: batch size, retrieves items in + batches allowing to retrieve more items than the limit. + :return: a list of events + :rtype: list[Event] or Pagination + """ + if self.event_type != EventType.SeriesMaster: + # you can only get occurrences if it's a seriesMaster + return [] + + url = self.build_url( + self._endpoints.get('occurrences').format(id=self.object_id)) + + if limit is None or limit > self.protocol.max_top_value: + batch = self.protocol.max_top_value + + params = {'$top': batch if batch else limit} + + if order_by: + params['$orderby'] = order_by + + if query: + if isinstance(query, str): + params['$filter'] = query + else: + params.update(query.as_params()) + + if isinstance(start, dt.date): + # Convert an all-day date which only contains year/month/day into a datetime object + start = dt.datetime(start.year, start.month, start.day) + if start.tzinfo is None: + # if it's a naive datetime, localize the datetime. + start = start.replace(tzinfo=self.protocol.timezone) # localize datetime into local tz + + if isinstance(end, dt.date): + # Convert an all-day date which only contains year/month/day into a datetime object + end = dt.datetime(end.year, end.month, end.day) + if end.tzinfo is None: + # if it's a naive datetime, localize the datetime. + end = end.replace(tzinfo=self.protocol.timezone) # localize datetime into local tz + + params[self._cc('startDateTime')] = start.isoformat() + params[self._cc('endDateTime')] = end.isoformat() + + response = self.con.get(url, params=params) + + if not response: + return iter(()) + + data = response.json() + + # Everything received from cloud must be passed as self._cloud_data_key + events = (self.__class__(parent=self, **{self._cloud_data_key: event}) + for event in data.get('value', [])) + next_link = data.get(NEXT_LINK_KEYWORD, None) + if batch and next_link: + return Pagination(parent=self, data=events, + constructor=self.__class__, + next_link=next_link, limit=limit) + else: + return events + + def delete(self): + """ Deletes a stored event + + :return: Success / Failure + :rtype: bool + """ + if self.object_id is None: + raise RuntimeError('Attempting to delete an unsaved event') + + url = self.build_url(self._endpoints.get('event').format(id=self.object_id)) + + response = self.con.delete(url) + + return bool(response) + + def save(self): + """ Create a new event or update an existing one by checking what + values have changed and update them on the server + + :return: Success / Failure + :rtype: bool + """ + + if self.object_id: + # update event + if not self._track_changes: + return True # there's nothing to update + url = self.build_url(self._endpoints.get('event').format(id=self.object_id)) + method = self.con.patch + data = self.to_api_data(restrict_keys=self._track_changes) + else: + # new event + if self.calendar_id: + url = self.build_url(self._endpoints.get('event_calendar').format(id=self.calendar_id)) + else: + url = self.build_url(self._endpoints.get('event_default')) + method = self.con.post + data = self.to_api_data() + + response = method(url, data=data) + if not response: + return False + + self._track_changes.clear() # clear the tracked changes + + if not self.object_id: + # new event + event = response.json() + + self.object_id = event.get(self._cc('id'), None) + + self.__created = event.get(self._cc('createdDateTime'), None) + self.__modified = event.get(self._cc('lastModifiedDateTime'), None) + + self.__created = parse(self.__created).astimezone( + self.protocol.timezone) if self.__created else None + self.__modified = parse(self.__modified).astimezone( + self.protocol.timezone) if self.__modified else None + + self.ical_uid = event.get(self._cc('iCalUId'), None) + else: + self.__modified = dt.datetime.now().replace(tzinfo=self.protocol.timezone) + + return True + + def accept_event(self, comment=None, *, send_response=True, + tentatively=False): + """ Accept the event + + :param comment: comment to add + :param send_response: whether or not to send response back + :param tentatively: whether acceptance is tentative + :return: Success / Failure + :rtype: bool + """ + if not self.object_id: + raise RuntimeError("Can't accept event that doesn't exist") + + url = self.build_url( + self._endpoints.get('event').format(id=self.object_id)) + url = url + '/tentativelyAccept' if tentatively else url + '/accept' + + data = {} + if comment and isinstance(comment, str): + data[self._cc('comment')] = comment + if send_response is False: + data[self._cc('sendResponse')] = send_response + + response = self.con.post(url, data=data or None) + + return bool(response) + + def decline_event(self, comment=None, *, send_response=True): + """ Decline the event + + :param str comment: comment to add + :param bool send_response: whether or not to send response back + :return: Success / Failure + :rtype: bool + """ + if not self.object_id: + raise RuntimeError("Can't accept event that doesn't exist") + + url = self.build_url( + self._endpoints.get('event').format(id=self.object_id)) + url = url + '/decline' + + data = {} + if comment and isinstance(comment, str): + data[self._cc('comment')] = comment + if send_response is False: + data[self._cc('sendResponse')] = send_response + + response = self.con.post(url, data=data or None) + + return bool(response) + + def cancel_event(self, comment=None, *, send_response=True): + """ Cancel the event + + :param str comment: comment to add + :param bool send_response: whether or not to send response back + :return: Success / Failure + :rtype: bool + """ + if not self.object_id: + raise RuntimeError("Can't accept event that doesn't exist") + + url = self.build_url( + self._endpoints.get('event').format(id=self.object_id)) + url = url + '/cancel' + + data = {} + if comment and isinstance(comment, str): + data[self._cc('comment')] = comment + if send_response is False: + data[self._cc('sendResponse')] = send_response + + response = self.con.post(url, data=data or None) + + return bool(response) + + def get_body_text(self): + """ Parse the body html and returns the body text using bs4 + + :return: body text + :rtype: str + """ + if self.body_type != 'HTML': + return self.body + + try: + soup = bs(self.body, 'html.parser') + except RuntimeError: + return self.body + else: + return soup.body.text + + def get_body_soup(self): + """ Returns the beautifulsoup4 of the html body + + :return: Html body + :rtype: BeautifulSoup + """ + if self.body_type.upper() != 'HTML': + return None + else: + return bs(self.body, 'html.parser') + + +class Calendar(ApiComponent, HandleRecipientsMixin): + _endpoints = { + 'calendar': '/calendars/{id}', + 'get_events': '/calendars/{id}/events', + 'default_events': '/calendar/events', + 'events_view': '/calendars/{id}/calendarView', + 'default_events_view': '/calendar/calendarView', + 'get_event': '/calendars/{id}/events/{ide}', + } + event_constructor = Event #: :meta private: + + def __init__(self, *, parent=None, con=None, **kwargs): + """ Create a Calendar Representation + + :param parent: parent for this operation + :type parent: Schedule + :param Connection con: connection to use if no parent specified + :param Protocol protocol: protocol to use if no parent specified + (kwargs) + :param str main_resource: use this resource instead of parent resource + (kwargs) + """ + if parent and con: + raise ValueError('Need a parent or a connection but not both') + self.con = parent.con if parent else con + + # Choose the main_resource passed in kwargs over parent main_resource + main_resource = kwargs.pop('main_resource', None) or ( + getattr(parent, 'main_resource', None) if parent else None) + + super().__init__( + protocol=parent.protocol if parent else kwargs.get('protocol'), + main_resource=main_resource) + + cloud_data = kwargs.get(self._cloud_data_key, {}) + + #: The calendar name. |br| **Type:** str + self.name = cloud_data.get(self._cc('name'), '') + #: The calendar's unique identifier. |br| **Type:** str + self.calendar_id = cloud_data.get(self._cc('id'), None) + self.__owner = self._recipient_from_cloud( + cloud_data.get(self._cc('owner'), {}), field='owner') + color = cloud_data.get(self._cc('color'), 'auto') + try: + #: Specifies the color theme to distinguish the calendar from other calendars in a UI. |br| **Type:** calendarColor + self.color = CalendarColor.from_value(color) + except: + self.color = CalendarColor.from_value('auto') + #: true if the user can write to the calendar, false otherwise. |br| **Type:** bool + self.can_edit = cloud_data.get(self._cc('canEdit'), False) + #: true if the user has permission to share the calendar, false otherwise. |br| **Type:** bool + self.can_share = cloud_data.get(self._cc('canShare'), False) + #: If true, the user can read calendar items that have been marked private, false otherwise. |br| **Type:** bool + self.can_view_private_items = cloud_data.get( + self._cc('canViewPrivateItems'), False) + + # Hex color only returns a value when a custom calandar is set + # Hex color is read-only, cannot be used to set calendar's color + #: The calendar color, expressed in a hex color code of three hexadecimal values, + #: each ranging from 00 to FF and representing the red, green, or blue components + #: of the color in the RGB color space. |br| **Type:** str + self.hex_color = cloud_data.get(self._cc('hexColor'), None) + + def __str__(self): + return self.__repr__() + + def __repr__(self): + return 'Calendar: {} from {}'.format(self.name, self.owner) + + def __eq__(self, other): + return self.calendar_id == other.calendar_id + + @property + def owner(self): + """ Owner of the calendar + + :rtype: str + """ + return self.__owner + + def update(self): + """ Updates this calendar. Only name and color can be changed. + + :return: Success / Failure + :rtype: bool + """ + + if not self.calendar_id: + return False + + url = self.build_url(self._endpoints.get('calendar').format(id=self.calendar_id)) + + data = { + self._cc('name'): self.name, + self._cc('color'): self._cc(self.color.value + if isinstance(self.color, CalendarColor) + else self.color) + } + + response = self.con.patch(url, data=data) + + return bool(response) + + def delete(self): + """ Deletes this calendar + + :return: Success / Failure + :rtype: bool + """ + + if not self.calendar_id: + return False + + url = self.build_url( + self._endpoints.get('calendar').format(id=self.calendar_id)) + + response = self.con.delete(url) + if not response: + return False + + self.calendar_id = None + + return True + + def get_events(self, limit: int = 25, *, query=None, order_by=None, batch=None, + download_attachments=False, include_recurring=True, + start_recurring=None, end_recurring=None): + """ Get events from this Calendar + + :param int limit: max no. of events to get. Over 999 uses batch. + :param query: applies a OData filter to the request + :type query: Query or str + :param order_by: orders the result set based on this condition + :type order_by: Query or str + :param int batch: batch size, retrieves items in + batches allowing to retrieve more items than the limit. + :param download_attachments: downloads event attachments + :param bool include_recurring: whether to include recurring events or not + :param start_recurring: a string datetime or a Query object with just a start condition + :param end_recurring: a string datetime or a Query object with just an end condition + :return: list of events in this calendar + :rtype: list[Event] or Pagination + """ + + if self.calendar_id is None: + # I'm the default calendar + if include_recurring: + url = self.build_url(self._endpoints.get('default_events_view')) + else: + url = self.build_url(self._endpoints.get('default_events')) + else: + if include_recurring: + url = self.build_url( + self._endpoints.get('events_view').format(id=self.calendar_id)) + else: + url = self.build_url( + self._endpoints.get('get_events').format(id=self.calendar_id)) + + if limit is None or limit > self.protocol.max_top_value: + batch = self.protocol.max_top_value + + if batch: + download_attachments = False + + params = {'$top': batch if batch else limit} + + if include_recurring: + start = None + end = None + if start_recurring is None: + pass + elif isinstance(start_recurring, str): + start = start_recurring + elif isinstance(start_recurring, dt.datetime): + start = start_recurring.isoformat() + else: + # it's a Query Object + start = start_recurring.get_filter_by_attribute('start/') + if end_recurring is None: + pass + elif isinstance(end_recurring, str): + end = end_recurring + elif isinstance(end_recurring, dt.datetime): + end = end_recurring.isoformat() + else: + # it's a Query Object + end = end_recurring.get_filter_by_attribute('end/') + if start is None or end is None: + raise ValueError("When 'include_recurring' is True you must provide " + "a 'start_recurring' and 'end_recurring' with a datetime string.") + start = start.replace("'", '') # remove the quotes + end = end.replace("'", '') # remove the quotes + + params[self._cc('startDateTime')] = start + params[self._cc('endDateTime')] = end + + if order_by: + params['$orderby'] = order_by + + if query: + if isinstance(query, str): + params['$filter'] = query + else: + params.update(query.as_params()) + + response = self.con.get(url, params=params) + + if not response: + return iter(()) + + data = response.json() + + # Everything received from cloud must be passed as self._cloud_data_key + events = (self.event_constructor(parent=self, + download_attachments= + download_attachments, + **{self._cloud_data_key: event}) + for event in data.get('value', [])) + next_link = data.get(NEXT_LINK_KEYWORD, None) + if batch and next_link: + return Pagination(parent=self, data=events, + constructor=self.event_constructor, + next_link=next_link, limit=limit) + else: + return events + + def new_event(self, subject=None): + """ Returns a new (unsaved) Event object + + :rtype: Event + """ + return self.event_constructor(parent=self, subject=subject, + calendar_id=self.calendar_id) + + def get_event(self, param): + """ Returns an Event instance by it's id + + :param param: an event_id or a Query instance + :return: event for the specified info + :rtype: Event + """ + + if param is None: + return None + if isinstance(param, str): + url = self.build_url( + self._endpoints.get('get_event').format(id=self.calendar_id, + ide=param)) + params = None + by_id = True + else: + url = self.build_url( + self._endpoints.get('get_events').format(id=self.calendar_id)) + params = {'$top': 1} + params.update(param.as_params()) + by_id = False + + response = self.con.get(url, params=params) + + if not response: + return None + + if by_id: + event = response.json() + else: + event = response.json().get('value', []) + if event: + event = event[0] + else: + return None + return self.event_constructor(parent=self, + **{self._cloud_data_key: event}) + + +class Schedule(ApiComponent): + _endpoints = { + 'root_calendars': '/calendars', + 'get_calendar': '/calendars/{id}', + 'default_calendar': '/calendar', + 'get_availability': '/calendar/getSchedule', + } + + calendar_constructor = Calendar #: :meta private: + event_constructor = Event #: :meta private: + + def __init__(self, *, parent=None, con=None, **kwargs): + """ Create a wrapper around calendars and events + + :param parent: parent for this operation + :type parent: Account + :param Connection con: connection to use if no parent specified + :param Protocol protocol: protocol to use if no parent specified + (kwargs) + :param str main_resource: use this resource instead of parent resource + (kwargs) + """ + if parent and con: + raise ValueError('Need a parent or a connection but not both') + self.con = parent.con if parent else con + + # Choose the main_resource passed in kwargs over parent main_resource + main_resource = kwargs.pop('main_resource', None) or ( + getattr(parent, 'main_resource', None) if parent else None) + + super().__init__( + protocol=parent.protocol if parent else kwargs.get('protocol'), + main_resource=main_resource) + + def __str__(self): + return self.__repr__() + + def __repr__(self): + return 'Schedule resource: {}'.format(self.main_resource) + + def list_calendars(self, limit=None, *, query=None, order_by=None, batch=None): + """ Gets a list of calendars + + To use query an order_by check the OData specification here: + https://docs.oasis-open.org/odata/odata/v4.0/errata03/os/odata-v4.0-errata03-os.html + + :param int limit: max no. of calendars to get. Over 999 uses batch. + :param query: applies a OData filter to the request + :type query: Query or str + :param order_by: orders the result set based on this condition + :type order_by: Query or str + :param int batch: batch size, retrieves items in + batches allowing to retrieve more items than the limit. + :return: list of calendars + :rtype: list[Calendar] or Pagination + + """ + url = self.build_url(self._endpoints.get('root_calendars')) + + params = {} + if limit is None or limit > self.protocol.max_top_value: + batch = self.protocol.max_top_value + params['$top'] = batch if batch else limit + if query: + if isinstance(query, str): + params["$filter"] = query + else: + params.update(query.as_params()) + if order_by: + params['$orderby'] = order_by + + response = self.con.get(url, params=params or None) + if not response: + return [] + + data = response.json() + + # Everything received from cloud must be passed as self._cloud_data_key + calendars = [self.calendar_constructor(parent=self, **{ + self._cloud_data_key: x}) for x in data.get('value', [])] + next_link = data.get(NEXT_LINK_KEYWORD, None) + if batch and next_link: + return Pagination(parent=self, data=calendars, + constructor=self.calendar_constructor, + next_link=next_link, limit=limit) + else: + return calendars + + + def new_calendar(self, calendar_name): + """ Creates a new calendar + + :param str calendar_name: name of the new calendar + :return: a new Calendar instance + :rtype: Calendar + """ + if not calendar_name: + return None + + url = self.build_url(self._endpoints.get('root_calendars')) + + response = self.con.post(url, data={self._cc('name'): calendar_name}) + if not response: + return None + + data = response.json() + + # Everything received from cloud must be passed as self._cloud_data_key + return self.calendar_constructor(parent=self, + **{self._cloud_data_key: data}) + + def get_calendar(self, calendar_id=None, calendar_name=None, query=None): + """Returns a calendar by it's id or name + + :param str calendar_id: the calendar id to be retrieved. + :param str calendar_name: the calendar name to be retrieved. + :param query: applies a OData filter to the request + :type query: Query + :return: calendar for the given info + :rtype: Calendar + """ + if calendar_id and calendar_name: + raise RuntimeError('Provide only one of the options') + + if not calendar_id and not calendar_name: + raise RuntimeError('Provide one of the options') + + if calendar_id: + # get calendar by it's id + url = self.build_url( + self._endpoints.get('get_calendar').format(id=calendar_id)) + params = None + else: + # get calendar by name + url = self.build_url(self._endpoints.get('root_calendars')) + params = { + '$filter': "{} eq '{}'".format(self._cc('name'), calendar_name), + '$top': 1} + if query: + if not isinstance(query, str): + params = {} if params is None else params + params.update(query.as_params()) + + response = self.con.get(url, params=params) + if not response: + return None + + if calendar_id: + data = response.json() + else: + data = response.json().get('value') + data = data[0] if data else None + if data is None: + return None + + # Everything received from cloud must be passed as self._cloud_data_key + return self.calendar_constructor(parent=self, + **{self._cloud_data_key: data}) + + def get_default_calendar(self): + """ Returns the default calendar for the current user + + :rtype: Calendar + """ + + url = self.build_url(self._endpoints.get('default_calendar')) + + response = self.con.get(url) + if not response: + return None + + data = response.json() + + # Everything received from cloud must be passed as self._cloud_data_key + return self.calendar_constructor(parent=self, + **{self._cloud_data_key: data}) + + def get_events( + self, + limit=25, + *, + query=None, + order_by=None, + batch=None, + download_attachments=False, + include_recurring=True, + start_recurring=None, + end_recurring=None, + ): + """Get events from the default Calendar + + :param int limit: max no. of events to get. Over 999 uses batch. + :param query: applies a OData filter to the request + :type query: Query or str + :param order_by: orders the result set based on this condition + :type order_by: Query or str + :param int batch: batch size, retrieves items in + batches allowing to retrieve more items than the limit. + :param bool download_attachments: downloads event attachments + :param bool include_recurring: whether to include recurring events or not + :param start_recurring: a string datetime or a Query object with just a start condition + :param end_recurring: a string datetime or a Query object with just an end condition + :return: list of items in this folder + :rtype: list[Event] or Pagination + """ + + default_calendar = self.calendar_constructor(parent=self) + + return default_calendar.get_events( + limit=limit, + query=query, + order_by=order_by, + batch=batch, + download_attachments=download_attachments, + include_recurring=include_recurring, + start_recurring=start_recurring, + end_recurring=end_recurring, + ) + + def new_event(self, subject=None): + """ Returns a new (unsaved) Event object in the default calendar + + :param str subject: subject text for the new event + :return: new event + :rtype: Event + """ + return self.event_constructor(parent=self, subject=subject) + + def get_availability(self, schedules, start, end, interval=60): + """ + Returns the free/busy availability for a set of users in a given time frame + :param list schedules: a list of strings (email addresses) + :param datetime start: the start time frame to look for available space + :param datetime end: the end time frame to look for available space + :param int interval: the number of minutes to look for space + """ + url = self.build_url(self._endpoints.get('get_availability')) + + data = { + 'startTime': self._build_date_time_time_zone(start), + 'endTime': self._build_date_time_time_zone(end), + 'availabilityViewInterval': interval, + 'schedules': schedules + } + + response = self.con.post(url, data=data) + if not response: + return [] + + data = response.json().get('value', []) + + # transform dates and availabilityView + availability_view_codes = { + '0': 'free', + '1': 'tentative', + '2': 'busy', + '3': 'out of office', + '4': 'working elsewhere', + } + for schedule in data: + a_view = schedule.get('availabilityView', '') + schedule['availabilityView'] = [availability_view_codes.get(code, 'unkknown') for code in a_view] + for item in schedule.get('scheduleItems', []): + item['start'] = self._parse_date_time_time_zone(item.get('start')) + item['end'] = self._parse_date_time_time_zone(item.get('end')) + + return data diff --git a/O365/category.py b/O365/category.py new file mode 100644 index 00000000..5d89d1b2 --- /dev/null +++ b/O365/category.py @@ -0,0 +1,212 @@ +from enum import Enum + +from .utils import ApiComponent + + +class CategoryColor(Enum): + RED = 'preset0' # 0 + ORANGE = 'preset1' # 1 + BROWN = 'preset2' # 2 + YELLOW = 'preset3' # 3 + GREEN = 'preset4' # 4 + TEAL = 'preset5' # 5 + OLIVE = 'preset6' # 6 + BLUE = 'preset7' # 7 + PURPLE = 'preset8' # 8 + CRANBERRY = 'preset9' # 9 + STEEL = 'preset10' # 10 + DARKSTEEL = 'preset11' # 11 + GRAY = 'preset12' # 12 + DARKGREY = 'preset13' # 13 + BLACK = 'preset14' # 14 + DARKRED = 'preset15' # 15 + DARKORANGE = 'preset16' # 16 + DARKBROWN = 'preset17' # 17 + DARKYELLOW = 'preset18' # 18 + DARKGREEN = 'preset19' # 19 + DARKTEAL = 'preset20' # 20 + DARKOLIVE = 'preset21' # 21 + DARKBLUE = 'preset22' # 22 + DARKPURPLE = 'preset23' # 23 + DARKCRANBERRY = 'preset24' # 24 + + @classmethod + def get(cls, color): + """ + Gets a color by name or value. + Raises ValueError if not found whithin the collection of colors. + """ + try: + return cls(color.capitalize()) # 'preset0' to 'Preset0' + except ValueError: + pass + try: + return cls[color.upper()] # 'red' to 'RED' + except KeyError: + raise ValueError('color is not a valid color from CategoryColor') from None + + +class Category(ApiComponent): + + _endpoints = { + 'update': '/outlook/masterCategories/{id}' + } + + def __init__(self, *, parent=None, con=None, **kwargs): + """Represents a category by which a user can group Outlook items such as messages and events. + It can be used in conjunction with Event, Message, Contact and Post. + + :param parent: parent object + :type parent: Account + :param Connection con: connection to use if no parent specified + :param Protocol protocol: protocol to use if no parent specified + (kwargs) + :param str main_resource: use this resource instead of parent resource + (kwargs) + + """ + + if parent and con: + raise ValueError('Need a parent or a connection but not both') + self.con = parent.con if parent else con + + # Choose the main_resource passed in kwargs over parent main_resource + main_resource = kwargs.pop('main_resource', None) or ( + getattr(parent, 'main_resource', None) if parent else None) + + super().__init__( + protocol=parent.protocol if parent else kwargs.get('protocol'), + main_resource=main_resource) + + cloud_data = kwargs.get(self._cloud_data_key, {}) + + #: The unique id of the category. |br| **Type:** str + self.object_id = cloud_data.get('id') + #: A unique name that identifies a category in the user's mailbox. |br| **Type:** str + self.name = cloud_data.get(self._cc('displayName')) + color = cloud_data.get(self._cc('color')) + #: A pre-set color constant that characterizes a category, and that is mapped to one of 25 predefined colors. |br| **Type:** categoryColor + self.color = CategoryColor(color) if color else None + + def __str__(self): + return self.__repr__() + + def __repr__(self): + return '{} (color: {})'.format(self.name, self.color.name if self.color else None) + + def update_color(self, color): + """ + Updates this Category color + :param None or str or CategoryColor color: the category color + """ + url = self.build_url(self._endpoints.get('update').format(id=self.object_id)) + if color is not None and not isinstance(color, CategoryColor): + color = CategoryColor.get(color) + + response = self.con.patch(url, data={'color': color.value if color else None}) + if not response: + return False + + self.color = color + return True + + def delete(self): + """ Deletes this Category """ + url = self.build_url(self._endpoints.get('update').format(id=self.object_id)) + + response = self.con.delete(url) + + return bool(response) + + +class Categories(ApiComponent): + + _endpoints = { + 'list': '/outlook/masterCategories', + 'get': '/outlook/masterCategories/{id}', + } + + category_constructor = Category #: :meta private: + + def __init__(self, *, parent=None, con=None, **kwargs): + """ Object to retrive categories + + :param parent: parent object + :type parent: Account + :param Connection con: connection to use if no parent specified + :param Protocol protocol: protocol to use if no parent specified + (kwargs) + :param str main_resource: use this resource instead of parent resource + (kwargs) + """ + + if parent and con: + raise ValueError('Need a parent or a connection but not both') + self.con = parent.con if parent else con + + # Choose the main_resource passed in kwargs over parent main_resource + main_resource = kwargs.pop('main_resource', None) or ( + getattr(parent, 'main_resource', None) if parent else None) + + super().__init__( + protocol=parent.protocol if parent else kwargs.get('protocol'), + main_resource=main_resource) + + def get_categories(self): + """ Returns a list of categories""" + url = self.build_url(self._endpoints.get('list')) + + response = self.con.get(url) + if not response: + return [] + + data = response.json() + + return [ + self.category_constructor(parent=self, **{self._cloud_data_key: category}) + for category in data.get('value', []) + ] + + def get_category(self, category_id): + """ Returns a category by id""" + url = self.build_url(self._endpoints.get('get').format(id=category_id)) + + response = self.con.get(url) + if not response: + return None + + data = response.json() + + return self.category_constructor(parent=self, **{self._cloud_data_key: data}) + + def create_category(self, name, color='auto'): + """ + Creates a category. + If the color is not provided it will be choosed from the pool of unused colors. + + :param str name: The name of this outlook category. Must be unique. + :param str or CategoryColor color: optional color. If not provided will be assigned automatically. + :return: bool + """ + if color == 'auto': + used_colors = {category.color for category in self.get_categories()} + all_colors = {color for color in CategoryColor} + available_colors = all_colors - used_colors + try: + color = available_colors.pop() + except KeyError: + # re-use a color + color = all_colors.pop() + else: + if color is not None and not isinstance(color, CategoryColor): + color = CategoryColor.get(color) + + url = self.build_url(self._endpoints.get('list')) + data = {self._cc('displayName'): name, 'color': color.value if color else None} + response = self.con.post(url, data=data) + if not response: + return None + + category = response.json() + + return self.category_constructor(parent=self, **{self._cloud_data_key: category}) diff --git a/O365/connection.py b/O365/connection.py new file mode 100644 index 00000000..8eb97249 --- /dev/null +++ b/O365/connection.py @@ -0,0 +1,1216 @@ +import json +import logging +import time +from typing import Callable, Dict, List, Optional, Union +from urllib.parse import parse_qs, urlparse + +from msal import ConfidentialClientApplication, PublicClientApplication +from requests import Response, Session +from requests.adapters import HTTPAdapter +from requests.exceptions import ( + ConnectionError, + HTTPError, + ProxyError, + RequestException, + SSLError, + Timeout, +) + +# Dynamic loading of module Retry by requests.packages +# noinspection PyUnresolvedReferences +from requests.packages.urllib3.util.retry import Retry +from tzlocal import get_localzone +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError + +from .utils import ( + ME_RESOURCE, + BaseTokenBackend, + FileSystemTokenBackend, + get_windows_tz, + to_camel_case, + to_pascal_case, + to_snake_case, +) + +log = logging.getLogger(__name__) + +GRAPH_API_VERSION: str = "v1.0" +OAUTH_REDIRECT_URL: str = "https://login.microsoftonline.com/common/oauth2/nativeclient" + +RETRIES_STATUS_LIST = ( + 429, # Status code for TooManyRequests + 500, + 502, + 503, + 504, # Server errors +) +RETRIES_BACKOFF_FACTOR: float = 0.5 + +DEFAULT_SCOPES: dict[str, list[str]] = { + # wrap any scope in a 1 element tuple to avoid prefixing + "basic": ["User.Read"], + "mailbox": ["Mail.Read"], + "mailbox_shared": ["Mail.Read.Shared"], + "mailbox_settings": ["MailboxSettings.ReadWrite"], + "message_send": ["Mail.Send"], + "message_send_shared": ["Mail.Send.Shared"], + "message_all": ["Mail.ReadWrite", "Mail.Send"], + "message_all_shared": ["Mail.ReadWrite.Shared", "Mail.Send.Shared"], + "address_book": ["Contacts.Read"], + "address_book_shared": ["Contacts.Read.Shared"], + "address_book_all": ["Contacts.ReadWrite"], + "address_book_all_shared": ["Contacts.ReadWrite.Shared"], + "calendar": ["Calendars.Read"], + "calendar_shared": ["Calendars.Read.Shared"], + "calendar_all": ["Calendars.ReadWrite"], + "calendar_shared_all": ["Calendars.ReadWrite.Shared"], + "users": ["User.ReadBasic.All"], + "onedrive": ["Files.Read.All"], + "onedrive_all": ["Files.ReadWrite.All"], + "sharepoint": ["Sites.Read.All"], + "sharepoint_all": ["Sites.ReadWrite.All"], + "settings_all": ["MailboxSettings.ReadWrite"], + "tasks": ["Tasks.Read"], + "tasks_all": ["Tasks.ReadWrite"], + "presence": ["Presence.Read"], +} + +MsalClientApplication = Union[PublicClientApplication, ConfidentialClientApplication] + + +class TokenExpiredError(HTTPError): + pass + + +class Protocol: + """Base class for all protocols""" + + # Override these in subclass + _protocol_url: str = "not_defined" # Main url to request. + _oauth_scope_prefix: str = "" # Prefix for scopes + _oauth_scopes: dict[str, list[str]] = {} # Dictionary of {scopes_name: [scope1, scope2]} + + def __init__( + self, + *, + protocol_url: Optional[str] = None, + api_version: Optional[str] = None, + default_resource: Optional[str] = None, + casing_function: Optional[Callable] = None, + protocol_scope_prefix: Optional[str] = None, + timezone: Union[Optional[str], Optional[ZoneInfo]] = None, + **kwargs, + ): + """Create a new protocol object + + :param protocol_url: the base url used to communicate with the + server + :param api_version: the api version + :param default_resource: the default resource to use when there is + nothing explicitly specified during the requests + :param casing_function: the casing transform function to be + used on api keywords (camelcase / pascalcase) + :param protocol_scope_prefix: prefix url for scopes + :param timezone: preferred timezone, if not provided will default + to the system timezone or fallback to UTC + :raises ValueError: if protocol_url or api_version are not supplied + """ + if protocol_url is None or api_version is None: + raise ValueError("Must provide valid protocol_url and api_version values") + #: The url for the protcol in use. |br| **Type:** str + self.protocol_url: str = protocol_url or self._protocol_url + #: The scope prefix for protcol in use. |br| **Type:** str + self.protocol_scope_prefix: str = protocol_scope_prefix or "" + #: The api version being used. |br| **Type:** str + self.api_version: str = api_version + #: The full service url. |br| **Type:** str + self.service_url: str = f"{protocol_url}{api_version}/" + #: The resource being used. Defaults to 'me'. |br| **Type:** str + self.default_resource: str = default_resource or ME_RESOURCE + #: Indicates if default casing is being used. |br| **Type:** bool + self.use_default_casing: bool = True if casing_function is None else False + #: The casing function being used. |br| **Type:** callable + self.casing_function: Callable = casing_function or to_camel_case + + # define any keyword that can be different in this protocol + # for example, attachments OData type differs between Outlook + # rest api and graph: (graph = #microsoft.graph.fileAttachment and + # outlook = #Microsoft.OutlookServices.FileAttachment') + #: The keyword data store. |br| **Type:** dict + self.keyword_data_store: dict = {} + + #: The max value for 'top' (500). |br| **Type:** str + self.max_top_value: int = 500 # Max $top parameter value + + #: The in use timezone. |br| **Type:** str + self._timezone: Optional[ZoneInfo] = None + + if timezone: + self.timezone = timezone # property setter will convert this timezone to ZoneInfo if a string is provided + else: + # get_localzone() from tzlocal will try to get the system local timezone and if not will return UTC + self.timezone: ZoneInfo = get_localzone() + + @property + def timezone(self) -> ZoneInfo: + return self._timezone + + @timezone.setter + def timezone(self, timezone: Union[str, ZoneInfo]) -> None: + self._update_timezone(timezone) + + def _update_timezone(self, timezone: Union[str, ZoneInfo]) -> None: + """Sets the timezone. This is not done in the setter as you can't call super from a overriden setter""" + if isinstance(timezone, str): + # convert string to ZoneInfo + try: + timezone = ZoneInfo(timezone) + except ZoneInfoNotFoundError as e: + log.error(f"Timezone {timezone} could not be found.") + raise e + else: + if not isinstance(timezone, ZoneInfo): + raise ValueError( + "The timezone parameter must be either a string or a valid ZoneInfo instance." + ) + log.debug(f"Timezone set to: {timezone}.") + self._timezone = timezone + + def get_service_keyword(self, keyword: str) -> Optional[str]: + """Returns the data set to the key in the internal data-key dict + + :param keyword: key to get value for + :return: value of the keyword + """ + return self.keyword_data_store.get(keyword, None) + + def convert_case(self, key: str) -> str: + """Returns a key converted with this protocol casing method + + Converts case to send/read from the cloud + + When using Microsoft Graph API, the keywords of the API use + lowerCamelCase Casing + + Default case in this API is lowerCamelCase + + :param key: a dictionary key to convert + :return: key after case conversion + """ + return key if self.use_default_casing else self.casing_function(key) + + @staticmethod + def to_api_case(key: str) -> str: + """Converts key to snake_case + + :param key: key to convert into snake_case + :return: key after case conversion + """ + return to_snake_case(key) + + def get_scopes_for( + self, user_provided_scopes: Optional[Union[list, str, tuple]] + ) -> list: + """Returns a list of scopes needed for each of the + scope_helpers provided, by adding the prefix to them if required + + :param user_provided_scopes: a list of scopes or scope helpers + :return: scopes with url prefix added + :raises ValueError: if unexpected datatype of scopes are passed + """ + if user_provided_scopes is None: + # return all available scopes + user_provided_scopes = [app_part for app_part in self._oauth_scopes] + elif isinstance(user_provided_scopes, str): + user_provided_scopes = [user_provided_scopes] + + if not isinstance(user_provided_scopes, (list, tuple)): + raise ValueError( + "'user_provided_scopes' must be a list or a tuple of strings" + ) + + scopes = set() + for app_part in user_provided_scopes: + for scope in self._oauth_scopes.get(app_part, [app_part]): + scopes.add(self.prefix_scope(scope)) + + return list(scopes) + + def prefix_scope(self, scope: str) -> str: + """Inserts the protocol scope prefix if required""" + if self.protocol_scope_prefix: + if not scope.startswith(self.protocol_scope_prefix): + return f"{self.protocol_scope_prefix}{scope}" + return scope + + +class MSGraphProtocol(Protocol): + """A Microsoft Graph Protocol Implementation + https://docs.microsoft.com/en-us/outlook/rest/compare-graph-outlook + """ + + _protocol_url = "https://graph.microsoft.com/" + _oauth_scope_prefix = "https://graph.microsoft.com/" + _oauth_scopes = DEFAULT_SCOPES + + def __init__(self, api_version: str = "v1.0", default_resource: Optional[str] = None, **kwargs): + """Create a new Microsoft Graph protocol object + + _protocol_url = 'https://graph.microsoft.com/' + + _oauth_scope_prefix = 'https://graph.microsoft.com/' + + :param str api_version: api version to use + :param str default_resource: the default resource to use when there is + nothing explicitly specified during the requests + """ + super().__init__( + protocol_url=self._protocol_url, + api_version=api_version, + default_resource=default_resource, + casing_function=to_camel_case, + protocol_scope_prefix=self._oauth_scope_prefix, + **kwargs, + ) + + self.keyword_data_store["message_type"] = "microsoft.graph.message" + self.keyword_data_store["event_message_type"] = "microsoft.graph.eventMessage" + self.keyword_data_store["file_attachment_type"] = ( + "#microsoft.graph.fileAttachment" + ) + self.keyword_data_store["item_attachment_type"] = ( + "#microsoft.graph.itemAttachment" + ) + self.keyword_data_store["prefer_timezone_header"] = ( + f'outlook.timezone="{get_windows_tz(self._timezone)}"' + ) + #: The max value for 'top' (999). |br| **Type:** str + self.max_top_value = 999 # Max $top parameter value + + @Protocol.timezone.setter + def timezone(self, timezone: Union[str, ZoneInfo]) -> None: + super()._update_timezone(timezone) + self.keyword_data_store["prefer_timezone_header"] = ( + f'outlook.timezone="{get_windows_tz(self._timezone)}"' + ) + + +class MSBusinessCentral365Protocol(Protocol): + """A Microsoft Business Central Protocol Implementation + https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/api-reference/v1.0/ + """ + + _protocol_url = "https://api.businesscentral.dynamics.com/" + _oauth_scope_prefix = "https://api.businesscentral.dynamics.com/" + _oauth_scopes = DEFAULT_SCOPES + _protocol_scope_prefix = "https://api.businesscentral.dynamics.com/" + + def __init__( + self, api_version: str ="v1.0", default_resource: Optional[str] = None, + environment: Optional[str] = None, **kwargs + ): + """Create a new Microsoft Graph protocol object + + _protocol_url = 'https://api.businesscentral.dynamics.com/' + + _oauth_scope_prefix = 'https://api.businesscentral.dynamics.com/' + + :param str api_version: api version to use + :param str default_resource: the default resource to use when there is + nothing explicitly specified during the requests + """ + if environment: + _version = "2.0" + _environment = "/" + environment + else: + _version = "1.0" + _environment = "" + + self._protocol_url = f"{self._protocol_url}v{_version}{_environment}/api/" + + super().__init__( + protocol_url=self._protocol_url, + api_version=api_version, + default_resource=default_resource, + casing_function=to_camel_case, + protocol_scope_prefix=self._protocol_scope_prefix, + **kwargs, + ) + + self.keyword_data_store["message_type"] = "microsoft.graph.message" + self.keyword_data_store["event_message_type"] = "microsoft.graph.eventMessage" + self.keyword_data_store["file_attachment_type"] = ( + "#microsoft.graph.fileAttachment" + ) + self.keyword_data_store["item_attachment_type"] = ( + "#microsoft.graph.itemAttachment" + ) + self.keyword_data_store["prefer_timezone_header"] = ( + f'outlook.timezone="{get_windows_tz(self.timezone)}"' + ) + #: The max value for 'top' (999). |br| **Type:** str + self.max_top_value = 999 # Max $top parameter value + + @Protocol.timezone.setter + def timezone(self, timezone: Union[str, ZoneInfo]) -> None: + super()._update_timezone(timezone) + self.keyword_data_store["prefer_timezone_header"] = ( + f'outlook.timezone="{get_windows_tz(self._timezone)}"' + ) + + +class Connection: + """Handles all communication (requests) between the app and the server""" + + _allowed_methods = ["get", "post", "put", "patch", "delete"] + + def __init__( + self, + credentials: str | tuple[str, str], + *, + proxy_server: Optional[str] = None, + proxy_port: Optional[int] = 8080, + proxy_username: Optional[str] = None, + proxy_password: Optional[str] = None, + proxy_http_only: bool = False, + requests_delay: int = 200, + raise_http_errors: bool = True, + request_retries: int = 3, + token_backend: Optional[BaseTokenBackend] = None, + tenant_id: str = "common", + auth_flow_type: str = "authorization", + username: Optional[str] = None, + password: Optional[str] = None, + timeout: Optional[int] = None, + json_encoder: Optional[json.JSONEncoder] = None, + verify_ssl: bool = True, + default_headers: dict = None, + store_token_after_refresh: bool = True, + **kwargs, + ): + """Creates an API connection object + + :param tuple credentials: a tuple of (client_id, client_secret) + Generate client_id and client_secret in https://entra.microsoft.com/ + :param str proxy_server: the proxy server + :param int proxy_port: the proxy port, defaults to 8080 + :param str proxy_username: the proxy username + :param str proxy_password: the proxy password + :param int requests_delay: number of milliseconds to wait between api + calls. + The Api will respond with 429 Too many requests if more than + 17 requests are made per second. Defaults to 200 milliseconds + just in case more than 1 connection is making requests + across multiple processes. + :param bool raise_http_errors: If True Http 4xx and 5xx status codes + will raise as exceptions + :param int request_retries: number of retries done when the server + responds with 5xx error codes. + :param BaseTokenBackend token_backend: the token backend used to get + and store tokens + :param str tenant_id: use this specific tenant id, defaults to common + :param dict default_headers: allow to force headers in api call + (ex: default_headers={"Prefer": 'IdType="ImmutableId"'}) to get constant id for objects. + :param str auth_flow_type: the auth method flow style used: Options: + + - 'authorization': 2-step web style grant flow using an authentication url + - 'public': 2-step web style grant flow using an authentication url for public apps where + client secret cannot be secured + - 'credentials': also called client credentials grant flow using only the client id and secret. + The secret can be certificate based authentication + - 'password': using the username and password. Not recommended + + :param str username: The username the credentials will be taken from in the token backend. + If None, the username will be the first one found in the token backend. + The user's email address to provide in case of auth_flow_type == 'password' + :param str password: The user's password to provide in case of auth_flow_type == 'password' + :param float or tuple timeout: How long to wait for the server to send + data before giving up, as a float, or a tuple (connect timeout, read timeout) + :param JSONEncoder json_encoder: The JSONEncoder to use during the JSON serialization on the request. + :param bool verify_ssl: set the verify flag on the requests library + :param bool store_token_after_refresh: if after a token refresh the token backend should call save_token + :param dict kwargs: any extra params passed to Connection + :raises ValueError: if credentials is not tuple of (client_id, client_secret) + + """ + + if auth_flow_type in ( + "public", + "password", + ): # allow client id only for public or password flow + if isinstance(credentials, str): + credentials = (credentials,) + if ( + not isinstance(credentials, tuple) + or len(credentials) != 1 + or (not credentials[0]) + ): + raise ValueError( + "Provide client id only for public or password flow credentials" + ) + else: + if ( + not isinstance(credentials, tuple) + or len(credentials) != 2 + or (not credentials[0] and not credentials[1]) + ): + raise ValueError("Provide valid auth credentials") + + self._auth_flow_type = ( + auth_flow_type # 'authorization', 'credentials', 'password', or 'public' + ) + if auth_flow_type in ("credentials", "password") and tenant_id == "common": + raise ValueError( + 'When using the "credentials" or "password" auth_flow, the "tenant_id" must be set' + ) + + #: The credentials for the connection. |br| **Type:** tuple + self.auth: tuple = credentials + #: The tenant id. |br| **Type:** str + self.tenant_id: str = tenant_id + + #: The default headers. |br| **Type:** dict + self.default_headers: Dict = default_headers or dict() + #: Store token after refresh. Default true. |br| **Type:** bool + self.store_token_after_refresh: bool = store_token_after_refresh + + token_backend = token_backend or FileSystemTokenBackend(**kwargs) + if not isinstance(token_backend, BaseTokenBackend): + raise ValueError( + '"token_backend" must be an instance of a subclass of BaseTokenBackend' + ) + #: The token backend in use. |br| **Type:** BaseTokenbackend + self.token_backend: BaseTokenBackend = token_backend + #: The session to use. |br| **Type:** Session + self.session: Optional[Session] = None + + #: The password for the connection. |br| **Type:** str + self.password: Optional[str] = password + + self._username: Optional[str] = None + self.username: Optional[str] = username # validate input + + #: The proxy to use. |br| **Type:** dict + self.proxy: Dict = {} + self.set_proxy( + proxy_server, proxy_port, proxy_username, proxy_password, proxy_http_only + ) + + #: The delay to put in a request. Default 0. |br| **Type:** int + self.requests_delay: int = requests_delay or 0 + #: The time of the previous request. |br| **Type:** float + self._previous_request_at: Optional[float] = None # store previous request time + #: Should http errors be raised. Default true. |br| **Type:** bool + self.raise_http_errors: bool = raise_http_errors + #: Number of time to retry request. Default 3. |br| **Type:** int + self.request_retries: int = request_retries + #: Timeout for the request. Default None. |br| **Type:** int + self.timeout: int = timeout + #: Whether to verify the ssl cert. Default true. |br| **Type:** bool + self.verify_ssl: bool = verify_ssl + #: JSONEncoder to use. |br| **Type:** json.JSONEncoder + self.json_encoder: Optional[json.JSONEncoder] = json_encoder + + #: the naive session. |br| **Type:** Session + self.naive_session: Optional[Session] = ( + None # lazy loaded: holds a requests Session object + ) + + self._msal_client: Optional[MsalClientApplication] = ( + None # store the msal client + ) + self._msal_authority: str = f"https://login.microsoftonline.com/{tenant_id}" + #: The oauth redirect url. |br| **Type:** str + self.oauth_redirect_url: str = ( + "https://login.microsoftonline.com/common/oauth2/nativeclient" + ) + + + @property + def auth_flow_type(self) -> str: + return self._auth_flow_type + + def _set_username_from_token_backend( + self, *, home_account_id: Optional[str] = None + ) -> None: + """ + If token data is present, this will try to set the username. If home_account_id is not provided this will try + to set the username from the first account found on the token_backend. + """ + account_info = self.token_backend.get_account(home_account_id=home_account_id) + if account_info: + self.username = account_info.get("username") + + @property + def username(self) -> Optional[str]: + """ + Returns the username in use + If username is not set this will try to set the username to the first account found + from the token_backend. + """ + if not self._username: + self._set_username_from_token_backend() + return self._username + + @username.setter + def username(self, username: Optional[str]) -> None: + if self._username == username: + return + log.debug(f"Current username changed from {self._username} to {username}") + self._username = username + + # if the user is changed and a valid session is set we must change the auth token in the session + if self.session is not None: + access_token = self.token_backend.get_access_token(username=username) + if access_token is not None: + self.update_session_auth_header(access_token=access_token["secret"]) + else: + # if we can't find an access token for the current user, then remove the auth header from the session + if "Authorization" in self.session.headers: + del self.session.headers["Authorization"] + + def set_proxy( + self, + proxy_server: str, + proxy_port: int, + proxy_username: str, + proxy_password: str, + proxy_http_only: bool, + ) -> None: + """Sets a proxy on the Session + + :param str proxy_server: the proxy server + :param int proxy_port: the proxy port, defaults to 8080 + :param str proxy_username: the proxy username + :param str proxy_password: the proxy password + :param bool proxy_http_only: if the proxy should only be used for http + """ + if proxy_server and proxy_port: + if proxy_username and proxy_password: + proxy_uri = ( + f"{proxy_username}:{proxy_password}@{proxy_server}:{proxy_port}" + ) + else: + proxy_uri = f"{proxy_server}:{proxy_port}" + + if proxy_http_only is False: + self.proxy = { + "http": f"http://{proxy_uri}", + "https": f"https://{proxy_uri}", + } + else: + self.proxy = { + "http": f"http://{proxy_uri}", + "https": f"http://{proxy_uri}", + } + + @property + def msal_client(self) -> MsalClientApplication: + """Returns the msal client or creates it if it's not already done""" + if self._msal_client is None: + if self.auth_flow_type in ("public", "password"): + client = PublicClientApplication( + client_id=self.auth[0], + authority=self._msal_authority, + token_cache=self.token_backend, + proxies=self.proxy, + verify=self.verify_ssl, + timeout=self.timeout + ) + elif self.auth_flow_type in ("authorization", "credentials"): + client = ConfidentialClientApplication( + client_id=self.auth[0], + client_credential=self.auth[1], + authority=self._msal_authority, + token_cache=self.token_backend, + proxies=self.proxy, + verify=self.verify_ssl, + timeout=self.timeout + ) + else: + raise ValueError( + '"auth_flow_type" must be "authorization", "public" or "credentials"' + ) + self._msal_client = client + return self._msal_client + + def get_authorization_url( + self, requested_scopes: List[str], redirect_uri: Optional[str] = None, **kwargs + ) -> tuple[str, dict]: + """Initializes the oauth authorization flow, getting the + authorization url that the user must approve. + + :param list[str] requested_scopes: list of scopes to request access for + :param str redirect_uri: redirect url configured in registered app + :param kwargs: allow to pass unused params in conjunction with Connection + :return: authorization url and the flow dict + """ + + redirect_uri = redirect_uri or self.oauth_redirect_url + + if self.auth_flow_type not in ("authorization", "public"): + raise RuntimeError( + 'This method is only valid for auth flow type "authorization" and "public"' + ) + + if not requested_scopes: + raise ValueError("Must provide at least one scope") + + flow = self.msal_client.initiate_auth_code_flow( + scopes=requested_scopes, redirect_uri=redirect_uri + ) + + return flow.get("auth_uri"), flow + + def request_token( + self, + authorization_url: Optional[str], + *, + flow: Optional[dict] = None, + requested_scopes: Optional[List[str]] = None, + store_token: bool = True, + **kwargs, + ) -> bool: + """Authenticates for the specified url and gets the oauth token data. Saves the + token in the backend if store_token is True. This will replace any other tokens stored + for the same username and scopes requested. + If the token data is successfully requested, then this method will try to set the username if + not previously set. + + :param str or None authorization_url: url given by the authorization flow or None if it's client credentials + :param dict flow: dict object holding the data used in get_authorization_url + :param list[str] requested_scopes: list of scopes to request access for + :param bool store_token: True to store the token in the token backend, + so you don't have to keep opening the auth link and + authenticating every time + :param kwargs: allow to pass unused params in conjunction with Connection + :return: Success/Failure + :rtype: bool + """ + + if self.auth_flow_type in ("authorization", "public"): + if not authorization_url: + raise ValueError( + f"Authorization url not provided for oauth flow {self.auth_flow_type}" + ) + # parse the authorization url to obtain the query string params + parsed = urlparse(authorization_url) + query_params_dict = {k: v[0] for k, v in parse_qs(parsed.query).items()} + + result = self.msal_client.acquire_token_by_auth_code_flow( + flow, auth_response=query_params_dict + ) + + elif self.auth_flow_type == "credentials": + if requested_scopes is None: + raise ValueError( + f'Auth flow type "credentials" needs the default scope for a resource.' + f" For example: https://graph.microsoft.com/.default" + ) + + result = self.msal_client.acquire_token_for_client(scopes=requested_scopes) + + elif self.auth_flow_type == "password": + if not requested_scopes: + raise ValueError( + 'Auth flow type "password" requires scopes and none where given' + ) + result = self.msal_client.acquire_token_by_username_password( + username=self.username, password=self.password, scopes=requested_scopes + ) + else: + raise ValueError( + '"auth_flow_type" must be "authorization", "password", "public" or "credentials"' + ) + + if "access_token" not in result: + log.error( + f'Unable to fetch auth token. Error: {result.get("error")} | Description: {result.get("error_description")}' + ) + return False + else: + # extract from the result the home_account_id used in the authentication to retrieve its username + id_token_claims = result.get("id_token_claims") + if id_token_claims: + oid = id_token_claims.get("oid") + tid = id_token_claims.get("tid") + if oid and tid: + home_account_id = f"{oid}.{tid}" + # the next call will change the current username, updating the session headers if session exists + self._set_username_from_token_backend( + home_account_id=home_account_id + ) + + # Update the session headers if the session exists + if self.session is not None: + self.update_session_auth_header(access_token=result["access_token"]) + + if store_token: + self.token_backend.save_token() + return True + + def load_token_from_backend(self) -> bool: + """Loads the token from the backend and tries to set the self.username if it's not set""" + if self.token_backend.load_token(): + if self._username is None: + account_info = self.token_backend.get_account() + if account_info: + self.username = account_info.get("username") + return True + return False + + def get_session(self, load_token: bool = False) -> Session: + """Create a requests Session object with the oauth token attached to it + + :param bool load_token: load the token from the token backend and load the access token into the session auth + :return: A ready to use requests session with authentication header attached + :rtype: requests.Session + """ + + if load_token and not self.token_backend.has_data: + # try to load the token from the token backend + self.load_token_from_backend() + + token = self.token_backend.get_access_token(username=self.username) + + session = Session() + if token is not None: + session.headers.update({"Authorization": f'Bearer {token["secret"]}'}) + session.verify = self.verify_ssl + session.proxies = self.proxy + + if self.request_retries: + retry = Retry( + total=self.request_retries, + read=self.request_retries, + connect=self.request_retries, + backoff_factor=RETRIES_BACKOFF_FACTOR, + status_forcelist=RETRIES_STATUS_LIST, + respect_retry_after_header=True, + ) + adapter = HTTPAdapter(max_retries=retry) + session.mount("http://", adapter) + session.mount("https://", adapter) + + return session + + def get_naive_session(self) -> Session: + """Creates and returns a naive session""" + naive_session = Session() # requests Session object + naive_session.proxies = self.proxy + naive_session.verify = self.verify_ssl + + if self.request_retries: + retry = Retry( + total=self.request_retries, + read=self.request_retries, + connect=self.request_retries, + backoff_factor=RETRIES_BACKOFF_FACTOR, + status_forcelist=RETRIES_STATUS_LIST, + ) + adapter = HTTPAdapter(max_retries=retry) + naive_session.mount("http://", adapter) + naive_session.mount("https://", adapter) + + return naive_session + + def update_session_auth_header(self, access_token: Optional[str] = None) -> None: + """ Will update the internal request session auth header with an access token""" + if access_token is None: + # try to get the access_token from the backend + access_token_dict = self.token_backend.get_access_token( + username=self.username + ) or {} + access_token = access_token_dict.get("secret") + if access_token is None: + # at this point this is an error. + raise RuntimeError("Tried to update the session auth header but no access " + "token was provided nor found in the token backend.") + log.debug("New access token set into session auth header") + self.session.headers.update( + {"Authorization": f"Bearer {access_token}"} + ) + + def _try_refresh_token(self) -> bool: + """Internal method to check try to update the refresh token""" + # first we check if we can acquire a new refresh token + token_refreshed = False + if ( + self.token_backend.token_is_long_lived(username=self.username) + or self.auth_flow_type == "credentials" + ): + # then we ask the token backend if we should refresh the token + log.debug("Asking the token backend if we should refresh the token") + should_rt = self.token_backend.should_refresh_token(con=self, username=self.username) + log.debug(f"Token Backend answered {should_rt}") + if should_rt is True: + # The backend has checked that we can refresh the token + return self.refresh_token() + elif should_rt is False: + # The token was refreshed by another instance and 'should_refresh_token' has updated it into the + # backend cache. So, update the session token and retry the request again + self.update_session_auth_header() + return True + else: + # the refresh was performed by the token backend, and it has updated all the data + return True + else: + log.error( + "You can not refresh an access token that has no 'refresh_token' available." + "Include 'offline_access' permission to get a 'refresh_token'." + ) + return False + + def refresh_token(self) -> bool: + """ + Refresh the OAuth authorization token. + This will be called automatically when the access token + expires, however, you can manually call this method to + request a new refresh token. + + :return bool: Success / Failure + """ + log.debug("Refreshing access token") + + if self.session is None: + self.session = self.get_session(load_token=True) + + # This will set the connection scopes from the scopes set in the stored refresh or access token + scopes = self.token_backend.get_token_scopes( + username=self.username, remove_reserved=True + ) + + # call the refresh! + if self.auth_flow_type == "credentials": + # in this case we don't have an account to work with... + result = self.msal_client.acquire_token_for_client(scopes=scopes) + else: + # call the refresh! + result = self.msal_client.acquire_token_silent_with_error( + scopes=scopes, + account=self.msal_client.get_accounts(username=self.username)[0], + ) + if result is None: + raise RuntimeError("There is no refresh token to refresh") + elif "error" in result: + raise RuntimeError(f"Refresh token operation failed: {result['error']}") + elif "access_token" in result: + log.debug( + f"New oauth token fetched by refresh method for username: {self.username}" + ) + # refresh done, update authorization header + self.update_session_auth_header(access_token=result["access_token"]) + + if self.store_token_after_refresh: + self.token_backend.save_token() + return True + return False + + def _check_delay(self) -> None: + """Checks if a delay is needed between requests and sleeps if True""" + if self._previous_request_at: + dif = ( + round(time.time() - self._previous_request_at, 2) * 1000 + ) # difference in milliseconds + if dif < self.requests_delay: + sleep_for = self.requests_delay - dif + log.debug(f"Sleeping for {sleep_for} milliseconds") + time.sleep(sleep_for / 1000) # sleep needs seconds + self._previous_request_at = time.time() + + def _internal_request( + self, + session_obj: Session, + url: str, + method: str, + ignore40x: bool = False, + **kwargs, + ) -> Response: + """Internal handling of requests. Handles Exceptions. + + :param session_obj: a requests Session instance. + :param str url: url to send request to + :param str method: type of request (get/put/post/patch/delete) + :param bool ignore40x: indicates whether to ignore 40x errors when it would + indicate that there the token has expired. This is set to 'True' for the + first call to the api, and 'False' for the call that is initiated after a + tpken refresh. + :param kwargs: extra params to send to the request api + :return: Response of the request + :rtype: requests.Response + """ + method = method.lower() + if method not in self._allowed_methods: + raise ValueError(f"Method must be one of: {self._allowed_methods}") + + if "headers" not in kwargs: + kwargs["headers"] = {**self.default_headers} + else: + for key, value in self.default_headers.items(): + if key not in kwargs["headers"]: + kwargs["headers"][key] = value + elif key == "Prefer" and key in kwargs["headers"]: + kwargs["headers"][key] = f"{kwargs['headers'][key]}, {value}" + + if method == "get": + kwargs.setdefault("allow_redirects", True) + elif method in ["post", "put", "patch"]: + if ( + kwargs.get("headers") is not None + and kwargs["headers"].get("Content-type") is None + ): + kwargs["headers"]["Content-type"] = "application/json" + if ( + "data" in kwargs + and kwargs["data"] is not None + and kwargs["headers"].get("Content-type") == "application/json" + ): + kwargs["data"] = json.dumps( + kwargs["data"], cls=self.json_encoder + ) # convert to json + + if self.timeout is not None: + kwargs["timeout"] = self.timeout + + self._check_delay() # sleeps if needed + try: + log.debug(f"Requesting ({method.upper()}) URL: {url}") + log.debug(f"Request parameters: {kwargs}") + log.debug(f"Session default headers: {session_obj.headers}") + # auto_retry will occur inside this function call if enabled + response = session_obj.request(method, url, **kwargs) + + response.raise_for_status() # raise 4XX and 5XX error codes. + log.debug( + f"Received response ({response.status_code}) from URL {response.url}" + ) + return response + except (ConnectionError, ProxyError, SSLError, Timeout) as e: + # We couldn't connect to the target url, raise error + log.debug( + f'Connection Error calling: {url}.{f"Using proxy {self.proxy}" if self.proxy else ""}' + ) + raise e # re-raise exception + except HTTPError as e: + # Server response with 4XX or 5XX error status codes + if e.response.status_code in [401, 403] and ignore40x is True: + # This could be a token expired error. + if self.token_backend.token_is_expired(username=self.username): + # Access token has expired, try to refresh the token and try again on the next loop + # By raising custom exception TokenExpiredError we signal oauth_request to fire a + # refresh token operation. + log.debug(f"Oauth Token is expired for username: {self.username}") + raise TokenExpiredError("Oauth Token is expired") + + # try to extract the error message: + try: + error = e.response.json() + error_message = error.get("error", {}).get("message", "") + error_code = ( + error.get("error", {}).get("innerError", {}).get("code", "") + ) + except ValueError: + error_message = "" + error_code = "" + + status_code = int(e.response.status_code / 100) + if status_code == 4: + # Client Error + # Logged as error. Could be a library error or Api changes + log.error( + f"Client Error: {e} | Error Message: {error_message} | Error Code: {error_code}" + ) + else: + # Server Error + log.debug(f"Server Error: {e}") + if self.raise_http_errors: + if error_message: + raise HTTPError( + f"{e.args[0]} | Error Message: {error_message}", + response=e.response, + ) from None + else: + raise e + else: + return e.response + except RequestException as e: + # catch any other exception raised by requests + log.debug(f"Request Exception: {e}") + raise e + + def naive_request(self, url: str, method: str, **kwargs) -> Response: + """Makes a request to url using an without oauth authorization + session, but through a normal session + + :param str url: url to send request to + :param str method: type of request (get/put/post/patch/delete) + :param kwargs: extra params to send to the request api + :return: Response of the request + :rtype: requests.Response + """ + if self.naive_session is None: + # lazy creation of a naive session + self.naive_session = self.get_naive_session() + + return self._internal_request( + self.naive_session, url, method, ignore40x=False, **kwargs + ) + + def oauth_request(self, url: str, method: str, **kwargs) -> Response: + """Makes a request to url using an oauth session. + Raises RuntimeError if the session does not have an Authorization header + + :param str url: url to send request to + :param str method: type of request (get/put/post/patch/delete) + :param kwargs: extra params to send to the request api + :return: Response of the request + :rtype: requests.Response + """ + # oauth authentication + if self.session is None: + self.session = self.get_session(load_token=True) + else: + if self.session.headers.get("Authorization") is None: + raise RuntimeError( + f"No auth token found. Authentication Flow needed for user {self.username}" + ) + + # In the event of a response that returned 401 or 403 unauthorised the ignore40x flag indicates + # that the 40x can be a token expired error. MsGraph is returning 401 or 403 when the access token + # has expired. We can not distinguish between a real 40x or token expired 40x. So in the event + # of a 40x http error we will ignore the first time and try to refresh the token, and then + # re-run the request. If the 40x goes away we can move on. If it keeps the 40x then we will + # raise the error. + try: + return self._internal_request( + self.session, url, method, ignore40x=True, **kwargs + ) + except TokenExpiredError as e: + # refresh and try again the request! + + # try to refresh the token and/or follow token backend answer on 'should_refresh_token' + if self._try_refresh_token(): + return self._internal_request( + self.session, url, method, ignore40x=False, **kwargs + ) + else: + raise e + + def get(self, url: str, params: Optional[dict] = None, **kwargs) -> Response: + """Shorthand for self.oauth_request(url, 'get') + + :param str url: url to send get oauth request to + :param dict params: request parameter to get the service data + :param kwargs: extra params to send to request api + :return: Response of the request + :rtype: requests.Response + """ + return self.oauth_request(url, "get", params=params, **kwargs) + + def post(self, url: str, data: Optional[dict] = None, **kwargs) -> Response: + """Shorthand for self.oauth_request(url, 'post') + + :param str url: url to send post oauth request to + :param dict data: post data to update the service + :param kwargs: extra params to send to request api + :return: Response of the request + :rtype: requests.Response + """ + return self.oauth_request(url, "post", data=data, **kwargs) + + def put(self, url: str, data: Optional[dict] = None, **kwargs) -> Response: + """Shorthand for self.oauth_request(url, 'put') + + :param str url: url to send put oauth request to + :param dict data: put data to update the service + :param kwargs: extra params to send to request api + :return: Response of the request + :rtype: requests.Response + """ + return self.oauth_request(url, "put", data=data, **kwargs) + + def patch(self, url: str, data: Optional[dict] = None, **kwargs) -> Response: + """Shorthand for self.oauth_request(url, 'patch') + + :param str url: url to send patch oauth request to + :param dict data: patch data to update the service + :param kwargs: extra params to send to request api + :return: Response of the request + :rtype: requests.Response + """ + return self.oauth_request(url, "patch", data=data, **kwargs) + + def delete(self, url: str, **kwargs) -> Response: + """Shorthand for self.request(url, 'delete') + + :param str url: url to send delete oauth request to + :param kwargs: extra params to send to request api + :return: Response of the request + :rtype: requests.Response + """ + return self.oauth_request(url, "delete", **kwargs) + + def __del__(self) -> None: + """ + Clear the session by closing it + This should be called manually by the user "del account.con" + There is no guarantee that this method will be called by the garbage collection + But this is not an issue because this connections will be automatically closed. + """ + if hasattr(self, "session") and self.session is not None: + self.session.close() + if hasattr(self, "naive_session") and self.naive_session is not None: + self.naive_session.close() + + +def oauth_authentication_flow( + client_id: str, + client_secret: str, + scopes: List[str] = None, + protocol: Optional[Protocol] = None, + **kwargs, +) -> bool: + """A helper method to perform the OAuth2 authentication flow. + Authenticate and get the oauth token + + :param str client_id: the client_id + :param str client_secret: the client_secret + :param list[str] scopes: a list of protocol user scopes to be converted + by the protocol or raw scopes + :param Protocol protocol: the protocol to be used. + Defaults to MSGraphProtocol + :param kwargs: other configuration to be passed to the Connection instance, + connection.get_authorization_url or connection.request_token + :return: Success or Failure + :rtype: bool + """ + + credentials = (client_id, client_secret) + + protocol = protocol or MSGraphProtocol() + + con = Connection(credentials, **kwargs) + + consent_url, flow = con.get_authorization_url( + requested_scopes=protocol.get_scopes_for(scopes), **kwargs + ) + + print("Visit the following url to give consent:") + print(consent_url) + + token_url = input("Paste the authenticated url here:\n") + + if token_url: + result = con.request_token(token_url, flow=flow, **kwargs) + if result: + print( + "Authentication Flow Completed. Oauth Access Token Stored. " + "You can now use the API." + ) + else: + print("Something go wrong. Please try again.") + + return result + else: + print("Authentication Flow aborted.") + return False diff --git a/O365/contact.py b/O365/contact.py deleted file mode 100644 index 54fe991b..00000000 --- a/O365/contact.py +++ /dev/null @@ -1,179 +0,0 @@ -# Copyright 2015 by Toben "Narcolapser" Archer. All Rights Reserved. -# -# Permission to use, copy, modify, and distribute this software and its documentation for any purpose -# and without fee is hereby granted, provided that the above copyright notice appear in all copies and -# that both that copyright notice and this permission notice appear in supporting documentation, and -# that the name of Toben Archer not be used in advertising or publicity pertaining to distribution of -# the software without specific, written prior permission. TOBEN ARCHER DISCLAIMS ALL WARRANTIES WITH -# REGARD TO THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT -# SHALL TOBEN ARCHER BE LIABLE FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES -# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE -# OR OTHER TORTIOUS ACTION, ARISING OUT -# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. - -import requests -import base64 -import json -import logging -import time - -logging.basicConfig(filename='o365.log',level=logging.DEBUG) - -log = logging.getLogger(__name__) - -class Contact( object ): - ''' - Contact manages lists of events on an associated contact on office365. - - Methods: - getName - Returns the name of the contact. - getContactId - returns the GUID that identifies the contact on office365 - getId - synonym of getContactId - getContacts - kicks off the process of fetching contacts. - - Variable: - events_url - the url that is actually called to fetch events. takes an ID, start, and end. - time_string - used for converting between struct_time and json's time format. - ''' - con_url = 'https://outlook.office365.com/api/v1.0/me/contacts/{0}' - time_string = '%Y-%m-%dT%H:%M:%SZ' - - def __init__(self, json=None, auth=None): - ''' - Wraps all the informaiton for managing contacts. - ''' - self.json = json - self.auth = auth - - if json: - log.debug('translating contact information into local variables.') - self.contactId = json['Id'] - self.name = json['DisplayName'] - else: - log.debug('there was no json, putting in some dumby info.') - self.json = {'DisplayName':'Jebediah Kerman'} - - def delete(self): - '''delete's a contact. cause who needs that guy anyway?''' - headers = {'Content-type': 'application/json', 'Accept': 'text/plain'} - - log.debug('preparing to delete contact.') - response = requests.delete(self.con_url.format(str(self.contactId)),headers=headers,auth=self.auth) - log.debug('response from delete attempt: {0}'.format(str(response))) - - return response.status_code == 204 - - def update(self): - '''updates a contact with information in the local json.''' - if not self.auth: - log.debug('no authentication information, cannot update') - return false - - headers = {'Content-type': 'application/json', 'Accept': 'application/json'} - - data = json.dumps(self.json) - - response = None - try: - response = requests.patch(self.con_url.format(str(self.contactId)),data,headers=headers,auth=self.auth) - log.debug('sent update request') - except Exception as e: - if response: - log.debug('response to contact update: {0}'.format(str(response))) - else: - log.error('No response, something is very wrong with update: {0}'.format(str(e))) - return False - - log.debug('Response to contact update: {0}'.format(str(response))) - - return Contact(response.json(),self.auth) - - def create(self): - '''create a contact with information in the local json.''' - if not self.auth: - log.debug('no authentication information, cannot create') - return false - - headers = {'Content-type': 'application/json', 'Accept': 'application/json'} - - data = json.dumps(self.json) - - response = None - try: - response = requests.post(self.con_url.format(str(self.contactId)),data,headers=headers,auth=self.auth) - log.debug('sent create request') - except Exception as e: - if response: - log.debug('response to contact create: {0}'.format(str(response))) - else: - log.error('No response, something is very wrong with create: {0}'.format(str(e))) - return False - - log.debug('Response to contact create: {0}'.format(str(response))) - - return Contact(response.json(),self.auth) - - def getContactId(self): - '''Get contact's GUID for office 365. mostly used interally in this library.''' - return self.json['Id'] - - def getId(self): - '''Get contact's GUID for office 365. mostly used interally in this library.''' - return self.getContactId() - - def getName(self): - '''Get the contact's Name.''' - return self.json['DisplayName'] - - def setName(self,val): - '''sets the display name of the contact.''' - self.json['DisplayName'] = val - - def getFirstEmailAddress(self): - '''Get the contact's first Email address. returns just the email address.''' - return self.json['EmailAddresses'][0]['Address'] - - def getEmailAdresses(self): - '''Get's all the contacts email addresses. returns a list of strings.''' - ret = [] - for e in self.json['EmailAddresses']: - ret.append(e['Address']) - - def getEmailAddress(self,loc): - ''' - This method will return the email address, text only, from the specified location. - As the order in which the addresses may have downloaded is non-deterministic, it can - not be garunteed that the nth address will be in the same position each time. - ''' - return self.json['EmailAddresses'][loc]['Address'] - - def setEmailAddress(self,val,loc): - ''' - Sets the email address of the specified index. The download of this information may - not be the same each time, so besure you know which address you are editing before - you use this method. - ''' - self.json['EmailAddress'][loc]['Address'] - - def getFirstEmailInfo(self): - '''gets an email address and it's associated date for the first email address.''' - return self.json['EmailAddresses'][0] - - def getAllEmailInfo(self): - '''Gets email addresses and any data that goes with it such as name, returns dict''' - return self.json['EmaillAddresses'] - - def setEmailInfo(self,val): - '''set the list of email addresses. Must be formated as such: - [{"Address":"youremail@example.com","Name","your name"},{and the next] - this replaces current inplace email address information. - ''' - self.json['EmailAddresses'] = val - - def addEmail(self,address,name=None): - '''takes a plain string email, and optionally name, and appends it to list.''' - ins = {'Address':address,'Name':None} - - - -#To the King! diff --git a/O365/directory.py b/O365/directory.py new file mode 100644 index 00000000..70dd32bc --- /dev/null +++ b/O365/directory.py @@ -0,0 +1,505 @@ +import logging + +from dateutil.parser import parse +from requests.exceptions import HTTPError + +from .message import Message, RecipientType +from .utils import ME_RESOURCE, NEXT_LINK_KEYWORD, ApiComponent, Pagination + +USERS_RESOURCE = 'users' + +log = logging.getLogger(__name__) + + +class User(ApiComponent): + + _endpoints = { + 'photo': '/photo/$value', + 'photo_size': '/photos/{size}/$value' + } + + message_constructor = Message #: :meta private: + + def __init__(self, *, parent=None, con=None, **kwargs): + """ Represents an Azure AD user account + + :param parent: parent object + :type parent: Account + :param Connection con: connection to use if no parent specified + :param Protocol protocol: protocol to use if no parent specified + (kwargs) + :param str main_resource: use this resource instead of parent resource + (kwargs) + """ + + if parent and con: + raise ValueError('Need a parent or a connection but not both') + self.con = parent.con if parent else con + + # Choose the main_resource passed in kwargs over parent main_resource + main_resource = kwargs.pop('main_resource', None) or ( + getattr(parent, 'main_resource', None) if parent else None) + + cloud_data = kwargs.get(self._cloud_data_key, {}) + + #: The unique identifier for the user. |br| **Type:** str + self.object_id = cloud_data.get('id') + + if main_resource == USERS_RESOURCE: + main_resource += f'/{self.object_id}' + + super().__init__( + protocol=parent.protocol if parent else kwargs.get('protocol'), + main_resource=main_resource) + + local_tz = self.protocol.timezone + cc = self._cc + + #: The type of the user. |br| **Type:** str + self.type = cloud_data.get('@odata.type') + #: The user principal name (UPN) of the user. + #: The UPN is an Internet-style sign-in name for the user based on the Internet + #: standard RFC 822. |br| **Type:** str + self.user_principal_name = cloud_data.get(cc('userPrincipalName')) + #: The name displayed in the address book for the user. |br| **Type:** str + self.display_name = cloud_data.get(cc('displayName')) + #: The given name (first name) of the user. |br| **Type:** str + self.given_name = cloud_data.get(cc('givenName'), '') + #: The user's surname (family name or last name). |br| **Type:** str + self.surname = cloud_data.get(cc('surname'), '') + #: The SMTP address for the user, for example, jeff@contoso.com. |br| **Type:** str + self.mail = cloud_data.get(cc('mail')) # read only + #: The telephone numbers for the user. |br| **Type:** list[str] + self.business_phones = cloud_data.get(cc('businessPhones'), []) + #: The user's job title. |br| **Type:** str + self.job_title = cloud_data.get(cc('jobTitle')) + #: The primary cellular telephone number for the user. |br| **Type:** str + self.mobile_phone = cloud_data.get(cc('mobilePhone')) + #: The office location in the user's place of business. |br| **Type:** str + self.office_location = cloud_data.get(cc('officeLocation')) + #: The preferred language for the user. The preferred language format is based on RFC 4646. + #: |br| **Type:** str + self.preferred_language = cloud_data.get(cc('preferredLanguage')) + # End of default properties. Next properties must be selected + + #: A freeform text entry field for the user to describe themselves. |br| **Type:** str + self.about_me = cloud_data.get(cc('aboutMe')) + #: true if the account is enabled; otherwise, false. |br| **Type:** str + self.account_enabled = cloud_data.get(cc('accountEnabled')) + #: The age group of the user. |br| **Type:** ageGroup + self.age_group = cloud_data.get(cc('ageGroup')) + #: The licenses that are assigned to the user, including inherited (group-based) licenses. + #: |br| **Type:** list[assignedLicenses] + self.assigned_licenses = cloud_data.get(cc('assignedLicenses')) + #: The plans that are assigned to the user. |br| **Type:** list[assignedPlans] + self.assigned_plans = cloud_data.get(cc('assignedPlans')) # read only + birthday = cloud_data.get(cc('birthday')) + #: The birthday of the user. |br| **Type:** datetime + self.birthday = parse(birthday).astimezone(local_tz) if birthday else None + #: The city where the user is located. |br| **Type:** str + self.city = cloud_data.get(cc('city')) + #: The name of the company that the user is associated with. |br| **Type:** str + self.company_name = cloud_data.get(cc('companyName')) + #: Whether consent was obtained for minors. |br| **Type:** consentProvidedForMinor + self.consent_provided_for_minor = cloud_data.get(cc('consentProvidedForMinor')) + #: The country or region where the user is located; for example, US or UK. + #: |br| **Type:** str + self.country = cloud_data.get(cc('country')) + created = cloud_data.get(cc('createdDateTime')) + #: The date and time the user was created. |br| **Type:** datetime + self.created = parse(created).astimezone( + local_tz) if created else None + #: The name of the department in which the user works. |br| **Type:** str + self.department = cloud_data.get(cc('department')) + #: The employee identifier assigned to the user by the organization. |br| **Type:** str + self.employee_id = cloud_data.get(cc('employeeId')) + #: The fax number of the user. |br| **Type:** str + self.fax_number = cloud_data.get(cc('faxNumber')) + hire_date = cloud_data.get(cc('hireDate')) + #: The type of the user. |br| **Type:** str + self.hire_date = parse(hire_date).astimezone( + local_tz) if hire_date else None + #: The instant message voice-over IP (VOIP) session initiation protocol (SIP) + #: addresses for the user. |br| **Type:** str + self.im_addresses = cloud_data.get(cc('imAddresses')) # read only + #: A list for the user to describe their interests. |br| **Type:** list[str] + self.interests = cloud_data.get(cc('interests')) + #: Don't use – reserved for future use. |br| **Type:** bool + self.is_resource_account = cloud_data.get(cc('isResourceAccount')) + last_password_change = cloud_data.get(cc('lastPasswordChangeDateTime')) + #: The time when this Microsoft Entra user last changed their password or + #: when their password was created, whichever date the latest action was performed. + #: |br| **Type:** str + self.last_password_change = parse(last_password_change).astimezone( + local_tz) if last_password_change else None + #: Used by enterprise applications to determine the legal age group of the user. + #: |br| **Type:** legalAgeGroupClassification + self.legal_age_group_classification = cloud_data.get(cc('legalAgeGroupClassification')) + #: State of license assignments for this user. + #: Also indicates licenses that are directly assigned or the user inherited through + #: group memberships. |br| **Type:** list[licenseAssignmentState] + self.license_assignment_states = cloud_data.get(cc('licenseAssignmentStates')) # read only + #: Settings for the primary mailbox of the signed-in user. |br| **Type:** MailboxSettings + self.mailbox_settings = cloud_data.get(cc('mailboxSettings')) + #: The mail alias for the user. |br| **Type:** str + self.mail_nickname = cloud_data.get(cc('mailNickname')) + #: The URL for the user's site. |br| **Type:** str + self.my_site = cloud_data.get(cc('mySite')) + #: A list of other email addresses for the user; for example: + #: ["bob@contoso.com", "Robert@fabrikam.com"]. |br| **Type:** list[str] + self.other_mails = cloud_data.get(cc('otherMails')) + #: Specifies password policies for the user. |br| **Type:** str + self.password_policies = cloud_data.get(cc('passwordPolicies')) + #: Specifies the password profile for the user. |br| **Type:** passwordProfile + self.password_profile = cloud_data.get(cc('passwordProfile')) + #: A list for the user to enumerate their past projects. |br| **Type:** list[str] + self.past_projects = cloud_data.get(cc('pastProjects')) + #: The postal code for the user's postal address. |br| **Type:** str + self.postal_code = cloud_data.get(cc('postalCode')) + #: The preferred data location for the user. |br| **Type:** str + self.preferred_data_location = cloud_data.get(cc('preferredDataLocation')) + #: The preferred name for the user. + #: **Not Supported. This attribute returns an empty string**. + #: |br| **Type:** str + self.preferred_name = cloud_data.get(cc('preferredName')) + #: The plans that are provisioned for the user.. |br| **Type:** list[provisionedPlan] + self.provisioned_plans = cloud_data.get(cc('provisionedPlans')) # read only + #: For example: ["SMTP: bob@contoso.com", "smtp: bob@sales.contoso.com"]. + #: |br| **Type:** list[str] + self.proxy_addresses = cloud_data.get(cc('proxyAddresses')) # read only + #: A list for the user to enumerate their responsibilities. |br| **Type:** list[str] + self.responsibilities = cloud_data.get(cc('responsibilities')) + #: A list for the user to enumerate the schools they attended |br| **Type:** list[str] + self.schools = cloud_data.get(cc('schools')) + #: Represents whether the user should be included in the Outlook global address list. + #: |br| **Type:** bool + self.show_in_address_list = cloud_data.get(cc('showInAddressList'), True) + #: A list for the user to enumerate their skills. |br| **Type:** list[str] + self.skills = cloud_data.get(cc('skills')) + sign_in_sessions_valid_from = cloud_data.get(cc('signInSessionsValidFromDateTime')) # read only + #: Any refresh tokens or session tokens (session cookies) issued before + #: this time are invalid. |br| **Type:** datetime + self.sign_in_sessions_valid_from = parse(sign_in_sessions_valid_from).astimezone( + local_tz) if sign_in_sessions_valid_from else None + #: The state or province in the user's address. |br| **Type:** str + self.state = cloud_data.get(cc('state')) + #: The street address of the user's place of business. |br| **Type:** str + self.street_address = cloud_data.get(cc('streetAddress')) + #: A two-letter country code (ISO standard 3166). |br| **Type:** str + self.usage_location = cloud_data.get(cc('usageLocation')) + #: A string value that can be used to classify user types in your directory. + #: |br| **Type:** str + self.user_type = cloud_data.get(cc('userType')) + #: Contains the on-premises samAccountName synchronized from the on-premises directory. + #: |br| **Type:** str + self.on_premises_sam_account_name = cloud_data.get(cc('onPremisesSamAccountName')) + + def __str__(self): + return self.__repr__() + + def __repr__(self): + return self.display_name or self.full_name or self.user_principal_name or 'Unknown Name' + + def __eq__(self, other): + return self.object_id == other.object_id + + def __hash__(self): + return self.object_id.__hash__() + + @property + def full_name(self): + """ Full Name (Name + Surname) + :rtype: str + """ + return f'{self.given_name} {self.surname}'.strip() + + def new_message(self, recipient=None, *, recipient_type=RecipientType.TO): + """ This method returns a new draft Message instance with this + user email as a recipient + + :param Recipient recipient: a Recipient instance where to send this + message. If None the email of this contact will be used + :param RecipientType recipient_type: section to add recipient into + :return: newly created message + :rtype: Message or None + """ + + if isinstance(recipient_type, str): + recipient_type = RecipientType(recipient_type) + + recipient = recipient or self.mail + if not recipient: + return None + + new_message = self.message_constructor(parent=self, is_draft=True) + + target_recipients = getattr(new_message, str(recipient_type.value)) + target_recipients.add(recipient) + + return new_message + + def get_profile_photo(self, size=None): + """Returns the user profile photo + + :param str size: 48x48, 64x64, 96x96, 120x120, 240x240, + 360x360, 432x432, 504x504, and 648x648 + """ + if size is None: + url = self.build_url(self._endpoints.get('photo')) + else: + url = self.build_url(self._endpoints.get('photo_size').format(size=size)) + + try: + response = self.con.get(url) + except HTTPError as e: + log.debug(f'Error while retrieving the user profile photo. Error: {e}') + return None + + if not response: + return None + + return response.content + + def update_profile_photo(self, photo): + """ Updates this user profile photo + :param bytes photo: the photo data in bytes + """ + + url = self.build_url(self._endpoints.get('photo')) + response = self.con.patch(url, data=photo, headers={'Content-type': 'image/jpeg'}) + + return bool(response) + + +class Directory(ApiComponent): + + _endpoints = { + 'get_user': '/{email}', + "invitation": "invitations", + } + user_constructor = User #: :meta private: + + def __init__(self, *, parent=None, con=None, **kwargs): + """ Represents the Active Directory + + :param parent: parent object + :type parent: Account + :param Connection con: connection to use if no parent specified + :param Protocol protocol: protocol to use if no parent specified + (kwargs) + :param str main_resource: use this resource instead of parent resource + (kwargs) + """ + + if parent and con: + raise ValueError('Need a parent or a connection but not both') + self.con = parent.con if parent else con + + # Choose the main_resource passed in kwargs over parent main_resource + main_resource = kwargs.pop('main_resource', None) or ( + getattr(parent, 'main_resource', None) if parent else None) + + super().__init__( + protocol=parent.protocol if parent else kwargs.get('protocol'), + main_resource=main_resource) + + def __repr__(self): + return 'Active Directory' + + def get_users(self, limit=100, *, query=None, order_by=None, batch=None): + """ Gets a list of users from the active directory + + When querying the Active Directory the Users endpoint will be used. + Only a limited set of information will be available unless you have + access to scope 'User.Read.All' which requires App Administration + Consent. + + Also using endpoints has some limitations on the querying capabilities. + + To use query an order_by check the OData specification here: + http://docs.oasis-open.org/odata/odata/v4.0/errata03/os/complete/ + part2-url-conventions/odata-v4.0-errata03-os-part2-url-conventions + -complete.html + + :param limit: max no. of contacts to get. Over 999 uses batch. + :type limit: int or None + :param query: applies a OData filter to the request + :type query: Query or str + :param order_by: orders the result set based on this condition + :type order_by: Query or str + :param int batch: batch size, retrieves items in + batches allowing to retrieve more items than the limit. + :return: list of users + :rtype: list[User] or Pagination + """ + + url = self.build_url('') # target the main_resource + + if limit is None or limit > self.protocol.max_top_value: + batch = self.protocol.max_top_value + + params = {'$top': batch if batch else limit} + + if order_by: + params['$orderby'] = order_by + + if query: + if isinstance(query, str): + params['$filter'] = query + else: + params.update(query.as_params()) + + response = self.con.get(url, params=params) + if not response: + return iter(()) + + data = response.json() + + # Everything received from cloud must be passed as self._cloud_data_key + users = (self.user_constructor(parent=self, **{self._cloud_data_key: user}) + for user in data.get('value', [])) + + next_link = data.get(NEXT_LINK_KEYWORD, None) + + if batch and next_link: + return Pagination(parent=self, data=users, + constructor=self.user_constructor, + next_link=next_link, limit=limit) + else: + return users + + def _get_user(self, url, query=None): + """Helper method so DRY""" + + params = {} + if query: + if isinstance(query, str): + params['$filter'] = query + else: + params.update(query.as_params()) + + response = self.con.get(url, params=params) + if not response: + return None + + data = response.json() + + # Everything received from cloud must be passed as self._cloud_data_key + return self.user_constructor(parent=self, **{self._cloud_data_key: data}) + + def get_user(self, user, query=None): + """ Returns a User by it's id or user principal name + + :param str user: the user id or user principal name + :return: User for specified email + :rtype: User + """ + url = self.build_url(self._endpoints.get('get_user').format(email=user)) + return self._get_user(url, query=query) + + def get_current_user(self, query=None): + """ Returns the current logged-in user""" + + if self.main_resource != ME_RESOURCE: + raise ValueError(f"Can't get the current user. The main resource must be set to '{ME_RESOURCE}'") + + url = self.build_url('') # target main_resource + return self._get_user(url, query=query) + + def get_user_manager(self, user, query=None): + """ Returns a Users' manager by the users id, or user principal name + + :param str user: the user id or user principal name + :return: User for specified email + :rtype: User + """ + url = self.build_url(self._endpoints.get('get_user').format(email=user)) + return self._get_user(url + '/manager', query=query) + + def get_user_direct_reports(self, user, limit=100, *, query=None, order_by=None, batch=None): + """ Gets a list of direct reports for the user provided from the active directory + + When querying the Active Directory the Users endpoint will be used. + + Also using endpoints has some limitations on the querying capabilities. + + To use query an order_by check the OData specification here: + http://docs.oasis-open.org/odata/odata/v4.0/errata03/os/complete/ + part2-url-conventions/odata-v4.0-errata03-os-part2-url-conventions + -complete.html + + :param limit: max no. of contacts to get. Over 999 uses batch. + :type limit: int or None + :param query: applies a OData filter to the request + :type query: Query or str + :param order_by: orders the result set based on this condition + :type order_by: Query or str + :param int batch: batch size, retrieves items in + batches allowing to retrieve more items than the limit. + :return: list of users + :rtype: list[User] or Pagination + """ + + url = self.build_url(self._endpoints.get('get_user').format(email=user)) + + if limit is None or limit > self.protocol.max_top_value: + batch = self.protocol.max_top_value + + params = {'$top': batch if batch else limit} + + if order_by: + params['$orderby'] = order_by + + if query: + if isinstance(query, str): + params['$filter'] = query + else: + params.update(query.as_params()) + + response = self.con.get(url + '/directReports', params=params) + if not response: + return iter(()) + + data = response.json() + + # Everything received from cloud must be passed as self._cloud_data_key + direct_reports = (self.user_constructor(parent=self, **{self._cloud_data_key: user}) + for user in data.get('value', [])) + + next_link = data.get(NEXT_LINK_KEYWORD, None) + + if batch and next_link: + return Pagination(parent=self, data=direct_reports, + constructor=self.user_constructor, + next_link=next_link, limit=limit) + else: + return direct_reports + + + def invite_user(self, email: str, redirect_url: str, **kwargs) -> dict[str]: + """ Sends a guest invitation to the named user to make them a guest of the tenant. + This user can then be added to groups and teams. + + The return dict is what the graph call returns. The two key pieces of information + is the invitedUser > id key, and the inviteRedeemKey (which is used to activate + the account). + + + :param email: the email address of the guest to be added + :type email: str + :param redirect_url: the URL the user will be redirected to after registering their guest account + :type redirect_url: str + :rtype: dict + """ + + url = self.build_url(self._endpoints.get('invitation')) + url = "{}{}".format( self.protocol.service_url, self._endpoints.get('invitation') ) + + data = kwargs + data['invitedUserEmailAddress'] = email + data['inviteRedirectUrl'] = redirect_url + + response = self.con.post(url, data=data) + + return_json = response.json() + return return_json diff --git a/O365/drive.py b/O365/drive.py new file mode 100644 index 00000000..3582c9be --- /dev/null +++ b/O365/drive.py @@ -0,0 +1,2024 @@ +import logging +import warnings +from pathlib import Path +from time import sleep +from typing import Union, Optional +from urllib.parse import quote, urlparse +from io import BytesIO + +from dateutil.parser import parse + +from .address_book import Contact +from .utils import ( + NEXT_LINK_KEYWORD, + ApiComponent, + OneDriveWellKnowFolderNames, + Pagination, + QueryBuilder, + CompositeFilter +) + +log = logging.getLogger(__name__) + +SIZE_THERSHOLD = 1024 * 1024 * 2 # 2 MB +UPLOAD_SIZE_LIMIT_SIMPLE = 1024 * 1024 * 4 # 4 MB +UPLOAD_SIZE_LIMIT_SESSION = 1024 * 1024 * 60 # 60 MB +CHUNK_SIZE_BASE = 1024 * 320 # 320 Kb + +# 5 MB --> Must be a multiple of CHUNK_SIZE_BASE +DEFAULT_UPLOAD_CHUNK_SIZE = 1024 * 1024 * 5 +ALLOWED_PDF_EXTENSIONS = {".csv", ".doc", ".docx", ".odp", ".ods", ".odt", + ".pot", ".potm", ".potx", + ".pps", ".ppsx", ".ppsxm", ".ppt", ".pptm", ".pptx", + ".rtf", ".xls", ".xlsx"} + + +class DownloadableMixin: + + def download(self, to_path: Union[None, str, Path] = None, name: str = None, + chunk_size: Union[str, int] = "auto", convert_to_pdf: bool = False, + output: Optional[BytesIO] = None): + """ Downloads this file to the local drive. Can download the + file in chunks with multiple requests to the server. + + :param to_path: a path to store the downloaded file + :type to_path: str or Path + :param str name: the name you want the stored file to have. + :param int chunk_size: number of bytes to retrieve from + each api call to the server. if auto, files bigger than + SIZE_THERSHOLD will be chunked (into memory, will be + however only 1 request) + :param bool convert_to_pdf: will try to download the converted pdf + if file extension in ALLOWED_PDF_EXTENSIONS + :param BytesIO output: (optional) an opened io object to write to. + if set, the to_path and name will be ignored + :return: Success / Failure + :rtype: bool + """ + # TODO: Add download with more than one request (chunk_requests) with + # header 'Range'. For example: 'Range': 'bytes=0-1024' + + if not output: + if to_path is None: + to_path = Path() + else: + if not isinstance(to_path, Path): + to_path = Path(to_path) + + if not to_path.exists(): + raise FileNotFoundError("{} does not exist".format(to_path)) + + if name and not Path(name).suffix and self.name: + name = name + Path(self.name).suffix + + name = name or self.name + if convert_to_pdf: + to_path = to_path / Path(name).with_suffix(".pdf") + else: + to_path = to_path / name + + url = self.build_url( + self._endpoints.get("download").format(id=self.object_id)) + + try: + if chunk_size is None: + stream = False + elif chunk_size == "auto": + if self.size and self.size > SIZE_THERSHOLD: + stream = True + else: + stream = False + chunk_size = None + elif isinstance(chunk_size, int): + stream = True + else: + raise ValueError("Argument chunk_size must be either 'auto' " + "or any integer number representing bytes") + + params = {} + if convert_to_pdf: + if not output: + if Path(name).suffix in ALLOWED_PDF_EXTENSIONS: + params["format"] = "pdf" + else: + params["format"] = "pdf" + + with self.con.get(url, stream=stream, params=params) as response: + if not response: + log.debug("Downloading driveitem Request failed: {}".format( + response.reason)) + return False + + def write_output(out): + if stream: + for chunk in response.iter_content( + chunk_size=chunk_size): + if chunk: + out.write(chunk) + else: + out.write(response.content) + + if output: + write_output(output) + else: + with to_path.open(mode="wb") as output: + write_output(output) + + except Exception as e: + log.error( + "Error downloading driveitem {}. Error: {}".format(self.name, + str(e))) + return False + + return True + + +class CopyOperation(ApiComponent): + """ https://github.com/OneDrive/onedrive-api-docs/issues/762 """ + + _endpoints = { + # all prefixed with /drives/{drive_id} on main_resource by default + 'item': '/items/{id}', + } + + def __init__(self, *, parent=None, con=None, target=None, **kwargs): + """ + + :param parent: parent for this operation i.e. the source of the copied item + :type parent: Drive + :param Connection con: connection to use if no parent specified + :param target: The target drive for the copy operation + :type target: Drive + :param Protocol protocol: protocol to use if no parent specified + (kwargs) + :param str main_resource: use this resource instead of parent resource + (kwargs) + :param str monitor_url: + :param str item_id: + """ + if parent and con: + raise ValueError('Need a parent or a connection but not both') + self.con = parent.con if parent else con + #: Parent drive of the copy operation. |br| **Type:** Drive + self.parent = parent # parent will be always a Drive + #: Target drive of the copy operation. |br| **Type:** Drive + self.target = target or parent + + # Choose the main_resource passed in kwargs over parent main_resource + main_resource = kwargs.pop('main_resource', None) or ( + getattr(parent, 'main_resource', None) if parent else None) + + super().__init__( + protocol=parent.protocol if parent else kwargs.get('protocol'), + main_resource=main_resource) + + #: Monitor url of the copy operation. |br| **Type:** str + self.monitor_url = kwargs.get('monitor_url', None) + #: item_id of the copy operation. |br| **Type:** str + self.item_id = kwargs.get('item_id', None) + if self.monitor_url is None and self.item_id is None: + raise ValueError('Must provide a valid monitor_url or item_id') + if self.monitor_url is not None and self.item_id is not None: + raise ValueError( + 'Must provide a valid monitor_url or item_id, but not both') + + if self.item_id: + #: Status of the copy operation. |br| **Type:** str + self.status = 'completed' + #: Percentage complete of the copy operation. |br| **Type:** float + self.completion_percentage = 100.0 + else: + self.status = 'inProgress' + self.completion_percentage = 0.0 + + def _request_status(self): + """ Checks the api endpoint to check if the async job progress """ + if self.item_id: + return True + + response = self.con.naive_request(self.monitor_url, method="get") + if not response: + return False + + data = response.json() + + self.status = data.get('status', 'inProgress') + self.completion_percentage = data.get(self._cc('percentageComplete'), + 0) + self.item_id = data.get(self._cc('resourceId'), None) + + return self.item_id is not None + + def check_status(self, delay=0): + """ Checks the api endpoint in a loop + + :param delay: number of seconds to wait between api calls. + Note Connection 'requests_delay' also apply. + :return: tuple of status and percentage complete + :rtype: tuple(str, float) + """ + if not self.item_id: + while not self._request_status(): + # wait until _request_status returns True + yield self.status, self.completion_percentage + if self.item_id is None: + sleep(delay) + else: + yield self.status, self.completion_percentage + + def get_item(self): + """ Returns the item copied + + :return: Copied Item + :rtype: DriveItem + """ + return self.target.get_item( + self.item_id) if self.item_id is not None else None + + +class DriveItemVersion(ApiComponent, DownloadableMixin): + """ A version of a DriveItem """ + + _endpoints = { + 'download': '/versions/{id}/content', + 'restore': '/versions/{id}/restoreVersion' + } + + def __init__(self, *, parent=None, con=None, **kwargs): + """ Version of DriveItem + + :param parent: parent for this operation + :type parent: DriveItem + :param Connection con: connection to use if no parent specified + :param Protocol protocol: protocol to use if no parent specified + (kwargs) + :param str main_resource: use this resource instead of parent resource + (kwargs) + """ + if parent and con: + raise ValueError('Need a parent or a connection but not both') + self.con = parent.con if parent else con + self._parent = parent if isinstance(parent, DriveItem) else None + + protocol = parent.protocol if parent else kwargs.get('protocol') + # Choose the main_resource passed in kwargs over parent main_resource + main_resource = kwargs.pop('main_resource', None) or ( + getattr(parent, 'main_resource', None) if parent else None) + + resource_prefix = '/items/{item_id}'.format( + item_id=self._parent.object_id) + main_resource = '{}{}'.format( + main_resource or (protocol.default_resource if protocol else ''), + resource_prefix) + super().__init__(protocol=protocol, main_resource=main_resource) + + cloud_data = kwargs.get(self._cloud_data_key, {}) + + #: The unique identifier of the item within the Drive. |br| **Type:** str + self.driveitem_id = self._parent.object_id + #: The ID of the version. |br| **Type:** str + self.object_id = cloud_data.get('id', '1.0') + #: The name (ID) of the version. |br| **Type:** str + self.name = self.object_id + modified = cloud_data.get(self._cc('lastModifiedDateTime'), None) + local_tz = self.protocol.timezone + #: Date and time the version was last modified. |br| **Type:** datetime + self.modified = parse(modified).astimezone( + local_tz) if modified else None + #: Indicates the size of the content stream for this version of the item. + #: |br| **Type:** int + self.size = cloud_data.get('size', 0) + modified_by = cloud_data.get(self._cc('lastModifiedBy'), {}).get('user', + None) + #: Identity of the user which last modified the version. |br| **Type:** Contact + self.modified_by = Contact(con=self.con, protocol=self.protocol, **{ + self._cloud_data_key: modified_by}) if modified_by else None + + def __str__(self): + return self.__repr__() + + def __repr__(self): + return ('Version Id: {} | Modified on: {} | by: {}' + ''.format(self.name, + self.modified, + self.modified_by.display_name + if self.modified_by else None)) + + def restore(self): + """ Restores this DriveItem Version. + You can not restore the current version (last one). + + :return: Success / Failure + :rtype: bool + """ + url = self.build_url( + self._endpoints.get('restore').format(id=self.object_id)) + + response = self.con.post(url) + + return bool(response) + + def download(self, to_path: Union[None, str, Path] = None, name: str = None, + chunk_size: Union[str, int] = 'auto', convert_to_pdf: bool = False, + output: Optional[BytesIO] = None): + """ Downloads this version. + You can not download the current version (last one). + + :return: Success / Failure + :rtype: bool + """ + return super().download(to_path=to_path, name=name, chunk_size=chunk_size, + convert_to_pdf=convert_to_pdf, output=output) + + +class DriveItemPermission(ApiComponent): + """ A Permission representation for a DriveItem """ + _endpoints = { + 'permission': '/items/{driveitem_id}/permissions/{id}' + } + + def __init__(self, *, parent=None, con=None, **kwargs): + """ Permissions for DriveItem + + :param parent: parent for this operation + :type parent: DriveItem + :param Connection con: connection to use if no parent specified + :param Protocol protocol: protocol to use if no parent specified + (kwargs) + :param str main_resource: use this resource instead of parent resource + (kwargs) + """ + if parent and con: + raise ValueError('Need a parent or a connection but not both') + self.con = parent.con if parent else con + self._parent = parent if isinstance(parent, DriveItem) else None + # Choose the main_resource passed in kwargs over parent main_resource + main_resource = kwargs.pop('main_resource', None) or ( + getattr(parent, 'main_resource', None) if parent else None) + + protocol = parent.protocol if parent else kwargs.get('protocol') + super().__init__(protocol=protocol, main_resource=main_resource) + + #: The unique identifier of the item within the Drive. |br| **Type:** str + self.driveitem_id = self._parent.object_id + cloud_data = kwargs.get(self._cloud_data_key, {}) + #: The unique identifier of the permission among all permissions on the item. |br| **Type:** str + self.object_id = cloud_data.get(self._cc('id')) + #: Provides a reference to the ancestor of the current permission, + #: if it's inherited from an ancestor. |br| **Type:** ItemReference + self.inherited_from = cloud_data.get(self._cc('inheritedFrom'), None) + + link = cloud_data.get(self._cc('link'), None) + #: The unique identifier of the permission among all permissions on the item. |br| **Type:** str + self.permission_type = 'owner' + if link: + #: The permission type. |br| **Type:** str + self.permission_type = 'link' + #: The share type. |br| **Type:** str + self.share_type = link.get('type', 'view') + #: The share scope. |br| **Type:** str + self.share_scope = link.get('scope', 'anonymous') + #: The share link. |br| **Type:** str + self.share_link = link.get('webUrl', None) + + invitation = cloud_data.get(self._cc('invitation'), None) + if invitation: + self.permission_type = 'invitation' + #: The share email. |br| **Type:** str + self.share_email = invitation.get('email', '') + invited_by = invitation.get('invitedBy', {}) + #: The invited by user. |br| **Type:** str + self.invited_by = invited_by.get('user', {}).get( + self._cc('displayName'), None) or invited_by.get('application', + {}).get( + self._cc('displayName'), None) + #: Is sign in required. |br| **Type:** bool + self.require_sign_in = invitation.get(self._cc('signInRequired'), + True) + + #: The type of permission, for example, read. |br| **Type:** list[str] + self.roles = cloud_data.get(self._cc('roles'), []) + granted_to = cloud_data.get(self._cc('grantedTo'), {}) + #: For user type permissions, the details of the users and applications + #: for this permission. |br| **Type:** IdentitySet + self.granted_to = granted_to.get('user', {}).get( + self._cc('displayName')) or granted_to.get('application', {}).get( + self._cc('displayName')) + #: A unique token that can be used to access this shared item via the shares API + #: |br| **Type:** str + self.share_id = cloud_data.get(self._cc('shareId'), None) + + def __str__(self): + return self.__repr__() + + def __repr__(self): + return 'Permission for {} of type: {}'.format(self._parent.name, + self.permission_type) + + def update_roles(self, roles='view'): + """ Updates the roles of this permission + + :return: Success / Failure + :rtype: bool + """ + if not self.object_id: + return False + + url = self.build_url(self._endpoints.get('permission').format( + driveitem_id=self.driveitem_id, id=self.object_id)) + + if roles in {'view', 'read'}: + data = {'roles': ['read']} + elif roles in {'edit', 'write'}: + data = {'roles': ['write']} + else: + raise ValueError('"{}" is not a valid share_type'.format(roles)) + + response = self.con.patch(url, data=data) + if not response: + return False + + self.roles = data.get('roles', []) + return True + + def delete(self): + """ Deletes this permission. Only permissions that are not + inherited can be deleted. + + :return: Success / Failure + :rtype: bool + """ + if not self.object_id: + return False + + url = self.build_url(self._endpoints.get('permission').format( + driveitem_id=self.driveitem_id, id=self.object_id)) + + response = self.con.delete(url) + if not response: + return False + + self.object_id = None + return True + + +class DriveItem(ApiComponent): + """ A DriveItem representation. Groups all functionality """ + + _endpoints = { + # all prefixed with /drives/{drive_id} on main_resource by default + 'list_items': '/items/{id}/children', + 'thumbnails': '/items/{id}/thumbnails', + 'item': '/items/{id}', + 'copy': '/items/{id}/copy', + 'download': '/items/{id}/content', + 'search': "/items/{id}/search(q='{search_text}')", + 'versions': '/items/{id}/versions', + 'version': '/items/{id}/versions/{version_id}', + 'simple_upload': '/items/{id}:/{filename}:/content', + 'create_upload_session': '/items/{id}:/{filename}:/createUploadSession', + 'share_link': '/items/{id}/createLink', + 'share_invite': '/items/{id}/invite', + 'permissions': '/items/{id}/permissions', + } + + def __init__(self, *, parent=None, con=None, **kwargs): + """ Create a DriveItem + + :param parent: parent for this operation + :type parent: Drive or drive.Folder + :param Connection con: connection to use if no parent specified + :param Protocol protocol: protocol to use if no parent specified + (kwargs) + :param str main_resource: use this resource instead of parent resource + (kwargs) + """ + if parent and con: + raise ValueError('Need a parent or a connection but not both') + self.con = parent.con if parent else con + self._parent = parent if isinstance(parent, DriveItem) else None + + # Choose the main_resource passed in kwargs over parent main_resource + main_resource = kwargs.pop('main_resource', None) or ( + getattr(parent, 'main_resource', None) if parent else None) + + protocol = parent.protocol if parent else kwargs.get('protocol') + if parent and not isinstance(parent, DriveItem): + # parent is a Drive so append the drive route to the main_resource + drive_id = (None if parent.object_id == 'root' + else parent.object_id) or None + + # prefix with the current known drive or the default one + resource_prefix = '/drives/{drive_id}'.format( + drive_id=drive_id) if drive_id else '/drive' + main_resource = '{}{}'.format(main_resource or ( + protocol.default_resource if protocol else ''), resource_prefix) + + super().__init__(protocol=protocol, main_resource=main_resource) + + cloud_data = kwargs.get(self._cloud_data_key, {}) + + #: The unique identifier of the item within the Drive. |br| **Type:** str + self.object_id = cloud_data.get(self._cc('id')) + + parent_reference = cloud_data.get(self._cc('parentReference'), {}) + #: The id of the parent. |br| **Type:** str + self.parent_id = parent_reference.get('id', None) + #: Identifier of the drive instance that contains the item. |br| **Type:** str + self.drive_id = parent_reference.get(self._cc('driveId'), None) + #: Path that can be used to navigate to the item. |br| **Type:** str + self.parent_path = parent_reference.get(self._cc("path"), None) + + remote_item = cloud_data.get(self._cc('remoteItem'), None) + if remote_item is not None: + #: The drive |br| **Type:** Drive + self.drive = None # drive is unknown? + #: Remote item data, if the item is shared from a drive other than the one being accessed. + #: |br| **Type:** remoteItem + self.remote_item = self._classifier(remote_item)(parent=self, **{ + self._cloud_data_key: remote_item}) + self.parent_id = self.remote_item.parent_id + self.drive_id = self.remote_item.drive_id + self.set_base_url('drives/{}'.format(self.drive_id)) # changes main_resource and _base_url + else: + self.drive = parent if isinstance(parent, Drive) else ( + parent.drive if isinstance(parent.drive, Drive) else kwargs.get( + 'drive', None)) + self.remote_item = None + + #: The name of the item (filename and extension). |br| **Type:** str + self.name = cloud_data.get(self._cc('name'), '') + #: URL that displays the resource in the browser. |br| **Type:** str + self.web_url = cloud_data.get(self._cc('webUrl')) + created_by = cloud_data.get(self._cc('createdBy'), {}).get('user', None) + #: Identity of the user, device, and application which created the item. |br| **Type:** Contact + self.created_by = Contact(con=self.con, protocol=self.protocol, **{ + self._cloud_data_key: created_by}) if created_by else None + modified_by = cloud_data.get(self._cc('lastModifiedBy'), {}).get('user', + None) + #: Identity of the user, device, and application which last modified the item + #: |br| **Type:** Contact + self.modified_by = Contact(con=self.con, protocol=self.protocol, **{ + self._cloud_data_key: modified_by}) if modified_by else None + + created = cloud_data.get(self._cc('createdDateTime'), None) + modified = cloud_data.get(self._cc('lastModifiedDateTime'), None) + local_tz = self.protocol.timezone + #: Date and time of item creation. |br| **Type:** datetime + self.created = parse(created).astimezone(local_tz) if created else None + #: Date and time the item was last modified. |br| **Type:** datetime + self.modified = parse(modified).astimezone( + local_tz) if modified else None + + #: Provides a user-visible description of the item. |br| **Type:** str + self.description = cloud_data.get(self._cc('description'), '') + #: Size of the item in bytes. |br| **Type:** int + self.size = cloud_data.get(self._cc('size'), 0) + #: Indicates that the item has been shared with others and + #: provides information about the shared state of the item. |br| **Type:** str + self.shared = cloud_data.get(self._cc('shared'), {}).get('scope', None) + + # Thumbnails + #: The thumbnails. |br| **Type:** any + self.thumbnails = cloud_data.get(self._cc('thumbnails'), []) + + def __str__(self): + return self.__repr__() + + def __repr__(self): + return '{}: {}'.format(self.__class__.__name__, self.name) + + def __eq__(self, other): + obj_id = getattr(other, 'object_id', None) + if obj_id is not None: + return self.object_id == obj_id + return False + + @staticmethod + def _classifier(item): + """ Subclass to change factory classes """ + if 'folder' in item: + return Folder + elif 'image' in item: + return Image + elif 'photo' in item: + return Photo + else: + return File + + @property + def is_folder(self): + """ Returns if this DriveItem is a Folder """ + return isinstance(self, Folder) + + @property + def is_file(self): + """ Returns if this DriveItem is a File """ + return isinstance(self, File) + + @property + def is_image(self): + """ Returns if this DriveItem is a Image """ + return isinstance(self, Image) + + @property + def is_photo(self): + """ Returns if this DriveItem is a Photo """ + return isinstance(self, Photo) + + def get_parent(self): + """ the parent of this DriveItem + + :return: Parent of this item + :rtype: Drive or drive.Folder + """ + if self._parent and self._parent.object_id == self.parent_id: + return self._parent + else: + if self.parent_id: + return self.drive.get_item(self.parent_id) + else: + # return the drive + return self.drive + + def get_drive(self): + """ + Returns this item drive + :return: Drive of this item + :rtype: Drive or None + """ + if not self.drive_id: + return None + + url = self.build_url('') + response = self.con.get(url) + if not response: + return None + + drive = response.json() + + return Drive(parent=self, main_resource='', **{self._cloud_data_key: drive}) + + def get_thumbnails(self, size=None): + """ Returns this Item Thumbnails. Thumbnails are not supported on + SharePoint Server 2016. + + :param size: request only the specified size: ej: "small", + Custom 300x400 px: "c300x400", Crop: "c300x400_Crop" + :return: Thumbnail Data + :rtype: dict + """ + if not self.object_id: + return [] + + url = self.build_url( + self._endpoints.get('thumbnails').format(id=self.object_id)) + + params = {} + if size is not None: + params['select'] = size + + response = self.con.get(url, params=params) + if not response: + return [] + + data = response.json() + + if not self.thumbnails or size is None: + self.thumbnails = data + + return data + + def update(self, **kwargs): + """ Updates this item + + :param kwargs: all the properties to be updated. + only name and description are allowed at the moment. + :return: Success / Failure + :rtype: bool + """ + if not self.object_id: + return False + + url = self.build_url( + self._endpoints.get('item').format(id=self.object_id)) + + data = {self._cc(key): value for key, value in kwargs.items() if + key in {'name', + 'description'}} # convert keys to protocol casing + if not data: + return False + + response = self.con.patch(url, data=data) + if not response: + return False + + new_data = response.json() + + for key in data: + value = new_data.get(key, None) + if value: + setattr(self, self.protocol.to_api_case(key), value) + + return True + + def delete(self): + """ Moves this item to the Recycle Bin + + :return: Success / Failure + :rtype: bool + """ + + if not self.object_id: + return False + + url = self.build_url( + self._endpoints.get('item').format(id=self.object_id)) + + response = self.con.delete(url) + if not response: + return False + + self.object_id = None + + return True + + def move(self, target): + """ Moves this DriveItem to another Folder. + Can't move between different Drives. + + :param target: a Folder, Drive item or Item Id string. + If it's a drive the item will be moved to the root folder. + :type target: drive.Folder or DriveItem or str + :return: Success / Failure + :rtype: bool + """ + + if isinstance(target, Folder): + target_id = target.object_id + elif isinstance(target, Drive): + # we need the root folder id + root_folder = target.get_root_folder() + if not root_folder: + return False + target_id = root_folder.object_id + elif isinstance(target, str): + target_id = target + else: + raise ValueError('Target must be a Folder or Drive') + + if not self.object_id or not target_id: + raise ValueError( + 'Both self, and target must have a valid object_id.') + + if target_id == 'root': + raise ValueError("When moving, target id can't be 'root'") + + url = self.build_url( + self._endpoints.get('item').format(id=self.object_id)) + + data = {'parentReference': {'id': target_id}} + + response = self.con.patch(url, data=data) + if not response: + return False + + self.parent_id = target_id + + return True + + def copy(self, target=None, name=None): + """Asynchronously creates a copy of this DriveItem and all it's + child elements. + + :param target: target location to move to. + If it's a drive the item will be moved to the root folder. + If it's None, the target is the parent of the item being copied i.e. item will be copied + into the same location. + :type target: drive.Folder or Drive + :param name: a new name for the copy. + :rtype: CopyOperation + """ + + if target is None and name is None: + raise ValueError('Must provide a target or a name (or both)') + + if isinstance(target, Folder): + target_id = target.object_id + drive_id = target.drive_id + target_drive = target.drive + elif isinstance(target, Drive): + # we need the root folder + root_folder = target.get_root_folder() + if not root_folder: + return None + target_id = root_folder.object_id + drive_id = root_folder.drive_id + target_drive = root_folder.drive + elif target is None: + target_id = None + drive_id = None + target_drive = None + else: + raise ValueError('Target, if provided, must be a Folder or Drive') + + if not self.object_id: + return None + + if target_id == 'root': + raise ValueError("When copying, target id can't be 'root'") + + url = self.build_url( + self._endpoints.get('copy').format(id=self.object_id)) + + if target_id and drive_id: + data = {'parentReference': {'id': target_id, 'driveId': drive_id}} + else: + data = {} + if name: + # incorporate the extension if the name provided has none. + if not Path(name).suffix and self.name: + name = name + Path(self.name).suffix + data['name'] = name + + response = self.con.post(url, data=data) + if not response: + return None + + # Find out if the server has run a Sync or Async operation + location = response.headers.get('Location', None) + + parent = self.drive or self.remote_item + if response.status_code == 202: + # Async operation + return CopyOperation(parent=parent, monitor_url=location, target=target_drive) + else: + # Sync operation. Item is ready to be retrieved + path = urlparse(location).path + item_id = path.split('/')[-1] + return CopyOperation(parent=parent, item_id=item_id, target=target_drive) + + def get_versions(self): + """ Returns a list of available versions for this item + + :return: list of versions + :rtype: list[DriveItemVersion] + """ + + if not self.object_id: + return [] + url = self.build_url( + self._endpoints.get('versions').format(id=self.object_id)) + + response = self.con.get(url) + if not response: + return [] + + data = response.json() + + # Everything received from cloud must be passed as self._cloud_data_key + return [DriveItemVersion(parent=self, **{self._cloud_data_key: item}) + for item in data.get('value', [])] + + def get_version(self, version_id): + """ Returns a version for specified id + + :return: a version object of specified id + :rtype: DriveItemVersion + """ + if not self.object_id: + return None + + url = self.build_url( + self._endpoints.get('version').format(id=self.object_id, + version_id=version_id)) + + response = self.con.get(url) + if not response: + return None + + data = response.json() + + # Everything received from cloud must be passed as self._cloud_data_key + return DriveItemVersion(parent=self, **{self._cloud_data_key: data}) + + def share_with_link(self, share_type='view', share_scope='anonymous', share_password=None, share_expiration_date=None): + """ Creates or returns a link you can share with others + + :param str share_type: 'view' to allow only view access, + 'edit' to allow editions, and + 'embed' to allow the DriveItem to be embedded + :param str share_scope: 'anonymous': anyone with the link can access. + 'organization' Only organization members can access + :param str share_password: sharing link password that is set by the creator. Optional. + :param str share_expiration_date: format of yyyy-MM-dd (e.g., 2022-02-14) that indicates the expiration date of the permission. Optional. + :return: link to share + :rtype: DriveItemPermission + """ + + if not self.object_id: + return None + + url = self.build_url( + self._endpoints.get('share_link').format(id=self.object_id)) + + data = { + 'type': share_type, + 'scope': share_scope + } + if share_password is not None: + data['password'] = share_password + if share_expiration_date is not None: + data['expirationDateTime'] = share_expiration_date + + response = self.con.post(url, data=data) + if not response: + return None + + data = response.json() + + # return data.get('link', {}).get('webUrl') + return DriveItemPermission(parent=self, **{self._cloud_data_key: data}) + + def share_with_invite(self, recipients, require_sign_in=True, + send_email=True, message=None, share_type='view'): + """ Sends an invitation to access or edit this DriveItem + + :param recipients: a string or Contact or a list of the former + representing recipients of this invitation + :type recipients: list[str] or list[Contact] or str or Contact + :param bool require_sign_in: if True the recipients + invited will need to log in to view the contents + :param bool send_email: if True an email will be send to the recipients + :param str message: the body text of the message emailed + :param str share_type: 'view': will allow to read the contents. + 'edit' will allow to modify the contents + :return: link to share + :rtype: DriveItemPermission + """ + if not self.object_id: + return None + + to = [] + if recipients is None: + raise ValueError('Provide a valid to parameter') + elif isinstance(recipients, (list, tuple)): + for x in recipients: + if isinstance(x, str): + to.append({'email': x}) + elif isinstance(x, Contact): + to.append({'email': x.main_email}) + else: + raise ValueError( + 'All the recipients must be either strings or Contacts') + elif isinstance(recipients, str): + to.append({'email': recipients}) + elif isinstance(recipients, Contact): + to.append({'email': recipients.main_email}) + else: + raise ValueError( + 'All the recipients must be either strings or Contacts') + + url = self.build_url( + self._endpoints.get('share_invite').format(id=self.object_id)) + + data = { + 'recipients': to, + self._cc('requireSignIn'): require_sign_in, + self._cc('sendInvitation'): send_email, + } + if share_type in {'view', 'read'}: + data['roles'] = ['read'] + elif share_type in {'edit', 'write'}: + data['roles'] = ['write'] + else: + raise ValueError( + '"{}" is not a valid share_type'.format(share_type)) + if send_email and message: + data['message'] = message + + response = self.con.post(url, data=data) + if not response: + return None + + data = response.json() + + return DriveItemPermission(parent=self, **{self._cloud_data_key: data}) + + def get_permissions(self): + """ Returns a list of DriveItemPermissions with the + permissions granted for this DriveItem. + + :return: List of Permissions + :rtype: list[DriveItemPermission] + """ + if not self.object_id: + return [] + + url = self.build_url( + self._endpoints.get('permissions').format(id=self.object_id)) + + response = self.con.get(url) + if not response: + return None + + data = response.json() + + # Everything received from cloud must be passed as self._cloud_data_key + return [DriveItemPermission(parent=self, **{self._cloud_data_key: item}) + for item in data.get('value', [])] + + +class File(DriveItem, DownloadableMixin): + """ A File """ + + def __init__(self, **kwargs): + super().__init__(**kwargs) + cloud_data = kwargs.get(self._cloud_data_key, {}) + + #: The MIME type for the file. |br| **Type:** str + self.mime_type = cloud_data.get(self._cc('file'), {}).get( + self._cc('mimeType'), None) + + #: Hashes of the file's binary content, if available. |br| **Type:** Hashes + self.hashes = cloud_data.get(self._cc('file'), {}).get( + self._cc('hashes'), None) + + @property + def extension(self): + """The suffix of the file name. + + :getter: get the suffix + :type: str + """ + return Path(self.name).suffix + + +class Image(File): + """ An Image """ + + def __init__(self, **kwargs): + super().__init__(**kwargs) + cloud_data = kwargs.get(self._cloud_data_key, {}) + + image = cloud_data.get(self._cc('image'), {}) + #: Height of the image, in pixels. |br| **Type:** int + self.height = image.get(self._cc('height'), 0) + #: Width of the image, in pixels. |br| **Type:** int + self.width = image.get(self._cc('width'), 0) + + @property + def dimensions(self): + """ Dimension of the Image + + :return: width x height + :rtype: str + """ + return '{}x{}'.format(self.width, self.height) + + +class Photo(Image): + """ Photo Object. Inherits from Image but has more attributes """ + + def __init__(self, **kwargs): + super().__init__(**kwargs) + cloud_data = kwargs.get(self._cloud_data_key, {}) + + photo = cloud_data.get(self._cc('photo'), {}) + + taken = photo.get(self._cc('takenDateTime'), None) + local_tz = self.protocol.timezone + #: Represents the date and time the photo was taken. |br| **Type:** datetime + self.taken_datetime = parse(taken).astimezone( + local_tz) if taken else None + #: Camera manufacturer. |br| **Type:** str + self.camera_make = photo.get(self._cc('cameraMake'), None) + #: Camera model. |br| **Type:** str + self.camera_model = photo.get(self._cc('cameraModel'), None) + #: The denominator for the exposure time fraction from the camera. |br| **Type:** float + self.exposure_denominator = photo.get(self._cc('exposureDenominator'), + None) + #: The numerator for the exposure time fraction from the camera. |br| **Type:** float + self.exposure_numerator = photo.get(self._cc('exposureNumerator'), None) + #: The F-stop value from the camera |br| **Type:** float + self.fnumber = photo.get(self._cc('fNumber'), None) + #: The focal length from the camera. |br| **Type:** float + self.focal_length = photo.get(self._cc('focalLength'), None) + #: The ISO value from the camera. |br| **Type:** int + self.iso = photo.get(self._cc('iso'), None) + + +class Folder(DriveItem): + """ A Folder inside a Drive """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + cloud_data = kwargs.get(self._cloud_data_key, {}) + + #: Number of children contained immediately within this container. |br| **Type:** int + self.child_count = cloud_data.get(self._cc('folder'), {}).get( + self._cc('childCount'), 0) + #: The unique identifier for this item in the /drive/special collection. |br| **Type:** str + self.special_folder = cloud_data.get(self._cc('specialFolder'), {}).get( + 'name', None) + + def get_items(self, limit=None, *, query=None, order_by=None, batch=None): + """ Returns generator all the items inside this folder + + :param int limit: max no. of folders to get. Over 999 uses batch. + :param query: applies a OData filter to the request + :type query: Query or str + :param order_by: orders the result set based on this condition + :type order_by: Query or str + :param int batch: batch size, retrieves items in + batches allowing to retrieve more items than the limit. + :return: items in this folder + :rtype: generator of DriveItem or Pagination + """ + + url = self.build_url( + self._endpoints.get('list_items').format(id=self.object_id)) + + if limit is None or limit > self.protocol.max_top_value: + batch = self.protocol.max_top_value + + params = {'$top': batch if batch else limit} + + if order_by: + params['$orderby'] = order_by + + if query: + if isinstance(query, str): + params['$filter'] = query + else: + params.update(query.as_params()) + + response = self.con.get(url, params=params) + if not response: + return iter(()) + + data = response.json() + + # Everything received from cloud must be passed as self._cloud_data_key + items = ( + self._classifier(item)(parent=self, **{self._cloud_data_key: item}) + for item in data.get('value', [])) + next_link = data.get(NEXT_LINK_KEYWORD, None) + if batch and next_link: + return Pagination(parent=self, data=items, + constructor=self._classifier, + next_link=next_link, limit=limit) + else: + return items + + def get_child_folders(self, limit=None, *, query=None, order_by=None, batch=None): + """ Returns all the folders inside this folder + + :param int limit: max no. of folders to get. Over 999 uses batch. + :param query: applies a OData filter to the request + :type query: Query or str + :param order_by: orders the result set based on this condition + :type order_by: Query or str + :param int batch: batch size, retrieves items in + batches allowing to retrieve more items than the limit. + :return: folder items in this folder + :rtype: generator of DriveItem or Pagination + """ + + if query: + if not isinstance(query, str): + if isinstance(query, CompositeFilter): + q = QueryBuilder(protocol=self.protocol) + query = query & q.unequal('folder', None) + else: + query = query.on_attribute('folder').unequal(None) + else: + q = QueryBuilder(protocol=self.protocol) + query = q.unequal('folder', None) + + return self.get_items(limit=limit, query=query, order_by=order_by, batch=batch) + + def create_child_folder(self, name, description=None): + """ Creates a Child Folder + + :param str name: the name of the new child folder + :param str description: the description of the new child folder + :return: newly created folder + :rtype: drive.Folder + """ + + if not self.object_id: + return None + + url = self.build_url( + self._endpoints.get('list_items').format(id=self.object_id)) + + data = {'name': name, 'folder': {}} + if description: + data['description'] = description + + response = self.con.post(url, data=data) + if not response: + return None + + folder = response.json() + + return self._classifier(folder)(parent=self, + **{self._cloud_data_key: folder}) + + def download_contents(self, to_folder=None): + """ This will download each file and folder sequentially. + Caution when downloading big folder structures + :param drive.Folder to_folder: folder where to store the contents + """ + if to_folder is None: + try: + to_folder = Path() / self.name + except Exception as e: + log.error('Could not create folder with name: {}. Error: {}'.format(self.name, e)) + to_folder = Path() # fallback to the same folder + else: + to_folder = Path() / to_folder + if not to_folder.exists(): + to_folder.mkdir() + if not isinstance(to_folder, str): + if not to_folder.exists(): + to_folder.mkdir() + else: + to_folder = Path() / self.name + + for item in self.get_items(query=self.new_query().select('id', 'size', 'folder', 'name')): + if item.is_folder and item.child_count > 0: + item.download_contents(to_folder=to_folder / item.name) + elif item.is_folder and item.child_count == 0: + # Create child folder without contents. + child_folder = to_folder / item.name + if not child_folder.exists(): + child_folder.mkdir() + else: + item.download(to_folder) + + def search(self, search_text, limit=None, *, query=None, order_by=None, + batch=None): + """ Search for DriveItems under this folder + The search API uses a search service under the covers, + which requires indexing of content. + + As a result, there will be some time between creation of an item + and when it will appear in search results. + + :param str search_text: The query text used to search for items. + Values may be matched across several fields including filename, + metadata, and file content. + :param int limit: max no. of folders to get. Over 999 uses batch. + :param query: applies a OData filter to the request + :type query: Query or str + :param order_by: orders the result set based on this condition + :type order_by: Query or str + :param int batch: batch size, retrieves items in + batches allowing to retrieve more items than the limit. + :return: items in this folder matching search + :rtype: generator of DriveItem or Pagination + """ + if not isinstance(search_text, str) or not search_text: + raise ValueError('Provide a valid search_text') + + url = self.build_url( + self._endpoints.get('search').format(id=self.object_id, + search_text=search_text)) + + if limit is None or limit > self.protocol.max_top_value: + batch = self.protocol.max_top_value + + params = {'$top': batch if batch else limit} + + if order_by: + params['$orderby'] = order_by + + if query: + if isinstance(query, str): + params['$filter'] = query + else: + if query.has_filters: + warnings.warn( + 'Filters are not allowed by the Api ' + 'Provider in this method') + query.clear_filters() + params.update(query.as_params()) + + response = self.con.get(url, params=params) + if not response: + return iter(()) + + data = response.json() + + # Everything received from cloud must be passed as self._cloud_data_key + items = ( + self._classifier(item)(parent=self, **{self._cloud_data_key: item}) + for item in data.get('value', [])) + next_link = data.get(NEXT_LINK_KEYWORD, None) + if batch and next_link: + return Pagination(parent=self, data=items, + constructor=self._classifier, + next_link=next_link, limit=limit) + else: + return items + + def upload_file( + self, + item, + item_name=None, + chunk_size=DEFAULT_UPLOAD_CHUNK_SIZE, + upload_in_chunks=False, + stream=None, + stream_size=None, + conflict_handling=None, + file_created_date_time: str = None, + file_last_modified_date_time: str= None + ): + """ Uploads a file + + :param item: path to the item you want to upload + :type item: str or Path + :param item_name: name of the item on the server. None to use original name + :type item_name: str or Path + :param chunk_size: Only applies if file is bigger than 4MB or upload_in_chunks is True. + Chunk size for uploads. Must be a multiple of 327.680 bytes + :param upload_in_chunks: force the method to upload the file in chunks + :param io.BufferedIOBase stream: (optional) an opened io object to read into. + if set, the to_path and name will be ignored + :param int stream_size: size of stream, required if using stream + :param conflict_handling: How to handle conflicts. + NOTE: works for chunk upload only (>4MB or upload_in_chunks is True) + None to use default (overwrite). Options: fail | replace | rename + :param file_created_date_time: allow to force file created date time while uploading + :param file_last_modified_date_time: allow to force file last modified date time while uploading + :type conflict_handling: str + :return: uploaded file + :rtype: DriveItem + """ + + if not stream: + if item is None: + raise ValueError('Item must be a valid path to file') + item = Path(item) if not isinstance(item, Path) else item + + if not item.exists(): + raise ValueError('Item must exist') + if not item.is_file(): + raise ValueError('Item must be a file') + + file_size = (stream_size if stream_size is not None else item.stat().st_size) + + if not upload_in_chunks and file_size <= UPLOAD_SIZE_LIMIT_SIMPLE: + # Simple Upload + url = self.build_url( + self._endpoints.get('simple_upload').format(id=self.object_id, + filename=quote(item.name if item_name is None else item_name))) + # headers = {'Content-type': 'text/plain'} + headers = {'Content-type': 'application/octet-stream'} + # headers = None + if stream: + data = stream.read() + else: + with item.open(mode='rb') as file: + data = file.read() + + response = self.con.put(url, headers=headers, data=data) + if not response: + return None + + data = response.json() + + return self._classifier(data)(parent=self, + **{self._cloud_data_key: data}) + else: + # Resumable Upload + url = self.build_url( + self._endpoints.get('create_upload_session').format( + id=self.object_id, filename=quote(item.name if item_name is None else item_name))) + + # WARNING : order matters in the dict, first we need to set conflictBehavior (if any) and then createdDateTime, otherwise microsoft refuses the api + # call... + file_data = {} + if conflict_handling: + file_data.setdefault("item", dict())["@microsoft.graph.conflictBehavior"] = conflict_handling + if file_created_date_time: + file_data.setdefault("item", dict()).setdefault("fileSystemInfo", dict())["createdDateTime"] = file_created_date_time + if file_last_modified_date_time: + file_data.setdefault("item", dict()).setdefault("fileSystemInfo", dict())["lastModifiedDateTime"] = file_last_modified_date_time + + log.info(f'Uploading file with {file_data=}') + + response = self.con.post(url, data=file_data) + if not response: + return None + + data = response.json() + + upload_url = data.get(self._cc('uploadUrl'), None) + log.info('Resumable upload on url: {}'.format(upload_url)) + expiration_date = data.get(self._cc('expirationDateTime'), None) + if expiration_date: + log.info('Expiration Date for this upload url is: {}'.format(expiration_date)) + if upload_url is None: + log.error('Create upload session response without ' + 'upload_url for file {}'.format(item.name)) + return None + + def write_stream(file): + current_bytes = 0 + while True: + data = file.read(chunk_size) + if not data: + break + transfer_bytes = len(data) + headers = { + 'Content-type': 'application/octet-stream', + 'Content-Length': str(len(data)), + 'Content-Range': 'bytes {}-{}/{}' + ''.format(current_bytes, + current_bytes + + transfer_bytes - 1, + file_size) + } + current_bytes += transfer_bytes + + # this request mut NOT send the authorization header. + # so we use a naive simple request. + response = self.con.naive_request(upload_url, 'PUT', + data=data, + headers=headers) + if not response: + return None + + if response.status_code != 202: + # file is completed + data = response.json() + return self._classifier(data)(parent=self, **{ + self._cloud_data_key: data}) + + if stream: + return write_stream(stream) + else: + with item.open(mode='rb') as file: + return write_stream(file) + + +class Drive(ApiComponent): + """ A Drive representation. + A Drive is a Container of Folders and Files and act as a root item """ + + _endpoints = { + 'default_drive': '/drive', + 'get_drive': '/drives/{id}', + 'get_root_item_default': '/drive/root', + 'get_root_item': '/drives/{id}/root', + 'list_items_default': '/drive/root/children', + 'list_items': '/drives/{id}/root/children', + 'get_item_default': '/drive/items/{item_id}', + 'get_item': '/drives/{id}/items/{item_id}', + 'get_item_by_path_default': '/drive/root:{item_path}', + 'get_item_by_path': '/drives/{id}/root:{item_path}', + 'recent_default': '/drive/recent', + 'recent': '/drives/{id}/recent', + 'shared_with_me_default': '/drive/sharedWithMe', + 'shared_with_me': '/drives/{id}/sharedWithMe', + 'get_special_default': '/drive/special/{name}', + 'get_special': '/drives/{id}/special/{name}', + 'search_default': "/drive/search(q='{search_text}')", + 'search': "/drives/{id}/search(q='{search_text}')", + } + + def __init__(self, *, parent=None, con=None, **kwargs): + """ Create a drive representation + + :param parent: parent for this operation + :type parent: Drive or Storage + :param Connection con: connection to use if no parent specified + :param Protocol protocol: protocol to use if no parent specified + (kwargs) + :param str main_resource: use this resource instead of parent resource + (kwargs) + """ + if parent and con: + raise ValueError('Need a parent or a connection but not both') + self.con = parent.con if parent else con + #: The parent of the Drive. |br| **Type:** Drive + self.parent = parent if isinstance(parent, Drive) else None + + # Choose the main_resource passed in kwargs over parent main_resource + main_resource = kwargs.pop('main_resource', None) + if main_resource is None: + main_resource = getattr(parent, 'main_resource', None) if parent else None + super().__init__( + protocol=parent.protocol if parent else kwargs.get('protocol'), + main_resource=main_resource) + + self._update_data(kwargs) + + def _update_data(self, data): + cloud_data = data.get(self._cloud_data_key, {}) + + self.object_id = cloud_data.get(self._cc('id')) + # Fallback to manual drive + self.name = cloud_data.get(self._cc('name'), data.get('name', + '')) + self.description = cloud_data.get(self._cc('description')) + self.drive_type = cloud_data.get(self._cc('driveType')) + self.web_url = cloud_data.get(self._cc('webUrl')) + + owner = cloud_data.get(self._cc('owner'), {}).get('user', None) + self.owner = Contact(con=self.con, protocol=self.protocol, + **{self._cloud_data_key: owner}) if owner else None + self.quota = cloud_data.get(self._cc('quota')) # dict + + created = cloud_data.get(self._cc('createdDateTime'), None) + modified = cloud_data.get(self._cc('lastModifiedDateTime'), None) + local_tz = self.protocol.timezone + self.created = parse(created).astimezone(local_tz) if created else None + self.modified = parse(modified).astimezone( + local_tz) if modified else None + + def __str__(self): + return self.__repr__() + + def __repr__(self): + owner = str(self.owner) if self.owner else '' + name = self.name or self.object_id or 'Default Drive' + if owner: + return 'Drive: {} (Owned by: {})'.format(name, owner) + else: + return 'Drive: {}'.format(name) + + def __eq__(self, other): + return self.object_id == other.object_id + + def get_root_folder(self): + """ Returns the Root Folder of this drive + + :return: Root Folder + :rtype: DriveItem + """ + if self.object_id: + # reference the current drive_id + url = self.build_url( + self._endpoints.get('get_root_item').format(id=self.object_id)) + else: + # we don't know the drive_id so go to the default drive + url = self.build_url(self._endpoints.get('get_root_item_default')) + + response = self.con.get(url) + if not response: + return None + + data = response.json() + + # Everything received from cloud must be passed as self._cloud_data_key + return self._classifier(data)(parent=self, + **{self._cloud_data_key: data}) + + def _base_get_list(self, url, limit=None, *, query=None, order_by=None, + batch=None, params={}): + """ Returns a collection of drive items """ + + if limit is None or limit > self.protocol.max_top_value: + batch = self.protocol.max_top_value + + params['$top'] = batch if batch else limit + + if order_by: + params['$orderby'] = order_by + + if query: + if isinstance(query, str): + params['$filter'] = query + else: + params.update(query.as_params()) + + response = self.con.get(url, params=params) + if not response: + return iter(()) + + data = response.json() + + # Everything received from cloud must be passed as self._cloud_data_key + items = ( + self._classifier(item)(parent=self, **{self._cloud_data_key: item}) + for item in data.get('value', [])) + + next_link = data.get(NEXT_LINK_KEYWORD, None) + if batch and next_link: + return Pagination(parent=self, data=items, + constructor=self._classifier, + next_link=next_link, limit=limit) + else: + return items + + def get_items(self, limit=None, *, query=None, order_by=None, batch=None): + """ Returns a collection of drive items from the root folder + + :param int limit: max no. of items to get. Over 999 uses batch. + :param query: applies a OData filter to the request + :type query: Query or str + :param order_by: orders the result set based on this condition + :type order_by: Query or str + :param int batch: batch size, retrieves items in + batches allowing to retrieve more items than the limit. + :return: items in this folder + :rtype: generator of DriveItem or Pagination + """ + + if self.object_id: + # reference the current drive_id + url = self.build_url( + self._endpoints.get('list_items').format(id=self.object_id)) + else: + # we don't know the drive_id so go to the default + url = self.build_url(self._endpoints.get('list_items_default')) + + return self._base_get_list(url, limit=limit, query=query, + order_by=order_by, batch=batch) + + def get_child_folders(self, limit=None, *, query=None, order_by=None, batch=None): + """ Returns all the folders inside this folder + + :param int limit: max no. of folders to get. Over 999 uses batch. + :param query: applies a OData filter to the request + :type query: Query or str + :param order_by: orders the result set based on this condition + :type order_by: Query or str + :param int batch: batch size, retrieves items in + batches allowing to retrieve more items than the limit. + :return: folder items in this folder + :rtype: generator of DriveItem or Pagination + """ + if query: + if not isinstance(query, str): + if isinstance(query, CompositeFilter): + q = QueryBuilder(protocol=self.protocol) + query = query & q.unequal('folder', None) + else: + query = query.on_attribute('folder').unequal(None) + else: + q = QueryBuilder(protocol=self.protocol) + query = q.unequal('folder', None) + + return self.get_items(limit=limit, query=query, order_by=order_by, batch=batch) + + def get_recent(self, limit=None, *, query=None, order_by=None, batch=None): + """ Returns a collection of recently used DriveItems + + :param int limit: max no. of items to get. Over 999 uses batch. + :param query: applies a OData filter to the request + :type query: Query or str + :param order_by: orders the result set based on this condition + :type order_by: Query or str + :param int batch: batch size, retrieves items in + batches allowing to retrieve more items than the limit. + :return: items in this folder + :rtype: generator of DriveItem or Pagination + """ + if self.object_id: + # reference the current drive_id + url = self.build_url( + self._endpoints.get('recent').format(id=self.object_id)) + else: + # we don't know the drive_id so go to the default + url = self.build_url(self._endpoints.get('recent_default')) + + return self._base_get_list(url, limit=limit, query=query, + order_by=order_by, batch=batch) + + def get_shared_with_me(self, limit=None, allow_external=False, *, query=None, order_by=None, + batch=None): + """ Returns a collection of DriveItems shared with me + + :param int limit: max no. of items to get. Over 999 uses batch. + :param query: applies a OData filter to the request + :type query: Query or str + :param order_by: orders the result set based on this condition + :type order_by: Query or str + :param int batch: batch size, retrieves items in + batches allowing to retrieve more items than the limit. + :param allow_external: includes items shared from external tenants + :type allow_external: bool + :return: items in this folder + :rtype: generator of DriveItem or Pagination + """ + + if self.object_id: + # reference the current drive_id + url = self.build_url( + self._endpoints.get('shared_with_me').format(id=self.object_id)) + else: + # we don't know the drive_id so go to the default + url = self.build_url(self._endpoints.get('shared_with_me_default')) + + # whether to include driveitems external to tenant + params = {"allowexternal": allow_external} + + return self._base_get_list(url, limit=limit, query=query, + order_by=order_by, batch=batch, params=params) + + def get_item(self, item_id): + """ Returns a DriveItem by it's Id + + :return: one item + :rtype: DriveItem + """ + if self.object_id: + # reference the current drive_id + url = self.build_url( + self._endpoints.get('get_item').format(id=self.object_id, + item_id=item_id)) + else: + # we don't know the drive_id so go to the default drive + url = self.build_url( + self._endpoints.get('get_item_default').format(item_id=item_id)) + + response = self.con.get(url) + if not response: + return None + + data = response.json() + + # Everything received from cloud must be passed as self._cloud_data_key + return self._classifier(data)(parent=self, + **{self._cloud_data_key: data}) + + def get_item_by_path(self, item_path): + """ Returns a DriveItem by it's absolute path: /path/to/file + :return: one item + :rtype: DriveItem + """ + + if not item_path.startswith("/"): + item_path = "/" + item_path + + if self.object_id: + # reference the current drive_id + url = self.build_url( + self._endpoints.get('get_item_by_path').format(id=self.object_id, + item_path=item_path)) + else: + # we don't know the drive_id so go to the default drive + url = self.build_url( + self._endpoints.get('get_item_by_path_default').format(item_path=item_path)) + + response = self.con.get(url) + if not response: + return None + + data = response.json() + + # Everything received from cloud must be passed as self._cloud_data_key + return self._classifier(data)(parent=self, + **{self._cloud_data_key: data}) + + def get_special_folder(self, name): + """ Returns the specified Special Folder + + :return: a special Folder + :rtype: drive.Folder + """ + + name = name if \ + isinstance(name, OneDriveWellKnowFolderNames) \ + else OneDriveWellKnowFolderNames(name.lower()) + name = name.value + + if self.object_id: + # reference the current drive_id + url = self.build_url( + self._endpoints.get('get_special').format(id=self.object_id, + name=name)) + else: + # we don't know the drive_id so go to the default + url = self.build_url( + self._endpoints.get('get_special_default').format(name=name)) + + response = self.con.get(url) + if not response: + return None + + data = response.json() + + # Everything received from cloud must be passed as self._cloud_data_key + return self._classifier(data)(parent=self, + **{self._cloud_data_key: data}) + + @staticmethod + def _classifier(item): + """ Subclass to change factory classes """ + if 'folder' in item: + return Folder + elif 'image' in item: + return Image + elif 'photo' in item: + return Photo + else: + return File + + def refresh(self): + """ Updates this drive with data from the server + + :return: Success / Failure + :rtype: bool + """ + + if self.object_id is None: + url = self.build_url(self._endpoints.get('default_drive')) + else: + url = self.build_url( + self._endpoints.get('get_drive').format(id=self.object_id)) + + response = self.con.get(url) + if not response: + return False + + drive = response.json() + + self._update_data({self._cloud_data_key: drive}) + return True + + def search(self, search_text, limit=None, *, query=None, order_by=None, + batch=None): + """ Search for DriveItems under this drive. + Your app can search more broadly to include items shared with the + current user. + + To broaden the search scope, use this search instead the Folder Search. + + The search API uses a search service under the covers, which requires + indexing of content. + + As a result, there will be some time between creation of an + item and when it will appear in search results. + + :param str search_text: The query text used to search for items. + Values may be matched across several fields including filename, + metadata, and file content. + :param int limit: max no. of items to get. Over 999 uses batch. + :param query: applies a OData filter to the request + :type query: Query or str + :param order_by: orders the result set based on this condition + :type order_by: Query or str + :param int batch: batch size, retrieves items in + batches allowing to retrieve more items than the limit. + :return: items in this folder matching search + :rtype: generator of DriveItem or Pagination + """ + if not isinstance(search_text, str) or not search_text: + raise ValueError('Provide a valid search_text') + + if self.object_id is None: + url = self.build_url(self._endpoints.get('search_default').format( + search_text=search_text)) + else: + url = self.build_url( + self._endpoints.get('search').format(id=self.object_id, + search_text=search_text)) + + if limit is None or limit > self.protocol.max_top_value: + batch = self.protocol.max_top_value + + params = {'$top': batch if batch else limit} + + if order_by: + params['$orderby'] = order_by + + if query: + if query.has_filters: + warnings.warn( + 'Filters are not allowed by the Api Provider ' + 'in this method') + query.clear_filters() + if isinstance(query, str): + params['$filter'] = query + else: + params.update(query.as_params()) + + response = self.con.get(url, params=params) + if not response: + return iter(()) + + data = response.json() + + # Everything received from cloud must be passed as self._cloud_data_key + items = ( + self._classifier(item)(parent=self, **{self._cloud_data_key: item}) + for item in data.get('value', [])) + next_link = data.get(NEXT_LINK_KEYWORD, None) + if batch and next_link: + return Pagination(parent=self, data=items, + constructor=self._classifier, + next_link=next_link, limit=limit) + else: + return items + + +class Storage(ApiComponent): + """ Parent Class that holds drives """ + + _endpoints = { + 'default_drive': '/drive', + 'get_drive': '/drives/{id}', + 'list_drives': '/drives', + } + drive_constructor = Drive #: :meta private: + + def __init__(self, *, parent=None, con=None, **kwargs): + """ Create a storage representation + + :param parent: parent for this operation + :type parent: Account + :param Connection con: connection to use if no parent specified + :param Protocol protocol: protocol to use if no parent specified + (kwargs) + :param str main_resource: use this resource instead of parent resource + (kwargs) + """ + if parent and con: + raise ValueError('Need a parent or a connection but not both') + self.con = parent.con if parent else con + + # Choose the main_resource passed in kwargs over parent main_resource + main_resource = kwargs.pop('main_resource', None) or ( + getattr(parent, 'main_resource', None) if parent else None) + super().__init__( + protocol=parent.protocol if parent else kwargs.get('protocol'), + main_resource=main_resource) + + def __str__(self): + return self.__repr__() + + def __repr__(self): + return 'Storage for resource: {}'.format(self.main_resource) + + def get_default_drive(self, request_drive=False): + """ Returns a Drive instance + + :param request_drive: True will make an api call to retrieve the drive + data + :return: default One Drive + :rtype: Drive + """ + if request_drive is False: + return Drive(con=self.con, protocol=self.protocol, + main_resource=self.main_resource, name='Default Drive') + + url = self.build_url(self._endpoints.get('default_drive')) + + response = self.con.get(url) + if not response: + return None + + drive = response.json() + + # Everything received from cloud must be passed as self._cloud_data_key + return self.drive_constructor(con=self.con, protocol=self.protocol, + main_resource=self.main_resource, + **{self._cloud_data_key: drive}) + + def get_drive(self, drive_id): + """ Returns a Drive instance + + :param drive_id: the drive_id to be retrieved + :return: Drive for the id + :rtype: Drive + """ + if not drive_id: + return None + + url = self.build_url( + self._endpoints.get('get_drive').format(id=drive_id)) + + response = self.con.get(url) + if not response: + return None + + drive = response.json() + + # Everything received from cloud must be passed as self._cloud_data_key + return self.drive_constructor(con=self.con, protocol=self.protocol, + main_resource=self.main_resource, + **{self._cloud_data_key: drive}) + + def get_drives(self): + """ Returns a collection of drives""" + + url = self.build_url(self._endpoints.get('list_drives')) + + response = self.con.get(url) + + if not response: + return [] + + data = response.json() + + # Everything received from cloud must be passed as self._cloud_data_key + drives = [self.drive_constructor(parent=self, **{self._cloud_data_key: drive}) for + drive in data.get('value', [])] + + return drives diff --git a/O365/event.py b/O365/event.py deleted file mode 100644 index 541747d0..00000000 --- a/O365/event.py +++ /dev/null @@ -1,298 +0,0 @@ -# Copyright 2015 by Toben "Narcolapser" Archer. All Rights Reserved. -# -# Permission to use, copy, modify, and distribute this software and its documentation for any purpose -# and without fee is hereby granted, provided that the above copyright notice appear in all copies and -# that both that copyright notice and this permission notice appear in supporting documentation, and -# that the name of Toben Archer not be used in advertising or publicity pertaining to distribution of -# the software without specific, written prior permission. TOBEN ARCHER DISCLAIMS ALL WARRANTIES WITH -# REGARD TO THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT -# SHALL TOBEN ARCHER BE LIABLE FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES -# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE -# OR OTHER TORTIOUS ACTION, ARISING OUT -# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. - -import logging -import json -import requests -import time - -logging.basicConfig(filename='o365.log',level=logging.DEBUG) - -log = logging.getLogger(__name__) - -class Event( object ): - ''' - Class for managing the creation and manipluation of events in a calendar. - - Methods: - create -- Creates the event in a calendar. - update -- Sends local changes up to the cloud. - delete -- Deletes event from the cloud. - toJson -- returns the json representation. - fullcalendarioJson -- gets a specific json representation used for fullcalendario. - getSubject -- gets the subject of the event. - getBody -- gets the body of the event. - getStart -- gets the starting time of the event. (struct_time) - getEnd -- gets the ending time of the event. (struct_time) - getAttendees -- gets the attendees of the event. - addAttendee -- adds an attendee to the event. update needs to be called for notification. - setSubject -- sets the subject line of the event. - setBody -- sets the body of the event. - setStart -- sets the starting time of the event. (struct_time) - setEnd -- sets the starting time of the event. (struct_time) - setAttendees -- sets the attendee list. - - Variables: - time_string -- Formated time string for translation to and from json. - create_url -- url for creating a new event. - update_url -- url for updating an existing event. - delete_url -- url for deleting an event. - ''' - #Formated time string for translation to and from json. - time_string = '%Y-%m-%dT%H:%M:%SZ' - #takes a calendar ID - create_url = 'https://outlook.office365.com/api/v1.0/me/calendars/{0}/events' - #takes current event ID - update_url = 'https://outlook.office365.com/api/v1.0/me/events/{0}' - #takes current event ID - delete_url = 'https://outlook.office365.com/api/v1.0/me/events/{0}' - - - def __init__(self,json=None,auth=None,cal=None): - ''' - Creates a new event wrapper. - - Keyword Argument: - json (default = None) -- json representation of an existing event. mostly just used by - this library internally for events that are downloaded by the callendar class. - auth (default = None) -- a (email,password) tuple which will be used for authentication - to office365. - cal (default = None) -- an instance of the calendar for this event to associate with. - ''' - self.auth = auth - self.calendar = cal - self.attendees = [] - - if json: - self.json = json - self.isNew = False - else: - self.json = {} - - - def create(self,calendar=None): - ''' - this method creates an event on the calender passed. - - IMPORTANT: It returns that event now created in the calendar, if you wish - to make any changes to this event after you make it, use the returned value - and not this particular event any further. - - calendar -- a calendar class onto which you want this event to be created. If this is left - empty then the event's default calendar, specified at instancing, will be used. If no - default is specified, then the event cannot be created. - - ''' - if not self.auth: - log.debug('failed authentication check when creating event.') - return False - - if calendar: - calId = calendar.calendarId - self.calendar = calendar - log.debug('sent to passed calendar.') - elif self.calendar: - calId = self.calendar.calendarId - log.debug('sent to default calendar.') - else: - log.debug('no valid calendar to upload to.') - return False - - headers = {'Content-type': 'application/json', 'Accept': 'application/json'} - - log.debug('creating json for request.') - data = json.dumps(self.json) - - response = None - try: - log.debug('sending post request now') - response = requests.post(self.create_url.format(calId),data,headers=headers,auth=self.auth) - log.debug('sent post request.') - except Exception as e: - if response: - log.debug('response to event creation: %s',str(response)) - else: - log.error('No response, something is very wrong with create: %s',str(e)) - return False - - log.debug('response to event creation: %s',str(response)) - return Event(response.json(),self.auth,calendar) - - def update(self): - '''Updates an event that already exists in a calendar.''' - if not self.auth: - return False - - if self.calendar: - calId = self.calendar.calendarId - else: - return False - - - headers = {'Content-type': 'application/json', 'Accept': 'application/json'} - - data = json.dumps(self.json) - - response = None - try: - response = requests.patch(self.update_url.format(self.json['Id']),data,headers=headers,auth=self.auth) - log.debug('sending patch request now') - except Exception as e: - if response: - log.debug('response to event creation: %s',str(response)) - else: - log.error('No response, something is very wrong with update: %s',str(e)) - return False - - log.debug('response to event creation: %s',str(response)) - - return Event(response.json(),self.auth) - - - def delete(self): - ''' - Delete's an event from the calendar it is in. - - But leaves you this handle. You could then change the calendar and transfer the event to - that new calendar. You know, if that's your thing. - ''' - if not self.auth: - return False - - headers = {'Content-type': 'application/json', 'Accept': 'text/plain'} - - response = None - try: - log.debug('sending delete request') - response = requests.delete(self.delete_url.format(self.json['Id']),headers=headers,auth=self.auth) - - except Exception as e: - if response: - log.debug('response to deletion: %s',str(response)) - else: - log.error('No response, something is very wrong with delete: %s',str(e)) - return False - - return response - - def toJson(self): - ''' - Creates a JSON representation of the calendar event. - - oh. uh. I mean it simply returns the json representation that has always been in self.json. - ''' - return self.json - - def fullcalendarioJson(self): - ''' - returns a form of the event suitable for the vehicle booking system here. - oh the joys of having a library to yourself! - ''' - ret = {} - ret['title'] = self.json['Subject'] - ret['driver'] = self.json['Organizer']['EmailAddress']['Name'] - ret['driverEmail'] = self.json['Organizer']['EmailAddress']['Address'] - ret['start'] = self.json['Start'] - ret['end'] = self.json['End'] - ret['IsAllDay'] = self.json['IsAllDay'] - return ret - - def getSubject(self): - '''Gets event subject line.''' - return self.json['Subject'] - - def getBody(self): - '''Gets event body content.''' - return self.json['Body']['Content'] - - def getStart(self): - '''Gets event start struct_time''' - return time.strptime(self.json['Start'], self.time_string) - - def getEnd(self): - '''Gets event end struct_time''' - return time.strptime(self.json['End'], self.time_string) - - def getAttendees(self): - '''Gets list of event attendees.''' - return self.json['Attendees'] - -# def addAttendee(self,val): -# '''adds an attendee to the event. must call update for notification to send.''' -# self.json['Attendees'].append(val) - - def setSubject(self,val): - '''sets event subject line.''' - self.json['Subject'] = val - - def setBody(self,val): - '''sets event body content.''' - self.json['Body']['Content'] = val - - def setStart(self,val): - '''sets event start struct_time.''' - self.json['Start'] = time.strftime(self.time_string,val) - - def setEnd(self,val): - '''sets event end struct_time.''' - self.json['End'] = time.strftime(self.time_string,val) - - def setAttendee(self,val): - ''' - set the recipient list. - - val: the one argument this method takes can be very flexible. you can send: - a dictionary: this must to be a dictionary formated as such: - {"EmailAddress":{"Address":"recipient@example.com"}} - with other options such ass "Name" with address. but at minimum it must have this. - a list: this must to be a list of libraries formatted the way specified above, - or it can be a list of libraries objects of type Contact. The method will sort - out the libraries from the contacts. - a string: this is if you just want to throw an email address. - a contact: type Contact from this library. - For each of these argument types the appropriate action will be taken to fit them to the - needs of the library. - ''' - if isinstance(val,list): - self.json['Attendees'] = val - elif isinstance(val,dict): - self.json['Attendees'] = [val] - elif isinstance(val,str): - if '@' in val: - self.json['Attendees'] = [] - self.addRecipient(val) - elif isinstance(val,Contact): - self.json['Attendees'] = [] - self.addRecipient(val) - else: - return False - return True - - def addAttendee(self,address,name=None): - ''' - Adds a recipient to the attendee list. - - Arguments: - address -- the email address of the person you are sending to. <<< Important that. - Address can also be of type contact. if it is name is superflous. Else, it - uses the name passed if you sent it one. - name -- the name of the person you are sending to. mostly just a decorator. - ''' - if isinstance(address,Contact): - self.json['Attendees'].append(address.getFirstEmailAddress()) - else: - if name is None: - name = address[:address.index('@')] - self.json['Attendees'].append({'EmailAddress':{'Address':address,'Name':name}}) - -#To the King! diff --git a/O365/excel.py b/O365/excel.py new file mode 100644 index 00000000..fa690135 --- /dev/null +++ b/O365/excel.py @@ -0,0 +1,2302 @@ +""" +2019-04-15 +Note: Support for workbooks stored in OneDrive Consumer platform is still not available. +At this time, only the files stored in business platform is supported by Excel REST APIs. +""" + +import datetime as dt +import logging +import re +from urllib.parse import quote + +from .drive import File +from .utils import ApiComponent, TrackerSet, to_snake_case, col_index_to_label + +log = logging.getLogger(__name__) + +PERSISTENT_SESSION_INACTIVITY_MAX_AGE = 60 * 7 # 7 minutes +NON_PERSISTENT_SESSION_INACTIVITY_MAX_AGE = 60 * 5 # 5 minutes +EXCEL_XLSX_MIME_TYPE = ( + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" +) + + +UnsetSentinel = object() + + +# TODO Excel: WorkbookFormatProtection, WorkbookRangeBorder + + +class FunctionException(Exception): + pass + + +class WorkbookSession(ApiComponent): + """ + See https://docs.microsoft.com/en-us/graph/api/resources/excel?view=graph-rest-1.0#sessions-and-persistence + """ + + _endpoints = { + "create_session": "/createSession", + "refresh_session": "/refreshSession", + "close_session": "/closeSession", + } + + def __init__(self, *, parent=None, con=None, persist=True, **kwargs): + """Create a workbook session object. + + :param parent: parent for this operation + :param Connection con: connection to use if no parent specified + :param Bool persist: Whether or not to persist the session changes + """ + if parent and con: + raise ValueError("Need a parent or a connection but not both") + self.con = parent.con if parent else con + + # Choose the main_resource passed in kwargs over parent main_resource + main_resource = kwargs.pop("main_resource", None) or ( + getattr(parent, "main_resource", None) if parent else None + ) + + super().__init__( + protocol=parent.protocol if parent else kwargs.get("protocol"), + main_resource=main_resource, + ) + + #: Whether or not the session changes are persisted. |br| **Type:** bool + self.persist = persist + + #: The inactivity limit. |br| **Type:** timedelta + self.inactivity_limit = ( + dt.timedelta(seconds=PERSISTENT_SESSION_INACTIVITY_MAX_AGE) + if persist + else dt.timedelta(seconds=NON_PERSISTENT_SESSION_INACTIVITY_MAX_AGE) + ) + #: The session id. |br| **Type:** str + self.session_id = None + #: The time of last activity. |br| **Type:** datetime + self.last_activity = dt.datetime.now() + + def __str__(self): + return self.__repr__() + + def __repr__(self): + return "Workbook Session: {}".format(self.session_id or "Not set") + + def __bool__(self): + return self.session_id is not None + + def create_session(self): + """Request a new session id""" + + url = self.build_url(self._endpoints.get("create_session")) + response = self.con.post(url, data={"persistChanges": self.persist}) + if not response: + raise RuntimeError("Could not create session as requested by the user.") + data = response.json() + self.session_id = data.get("id") + + return True + + def refresh_session(self): + """Refresh the current session id""" + + if self.session_id: + url = self.build_url(self._endpoints.get("refresh_session")) + response = self.con.post( + url, headers={"workbook-session-id": self.session_id} + ) + return bool(response) + return False + + def close_session(self): + """Close the current session""" + + if self.session_id: + url = self.build_url(self._endpoints.get("close_session")) + response = self.con.post( + url, headers={"workbook-session-id": self.session_id} + ) + return bool(response) + return False + + def prepare_request(self, kwargs): + """If session is in use, prepares the request headers and + checks if the session is expired. + """ + if self.session_id is not None: + actual = dt.datetime.now() + + if (self.last_activity + self.inactivity_limit) < actual: + # session expired + if self.persist: + # request new session + self.create_session() + actual = dt.datetime.now() + else: + # raise error and recommend to manualy refresh session + raise RuntimeError( + "A non Persistent Session is expired. " + "For consistency reasons this exception is raised. " + "Please try again with manual refresh of the session " + ) + self.last_activity = actual + + headers = kwargs.get("headers") + if headers is None: + kwargs["headers"] = headers = {} + headers["workbook-session-id"] = self.session_id + + def get(self, *args, **kwargs): + self.prepare_request(kwargs) + return self.con.get(*args, **kwargs) + + def post(self, *args, **kwargs): + self.prepare_request(kwargs) + return self.con.post(*args, **kwargs) + + def put(self, *args, **kwargs): + self.prepare_request(kwargs) + return self.con.put(*args, **kwargs) + + def patch(self, *args, **kwargs): + self.prepare_request(kwargs) + return self.con.patch(*args, **kwargs) + + def delete(self, *args, **kwargs): + self.prepare_request(kwargs) + return self.con.delete(*args, **kwargs) + + +class RangeFormatFont: + """A font format applied to a range""" + + def __init__(self, parent): + #: The parent of the range format font. |br| **Type:** parent + self.parent = parent + self._track_changes = TrackerSet(casing=parent._cc) + self._loaded = False + + self._bold = False + self._color = "#000000" # default black + self._italic = False + self._name = "Calibri" + self._size = 10 + self._underline = "None" + + def _load_data(self): + """Loads the data into this instance""" + url = self.parent.build_url(self.parent._endpoints.get("format")) + response = self.parent.session.get(url) + if not response: + return False + data = response.json() + + self._bold = data.get("bold", False) + self._color = data.get("color", "#000000") # default black + self._italic = data.get("italic", False) + self._name = data.get("name", "Calibri") # default Calibri + self._size = data.get("size", 10) # default 10 + self._underline = data.get("underline", "None") + + self._loaded = True + return True + + def to_api_data(self, restrict_keys=None): + """Returns a dict to communicate with the server + + :param restrict_keys: a set of keys to restrict the returned data to + :rtype: dict + """ + cc = self.parent._cc # alias + data = { + cc("bold"): self._bold, + cc("color"): self._color, + cc("italic"): self._italic, + cc("name"): self._name, + cc("size"): self._size, + cc("underline"): self._underline, + } + + if restrict_keys: + for key in list(data.keys()): + if key not in restrict_keys: + del data[key] + return data + + @property + def bold(self): + if not self._loaded: + self._load_data() + return self._bold + + @bold.setter + def bold(self, value): + self._bold = value + self._track_changes.add("bold") + + @property + def color(self): + """The color of the range format font + + :getter: get the color + :setter: set the color + :type: str + """ + if not self._color: + self._load_data() + return self._color + + @color.setter + def color(self, value): + self._color = value + self._track_changes.add("color") + + @property + def italic(self): + """Is range format font in italics + + :getter: get the italic + :setter: set the italic + :type: bool + """ + if not self._loaded: + self._load_data() + return self._italic + + @italic.setter + def italic(self, value): + self._italic = value + self._track_changes.add("italic") + + @property + def name(self): + """The name of the range format font + + :getter: get the name + :setter: set the name + :type: str + """ + if not self._loaded: + self._load_data() + return self._name + + @name.setter + def name(self, value): + self._name = value + self._track_changes.add("name") + + @property + def size(self): + """The size of the range format font + + :getter: get the size + :setter: set the size + :type: int + """ + if not self._loaded: + self._load_data() + return self._size + + @size.setter + def size(self, value): + self._size = value + self._track_changes.add("size") + + @property + def underline(self): + """Is range format font underlined + + :getter: get the underline + :setter: set the underline + :type: bool + """ + if not self._loaded: + self._load_data() + return self._underline + + @underline.setter + def underline(self, value): + self._underline = value + self._track_changes.add("underline") + + +class RangeFormat(ApiComponent): + """A format applied to a range""" + + _endpoints = { + "borders": "/borders", + "font": "/font", + "fill": "/fill", + "clear_fill": "/fill/clear", + "auto_fit_columns": "/autofitColumns", + "auto_fit_rows": "/autofitRows", + } + + def __init__(self, parent=None, session=None, **kwargs): + if parent and session: + raise ValueError("Need a parent or a session but not both") + + #: The range of the range format. |br| **Type:** range + self.range = parent + #: The session for the range format. |br| **Type:** str + self.session = parent.session if parent else session + + # Choose the main_resource passed in kwargs over parent main_resource + main_resource = kwargs.pop("main_resource", None) or ( + getattr(parent, "main_resource", None) if parent else None + ) + + # append the format path + main_resource = "{}/format".format(main_resource) + + super().__init__( + protocol=parent.protocol if parent else kwargs.get("protocol"), + main_resource=main_resource, + ) + + self._track_changes = TrackerSet(casing=self._cc) + self._track_background_color = False + + cloud_data = kwargs.get(self._cloud_data_key, {}) + + self._column_width = cloud_data.get("columnWidth", 11) + self._horizontal_alignment = cloud_data.get("horizontalAlignment", "General") + self._row_height = cloud_data.get("rowHeight", 15) + self._vertical_alignment = cloud_data.get("verticalAlignment", "Bottom") + self._wrap_text = cloud_data.get("wrapText", None) + + self._font = RangeFormatFont(self) + self._background_color = UnsetSentinel + + def __str__(self): + return self.__repr__() + + def __repr__(self): + return "Format for range address: {}".format( + self.range.address if self.range else "Unkknown" + ) + + @property + def column_width(self): + """The width of all columns within the range + + :getter: get the column_width + :setter: set the column_width + :type: float + """ + return self._column_width + + @column_width.setter + def column_width(self, value): + self._column_width = value + self._track_changes.add("column_width") + + @property + def horizontal_alignment(self): + """The horizontal alignment for the specified object. + Possible values are: General, Left, Center, Right, Fill, Justify, + CenterAcrossSelection, Distributed. + + :getter: get the vertical_alignment + :setter: set the vertical_alignment + :type: string + """ + return self._horizontal_alignment + + @horizontal_alignment.setter + def horizontal_alignment(self, value): + self._horizontal_alignment = value + self._track_changes.add("horizontal_alignment") + + @property + def row_height(self): + """The height of all rows in the range. + + :getter: get the row_height + :setter: set the row_height + :type: float + """ + return self._row_height + + @row_height.setter + def row_height(self, value): + self._row_height = value + self._track_changes.add("row_height") + + @property + def vertical_alignment(self): + """The vertical alignment for the specified object. + Possible values are: Top, Center, Bottom, Justify, Distributed. + + :getter: get the vertical_alignment + :setter: set the vertical_alignment + :type: string + """ + return self._vertical_alignment + + @vertical_alignment.setter + def vertical_alignment(self, value): + self._vertical_alignment = value + self._track_changes.add("vertical_alignment") + + @property + def wrap_text(self): + """Indicates whether Excel wraps the text in the object + + :getter: get the wrap_text + :setter: set the wrap_text + :type: bool + """ + return self._wrap_text + + @wrap_text.setter + def wrap_text(self, value): + self._wrap_text = value + self._track_changes.add("wrap_text") + + def to_api_data(self, restrict_keys=None): + """Returns a dict to communicate with the server + + :param restrict_keys: a set of keys to restrict the returned data to + :rtype: dict + """ + cc = self._cc # alias + data = { + cc("column_width"): self._column_width, + cc("horizontal_alignment"): self._horizontal_alignment, + cc("row_height"): self._row_height, + cc("vertical_alignment"): self._vertical_alignment, + cc("wrap_text"): self._wrap_text, + } + + if restrict_keys: + for key in list(data.keys()): + if key not in restrict_keys: + del data[key] + return data + + def update(self): + """Updates this range format""" + if self._track_changes: + data = self.to_api_data(restrict_keys=self._track_changes) + if data: + response = self.session.patch(self.build_url(""), data=data) + if not response: + return False + self._track_changes.clear() + if self._font._track_changes: + data = self._font.to_api_data(restrict_keys=self._font._track_changes) + if data: + response = self.session.patch( + self.build_url(self._endpoints.get("font")), data=data + ) + if not response: + return False + self._font._track_changes.clear() + if self._track_background_color: + if self._background_color is None: + url = self.build_url(self._endpoints.get("clear_fill")) + response = self.session.post(url) + else: + data = {"color": self._background_color} + url = self.build_url(self._endpoints.get("fill")) + response = self.session.patch(url, data=data) + if not response: + return False + self._track_background_color = False + + return True + + @property + def font(self): + """Returns the font object defined on the overall range selected + + :getter: get the font + :setter: set the font + :type: RangeFormatFont + """ + return self._font + + @property + def background_color(self): + """The background color of the range + + :getter: get the background_color + :setter: set the background_color + :type: UnsentSentinel + """ + if self._background_color is UnsetSentinel: + self._load_background_color() + return self._background_color + + @background_color.setter + def background_color(self, value): + self._background_color = value + self._track_background_color = True + + def _load_background_color(self): + """Loads the data related to the fill color""" + url = self.build_url(self._endpoints.get("fill")) + response = self.session.get(url) + if not response: + return None + data = response.json() + self._background_color = data.get("color", None) + + def auto_fit_columns(self): + """Changes the width of the columns of the current range + to achieve the best fit, based on the current data in the columns + """ + url = self.build_url(self._endpoints.get("auto_fit_columns")) + return bool(self.session.post(url)) + + def auto_fit_rows(self): + """Changes the width of the rows of the current range + to achieve the best fit, based on the current data in the rows + """ + url = self.build_url(self._endpoints.get("auto_fit_rows")) + return bool(self.session.post(url)) + + def set_borders(self, side_style=""): + """Sets the border of this range""" + pass + + +class Range(ApiComponent): + """An Excel Range""" + + _endpoints = { + "get_cell": "/cell(row={},column={})", + "get_column": "/column(column={})", + "get_bounding_rect": "/boundingRect", + "columns_after": "/columnsAfter(count={})", + "columns_before": "/columnsBefore(count={})", + "entire_column": "/entireColumn", + "intersection": "/intersection", + "last_cell": "/lastCell", + "last_column": "/lastColumn", + "last_row": "/lastRow", + "offset_range": "/offsetRange", + "get_row": "/row", + "rows_above": "/rowsAbove(count={})", + "rows_below": "/rowsBelow(count={})", + "get_used_range": "/usedRange(valuesOnly={})", + "clear_range": "/clear", + "delete_range": "/delete", + "insert_range": "/insert", + "merge_range": "/merge", + "unmerge_range": "/unmerge", + "get_resized_range": "/resizedRange(deltaRows={}, deltaColumns={})", + "get_format": "/format", + } + range_format_constructor = RangeFormat #: :meta private: + + def __init__(self, parent=None, session=None, **kwargs): + if parent and session: + raise ValueError("Need a parent or a session but not both") + + self.session = parent.session if parent else session + + cloud_data = kwargs.get(self._cloud_data_key, {}) + + #: The id of the range. |br| **Type:** str + self.object_id = cloud_data.get("address", None) + + # Choose the main_resource passed in kwargs over parent main_resource + main_resource = kwargs.pop("main_resource", None) or ( + getattr(parent, "main_resource", None) if parent else None + ) + + # append the encoded range path + if isinstance(parent, Range): + # strip the main resource + main_resource = main_resource.split("/range")[0] + if isinstance(parent, (WorkSheet, Range)): + if "!" in self.object_id: + # remove the sheet string from the address as it's not needed + self.object_id = self.object_id.split("!")[1] + main_resource = "{}/range(address='{}')".format( + main_resource, quote(self.object_id) + ) + else: + main_resource = "{}/range".format(main_resource) + + super().__init__( + protocol=parent.protocol if parent else kwargs.get("protocol"), + main_resource=main_resource, + ) + + self._track_changes = TrackerSet(casing=self._cc) + + #: Represents the range reference in A1-style. + #: Address value contains the Sheet reference + #: (for example, Sheet1!A1:B4). |br| **Type:** str + self.address = cloud_data.get("address", "") + #: Represents range reference for the specified range in the language of the user. + #: |br| **Type:** str + self.address_local = cloud_data.get("addressLocal", "") + #: Represents the total number of columns in the range. |br| **Type:** int + self.column_count = cloud_data.get("columnCount", 0) + #: Returns the total number of rows in the range. |br| **Type:** int + self.row_count = cloud_data.get("rowCount", 0) + #: Number of cells in the range. |br| **Type:** int + self.cell_count = cloud_data.get("cellCount", 0) + self._column_hidden = cloud_data.get("columnHidden", False) + #: Represents the column number of the first cell in the range. Zero-indexed. + #: |br| **Type:** int + self.column_index = cloud_data.get("columnIndex", 0) # zero indexed + self._row_hidden = cloud_data.get("rowHidden", False) + #: Returns the row number of the first cell in the range. Zero-indexed. + #: |br| **Type:** int + self.row_index = cloud_data.get("rowIndex", 0) # zero indexed + self._formulas = cloud_data.get("formulas", [[]]) + self._formulas_local = cloud_data.get("formulasLocal", [[]]) + self._formulas_r1_c1 = cloud_data.get("formulasR1C1", [[]]) + #: Represents if all cells of the current range are hidden. |br| **Type:** bool + self.hidden = cloud_data.get("hidden", False) + self._number_format = cloud_data.get("numberFormat", [[]]) + #: Text values of the specified range. |br| **Type:** str + self.text = cloud_data.get("text", [[]]) + #: Represents the type of data of each cell. + #: The possible values are: Unknown, Empty, String, + #: Integer, Double, Boolean, Error. |br| **Type:** list[list] + self.value_types = cloud_data.get("valueTypes", [[]]) + self._values = cloud_data.get("values", [[]]) + + def __str__(self): + return self.__repr__() + + def __repr__(self): + return "Range address: {}".format(self.address) + + def __eq__(self, other): + return self.object_id == other.object_id + + @property + def column_hidden(self): + """Indicates whether all columns of the current range are hidden. + + :getter: get the column_hidden + :setter: set the column_hidden + :type: bool + """ + return self._column_hidden + + @column_hidden.setter + def column_hidden(self, value): + self._column_hidden = value + self._track_changes.add("column_hidden") + + @property + def row_hidden(self): + """Indicates whether all rows of the current range are hidden. + + :getter: get the row_hidden + :setter: set the row_hidden + :type: bool + """ + return self._row_hidden + + @row_hidden.setter + def row_hidden(self, value): + self._row_hidden = value + self._track_changes.add("row_hidden") + + @property + def formulas(self): + """Represents the formula in A1-style notation. + + :getter: get the formulas + :setter: set the formulas + :type: any + """ + return self._formulas + + @formulas.setter + def formulas(self, value): + self._formulas = value + self._track_changes.add("formulas") + + @property + def formulas_local(self): + """Represents the formula in A1-style notation, in the user's language + and number-formatting locale. For example, the English "=SUM(A1, 1.5)" + formula would become "=SUMME(A1; 1,5)" in German. + + :getter: get the formulas_local + :setter: set the formulas_local + :type: list[list] + """ + return self._formulas_local + + @formulas_local.setter + def formulas_local(self, value): + self._formulas_local = value + self._track_changes.add("formulas_local") + + @property + def formulas_r1_c1(self): + """Represents the formula in R1C1-style notation. + + :getter: get the formulas_r1_c1 + :setter: set the formulas_r1_c1 + :type: list[list] + """ + return self._formulas_r1_c1 + + @formulas_r1_c1.setter + def formulas_r1_c1(self, value): + self._formulas_r1_c1 = value + self._track_changes.add("formulas_r1_c1") + + @property + def number_format(self): + """Represents Excel's number format code for the given cell. + + :getter: get the number_format + :setter: set the number_fromat + :type: list[list] + """ + return self._number_format + + @number_format.setter + def number_format(self, value): + self._number_format = value + self._track_changes.add("number_format") + + @property + def values(self): + """Represents the raw values of the specified range. + The data returned can be of type string, number, or a Boolean. + Cell that contains an error returns the error string. + + :getter: get the number_format + :setter: set the number_fromat + :type: list[list] + """ + return self._values + + @values.setter + def values(self, value): + if not isinstance(value, list): + value = [[value]] # values is always a 2 dimensional array + self._values = value + self._track_changes.add("values") + + def to_api_data(self, restrict_keys=None): + """Returns a dict to communicate with the server + + :param restrict_keys: a set of keys to restrict the returned data to + :rtype: dict + """ + cc = self._cc # alias + data = { + cc("column_hidden"): self._column_hidden, + cc("row_hidden"): self._row_hidden, + cc("formulas"): self._formulas, + cc("formulas_local"): self._formulas_local, + cc("formulas_r1_c1"): self._formulas_r1_c1, + cc("number_format"): self._number_format, + cc("values"): self._values, + } + + if restrict_keys: + for key in list(data.keys()): + if key not in restrict_keys: + del data[key] + return data + + def _get_range(self, endpoint, *args, method="GET", **kwargs): + """Helper that returns another range""" + if args: + url = self.build_url(self._endpoints.get(endpoint).format(*args)) + else: + url = self.build_url(self._endpoints.get(endpoint)) + if not kwargs: + kwargs = None + if method == "GET": + response = self.session.get(url, params=kwargs) + elif method == "POST": + response = self.session.post(url, data=kwargs) + if not response: + return None + return self.__class__(parent=self, **{self._cloud_data_key: response.json()}) + + def get_cell(self, row, column): + """ + Gets the range object containing the single cell based on row and column numbers. + :param int row: the row number + :param int column: the column number + :return: a Range instance + """ + return self._get_range("get_cell", row, column) + + def get_column(self, index): + """ + Returns a column whitin the range + :param int index: the index of the column. zero indexed + :return: a Range + """ + return self._get_range("get_column", index) + + def get_bounding_rect(self, address): + """ + Gets the smallest range object that encompasses the given ranges. + For example, the GetBoundingRect of "B2:C5" and "D10:E15" is "B2:E16". + :param str address: another address to retrieve it's bounding rect + """ + return self._get_range("get_bounding_rect", anotherRange=address) + + def get_columns_after(self, columns=1): + """ + Gets a certain number of columns to the right of the given range. + :param int columns: Optional. The number of columns to include in the resulting range. + """ + return self._get_range("columns_after", columns, method="POST") + + def get_columns_before(self, columns=1): + """ + Gets a certain number of columns to the left of the given range. + :param int columns: Optional. The number of columns to include in the resulting range. + """ + return self._get_range("columns_before", columns, method="POST") + + def get_entire_column(self): + """Gets a Range that represents the entire column of the range.""" + return self._get_range("entire_column") + + def get_intersection(self, address): + """ + Gets the Range that represents the rectangular intersection of the given ranges. + + :param address: the address range you want ot intersect with. + :return: Range + """ + self._get_range("intersection", anotherRange=address) + + def get_last_cell(self): + """Gets the last cell within the range.""" + return self._get_range("last_cell") + + def get_last_column(self): + """Gets the last column within the range.""" + return self._get_range("last_column") + + def get_last_row(self): + """Gets the last row within the range.""" + return self._get_range("last_row") + + def get_offset_range(self, row_offset, column_offset): + """Gets an object which represents a range that's offset from the specified range. + The dimension of the returned range will match this range. + If the resulting range is forced outside the bounds of the worksheet grid, + an exception will be thrown. + + :param int row_offset: The number of rows (positive, negative, or 0) + by which the range is to be offset. + :param int column_offset: he number of columns (positive, negative, or 0) + by which the range is to be offset. + :return: Range + """ + + return self._get_range( + "offset_range", rowOffset=row_offset, columnOffset=column_offset + ) + + def get_row(self, index): + """ + Gets a row contained in the range. + :param int index: Row number of the range to be retrieved. + :return: Range + """ + return self._get_range("get_row", method="POST", row=index) + + def get_rows_above(self, rows=1): + """ + Gets a certain number of rows above a given range. + + :param int rows: Optional. The number of rows to include in the resulting range. + :return: Range + """ + return self._get_range("rows_above", rows, method="POST") + + def get_rows_below(self, rows=1): + """ + Gets a certain number of rows below a given range. + + :param int rows: Optional. The number of rows to include in the resulting range. + :return: Range + """ + return self._get_range("rows_below", rows, method="POST") + + def get_used_range(self, only_values=True): + """ + Returns the used range of the given range object. + + :param bool only_values: Optional. Defaults to True. + Considers only cells with values as used cells (ignores formatting). + :return: Range + """ + # Format the "only_values" parameter as a lowercase string to work correctly with the Graph API + return self._get_range("get_used_range", str(only_values).lower()) + + def clear(self, apply_to="all"): + """ + Clear range values, format, fill, border, etc. + + :param str apply_to: Optional. Determines the type of clear action. + The possible values are: all, formats, contents. + """ + url = self.build_url(self._endpoints.get("clear_range")) + return bool(self.session.post(url, data={"applyTo": apply_to.capitalize()})) + + def delete(self, shift="up"): + """ + Deletes the cells associated with the range. + + :param str shift: Optional. Specifies which way to shift the cells. + The possible values are: up, left. + """ + url = self.build_url(self._endpoints.get("delete_range")) + return bool(self.session.post(url, data={"shift": shift.capitalize()})) + + def insert_range(self, shift): + """ + Inserts a cell or a range of cells into the worksheet in place of this range, + and shifts the other cells to make space. + + :param str shift: Specifies which way to shift the cells. The possible values are: down, right. + :return: new Range instance at the now blank space + """ + return self._get_range("insert_range", method="POST", shift=shift.capitalize()) + + def merge(self, across=False): + """ + Merge the range cells into one region in the worksheet. + + :param bool across: Optional. Set True to merge cells in each row of the + specified range as separate merged cells. + """ + url = self.build_url(self._endpoints.get("merge_range")) + return bool(self.session.post(url, data={"across": across})) + + def unmerge(self): + """Unmerge the range cells into separate cells.""" + url = self.build_url(self._endpoints.get("unmerge_range")) + return bool(self.session.post(url)) + + def get_resized_range(self, rows, columns): + """ + Gets a range object similar to the current range object, + but with its bottom-right corner expanded (or contracted) + by some number of rows and columns. + + :param int rows: The number of rows by which to expand the + bottom-right corner, relative to the current range. + :param int columns: The number of columns by which to expand the + bottom-right corner, relative to the current range. + :return: Range + """ + return self._get_range("get_resized_range", rows, columns, method="GET") + + def update(self): + """Update this range""" + + if not self._track_changes: + return True # there's nothing to update + + data = self.to_api_data(restrict_keys=self._track_changes) + response = self.session.patch(self.build_url(""), data=data) + if not response: + return False + + data = response.json() + + for field in self._track_changes: + setattr(self, to_snake_case(field), data.get(field)) + self._track_changes.clear() + + return True + + def get_worksheet(self): + """Returns this range worksheet""" + url = self.build_url("") + q = self.q().select("address").expand("worksheet") + response = self.session.get(url, params=q.as_params()) + if not response: + return None + data = response.json() + + ws = data.get("worksheet") + if ws is None: + return None + return WorkSheet(session=self.session, **{self._cloud_data_key: ws}) + + def get_format(self): + """Returns a RangeFormat instance with the format of this range""" + url = self.build_url(self._endpoints.get("get_format")) + response = self.session.get(url) + if not response: + return None + return self.range_format_constructor( + parent=self, **{self._cloud_data_key: response.json()} + ) + + +class NamedRange(ApiComponent): + """Represents a defined name for a range of cells or value""" + + _endpoints = { + "get_range": "/range", + } + + range_constructor = Range #: :meta private: + + def __init__(self, parent=None, session=None, **kwargs): + if parent and session: + raise ValueError("Need a parent or a session but not both") + + self.session = parent.session if parent else session + + cloud_data = kwargs.get(self._cloud_data_key, {}) + + #: Id of the named range |br| **Type:** str + self.object_id = cloud_data.get("name", None) + + # Choose the main_resource passed in kwargs over parent main_resource + main_resource = kwargs.pop("main_resource", None) or ( + getattr(parent, "main_resource", None) if parent else None + ) + + main_resource = "{}/names/{}".format(main_resource, self.object_id) + + super().__init__( + protocol=parent.protocol if parent else kwargs.get("protocol"), + main_resource=main_resource, + ) + + #: The name of the object. |br| **Type:** str + self.name = cloud_data.get("name", None) + #: The comment associated with this name. |br| **Type:** str + self.comment = cloud_data.get("comment", "") + #: Indicates whether the name is scoped to the workbook or to a specific worksheet. + #: |br| **Type:** str + self.scope = cloud_data.get("scope", "") + #: The type of reference is associated with the name. + #: Possible values are: String, Integer, Double, Boolean, Range. |br| **Type:** str + self.data_type = cloud_data.get("type", "") + #: The formula that the name is defined to refer to. + #: For example, =Sheet14!$B$2:$H$12 and =4.75. |br| **Type:** str + self.value = cloud_data.get("value", "") + #: Indicates whether the object is visible. |br| **Type:** bool + self.visible = cloud_data.get("visible", True) + + def __str__(self): + return self.__repr__() + + def __repr__(self): + return "Named Range: {} ({})".format(self.name, self.value) + + def __eq__(self, other): + return self.object_id == other.object_id + + def get_range(self): + """Returns the Range instance this named range refers to""" + url = self.build_url(self._endpoints.get("get_range")) + response = self.session.get(url) + if not response: + return None + return self.range_constructor( + parent=self, **{self._cloud_data_key: response.json()} + ) + + def update(self, *, visible=None, comment=None): + """ + Updates this named range + :param bool visible: Specifies whether the object is visible or not + :param str comment: Represents the comment associated with this name + :return: Success or Failure + """ + if visible is None and comment is None: + raise ValueError('Provide "visible" or "comment" to update.') + data = {} + if visible is not None: + data["visible"] = visible + if comment is not None: + data["comment"] = comment + data = None if not data else data + response = self.session.patch(self.build_url(""), data=data) + if not response: + return False + data = response.json() + + self.visible = data.get("visible", self.visible) + self.comment = data.get("comment", self.comment) + return True + + +class TableRow(ApiComponent): + """An Excel Table Row""" + + _endpoints = { + "get_range": "/range", + "delete": "/delete", + } + range_constructor = Range #: :meta private: + + def __init__(self, parent=None, session=None, **kwargs): + if parent and session: + raise ValueError("Need a parent or a session but not both") + + #: Parent of the table row. |br| **Type:** parent + self.table = parent + #: Session of table row |br| **Type:** session + self.session = parent.session if parent else session + + cloud_data = kwargs.get(self._cloud_data_key, {}) + + #: Id of the Table Row |br| **Type:** str + self.object_id = cloud_data.get("index", None) + + # Choose the main_resource passed in kwargs over parent main_resource + main_resource = kwargs.pop("main_resource", None) or ( + getattr(parent, "main_resource", None) if parent else None + ) + + # append the encoded column path + main_resource = "{}/rows/itemAt(index={})".format(main_resource, self.object_id) + + super().__init__( + protocol=parent.protocol if parent else kwargs.get("protocol"), + main_resource=main_resource, + ) + + #: The index of the row within the rows collection of the table. Zero-based. + #: |br| **Type:** int + self.index = cloud_data.get("index", 0) # zero indexed + #: The raw values of the specified range. + #: The data returned could be of type string, number, or a Boolean. + #: Any cell that contain an error will return the error string. + #: |br| **Type:** list[list] + self.values = cloud_data.get("values", [[]]) # json string + + def __str__(self): + return self.__repr__() + + def __repr__(self): + return "Row number: {}".format(self.index) + + def __eq__(self, other): + return self.object_id == other.object_id + + def get_range(self): + """Gets the range object associated with the entire row""" + url = self.build_url(self._endpoints.get("get_range")) + response = self.session.get(url) + if not response: + return None + return self.range_constructor( + parent=self, **{self._cloud_data_key: response.json()} + ) + + def update(self, values): + """Updates this row""" + response = self.session.patch(self.build_url(""), data={"values": values}) + if not response: + return False + data = response.json() + self.values = data.get("values", self.values) + return True + + def delete(self): + """Deletes this row""" + url = self.build_url(self._endpoints.get("delete")) + return bool(self.session.post(url)) + + +class TableColumn(ApiComponent): + """An Excel Table Column""" + + _endpoints = { + "delete": "/delete", + "data_body_range": "/dataBodyRange", + "header_row_range": "/headerRowRange", + "total_row_range": "/totalRowRange", + "entire_range": "/range", + "clear_filter": "/filter/clear", + "apply_filter": "/filter/apply", + } + range_constructor = Range #: :meta private: + + def __init__(self, parent=None, session=None, **kwargs): + if parent and session: + raise ValueError("Need a parent or a session but not both") + + #: Parent of the table column. |br| **Type:** parent + self.table = parent + #: session of the table column.. |br| **Type:** session + self.session = parent.session if parent else session + + cloud_data = kwargs.get(self._cloud_data_key, {}) + + #: Id of the Table Column|br| **Type:** str + self.object_id = cloud_data.get("id", None) + + # Choose the main_resource passed in kwargs over parent main_resource + main_resource = kwargs.pop("main_resource", None) or ( + getattr(parent, "main_resource", None) if parent else None + ) + + # append the encoded column path + main_resource = "{}/columns('{}')".format(main_resource, quote(self.object_id)) + + super().__init__( + protocol=parent.protocol if parent else kwargs.get("protocol"), + main_resource=main_resource, + ) + + #: The name of the table column. |br| **Type:** str + self.name = cloud_data.get("name", "") + #: TThe index of the column within the columns collection of the table. Zero-indexed. + #: |br| **Type:** int + self.index = cloud_data.get("index", 0) # zero indexed + #: Represents the raw values of the specified range. + #: The data returned could be of type string, number, or a Boolean. + #: Cell that contain an error will return the error string. |br| **Type:** list[list] + self.values = cloud_data.get("values", [[]]) # json string + + def __str__(self): + return self.__repr__() + + def __repr__(self): + return "Table Column: {}".format(self.name) + + def __eq__(self, other): + return self.object_id == other.object_id + + def delete(self): + """Deletes this table Column""" + url = self.build_url(self._endpoints.get("delete")) + return bool(self.session.post(url)) + + def update(self, values): + """ + Updates this column + :param values: values to update + """ + response = self.session.patch(self.build_url(""), data={"values": values}) + if not response: + return False + data = response.json() + + self.values = data.get("values", "") + return True + + def _get_range(self, endpoint_name): + """Returns a Range based on the endpoint name""" + + url = self.build_url(self._endpoints.get(endpoint_name)) + response = self.session.get(url) + if not response: + return None + return self.range_constructor( + parent=self, **{self._cloud_data_key: response.json()} + ) + + def get_data_body_range(self): + """Gets the range object associated with the data body of the column""" + return self._get_range("data_body_range") + + def get_header_row_range(self): + """Gets the range object associated with the header row of the column""" + return self._get_range("header_row_range") + + def get_total_row_range(self): + """Gets the range object associated with the totals row of the column""" + return self._get_range("total_row_range") + + def get_range(self): + """Gets the range object associated with the entire column""" + return self._get_range("entire_range") + + def clear_filter(self): + """Clears the filter applied to this column""" + url = self.build_url(self._endpoints.get("clear_filter")) + return bool(self.session.post(url)) + + def apply_filter(self, criteria): + """ + Apply the given filter criteria on the given column. + + :param str criteria: the criteria to apply + + Example: + + .. code-block:: json + + { + "color": "string", + "criterion1": "string", + "criterion2": "string", + "dynamicCriteria": "string", + "filterOn": "string", + "icon": {"@odata.type": "microsoft.graph.workbookIcon"}, + "values": {"@odata.type": "microsoft.graph.Json"} + } + + """ + url = self.build_url(self._endpoints.get("apply_filter")) + return bool(self.session.post(url, data={"criteria": criteria})) + + def get_filter(self): + """Returns the filter applie to this column""" + q = self.q().select("name").expand("filter") + response = self.session.get(self.build_url(""), params=q.as_params()) + if not response: + return None + data = response.json() + return data.get("criteria", None) + + +class Table(ApiComponent): + """An Excel Table""" + + _endpoints = { + "get_columns": "/columns", + "get_column": "/columns/{id}", + "delete_column": "/columns/{id}/delete", + "get_column_index": "/columns/itemAt", + "add_column": "/columns/add", + "get_rows": "/rows", + "get_row": "/rows/{id}", + "delete_row": "/rows/$/itemAt(index={id})", + "get_row_index": "/rows/itemAt", + "add_rows": "/rows/add", + "delete": "/", + "data_body_range": "/dataBodyRange", + "header_row_range": "/headerRowRange", + "total_row_range": "/totalRowRange", + "entire_range": "/range", + "convert_to_range": "/convertToRange", + "clear_filters": "/clearFilters", + "reapply_filters": "/reapplyFilters", + } + column_constructor = TableColumn #: :meta private: + row_constructor = TableRow #: :meta private: + range_constructor = Range #: :meta private: + + def __init__(self, parent=None, session=None, **kwargs): + if parent and session: + raise ValueError("Need a parent or a session but not both") + + #: Parent of the table. |br| **Type:** parent + self.parent = parent + #: Session of the table. |br| **Type:** session + self.session = parent.session if parent else session + + cloud_data = kwargs.get(self._cloud_data_key, {}) + + #: The unique identifier for the table in the workbook. |br| **Type:** str + self.object_id = cloud_data.get("id", None) + + # Choose the main_resource passed in kwargs over parent main_resource + main_resource = kwargs.pop("main_resource", None) or ( + getattr(parent, "main_resource", None) if parent else None + ) + + # append the encoded table path + main_resource = "{}/tables('{}')".format(main_resource, quote(self.object_id)) + + super().__init__( + protocol=parent.protocol if parent else kwargs.get("protocol"), + main_resource=main_resource, + ) + + #: The name of the table. |br| **Type:** str + self.name = cloud_data.get("name", None) + #: Indicates whether the header row is visible or not |br| **Type:** bool + self.show_headers = cloud_data.get("showHeaders", True) + #: Indicates whether the total row is visible or not. |br| **Type:** bool + self.show_totals = cloud_data.get("showTotals", True) + #: A constant value that represents the Table style |br| **Type:** str + self.style = cloud_data.get("style", None) + #: Indicates whether the first column contains special formatting. |br| **Type:** bool + self.highlight_first_column = cloud_data.get("highlightFirstColumn", False) + #: Indicates whether the last column contains special formatting. |br| **Type:** bool + self.highlight_last_column = cloud_data.get("highlightLastColumn", False) + #: Indicates whether the columns show banded formatting in which odd columns + #: are highlighted differently from even ones to make reading the table easier. + #: |br| **Type:** bool + self.show_banded_columns = cloud_data.get("showBandedColumns", False) + #: The name of the table column. |br| **Type:** str + self.show_banded_rows = cloud_data.get("showBandedRows", False) + #: Indicates whether the rows show banded formatting in which odd rows + #: are highlighted differently from even ones to make reading the table easier. + #: |br| **Type:** bool + self.show_filter_button = cloud_data.get("showFilterButton", False) + #: A legacy identifier used in older Excel clients. |br| **Type:** str + self.legacy_id = cloud_data.get("legacyId", False) + + def __str__(self): + return self.__repr__() + + def __repr__(self): + return "Table: {}".format(self.name) + + def __eq__(self, other): + return self.object_id == other.object_id + + def get_columns(self, *, top=None, skip=None): + """ + Return the columns of this table + :param int top: specify n columns to retrieve + :param int skip: specify n columns to skip + """ + url = self.build_url(self._endpoints.get("get_columns")) + + params = {} + if top is not None: + params["$top"] = top + if skip is not None: + params["$skip"] = skip + params = None if not params else params + response = self.session.get(url, params=params) + + if not response: + return iter(()) + + data = response.json() + + return ( + self.column_constructor(parent=self, **{self._cloud_data_key: column}) + for column in data.get("value", []) + ) + + def get_column(self, id_or_name): + """ + Gets a column from this table by id or name + :param id_or_name: the id or name of the column + :return: WorkBookTableColumn + """ + url = self.build_url( + self._endpoints.get("get_column").format(id=quote(id_or_name)) + ) + response = self.session.get(url) + + if not response: + return None + + data = response.json() + + return self.column_constructor(parent=self, **{self._cloud_data_key: data}) + + def get_column_at_index(self, index): + """ + Returns a table column by it's index + :param int index: the zero-indexed position of the column in the table + """ + if index is None: + return None + + url = self.build_url(self._endpoints.get("get_column_index")) + response = self.session.post(url, data={"index": index}) + + if not response: + return None + + return self.column_constructor( + parent=self, **{self._cloud_data_key: response.json()} + ) + + def delete_column(self, id_or_name): + """ + Deletes a Column by its id or name + :param id_or_name: the id or name of the column + :return bool: Success or Failure + """ + url = self.build_url( + self._endpoints.get("delete_column").format(id=quote(id_or_name)) + ) + return bool(self.session.post(url)) + + def add_column(self, name, *, index=0, values=None): + """ + Adds a column to the table + :param str name: the name of the column + :param int index: the index at which the column should be added. Defaults to 0. + :param list values: a two dimension array of values to add to the column + """ + if name is None: + return None + + params = {"name": name, "index": index} + if values is not None: + params["values"] = values + + url = self.build_url(self._endpoints.get("add_column")) + response = self.session.post(url, data=params) + if not response: + return None + + data = response.json() + + return self.column_constructor(parent=self, **{self._cloud_data_key: data}) + + def get_rows(self, *, top=None, skip=None): + """ + Return the rows of this table + :param int top: specify n rows to retrieve + :param int skip: specify n rows to skip + :rtype: TableRow + """ + url = self.build_url(self._endpoints.get("get_rows")) + + params = {} + if top is not None: + params["$top"] = top + if skip is not None: + params["$skip"] = skip + params = None if not params else params + response = self.session.get(url, params=params) + + if not response: + return iter(()) + + data = response.json() + + return ( + self.row_constructor(parent=self, **{self._cloud_data_key: row}) + for row in data.get("value", []) + ) + + def get_row(self, index): + """Returns a Row instance at an index""" + url = self.build_url(self._endpoints.get("get_row").format(id=index)) + response = self.session.get(url) + if not response: + return None + return self.row_constructor( + parent=self, **{self._cloud_data_key: response.json()} + ) + + def get_row_at_index(self, index): + """ + Returns a table row by it's index + :param int index: the zero-indexed position of the row in the table + """ + if index is None: + return None + + url = self.build_url(self._endpoints.get("get_row_index")) + url = "{}(index={})".format(url, index) + response = self.session.get(url) + + if not response: + return None + + return self.row_constructor( + parent=self, **{self._cloud_data_key: response.json()} + ) + + def delete_row(self, index): + """ + Deletes a Row by it's index + :param int index: the index of the row. zero indexed + :return bool: Success or Failure + """ + url = self.build_url(self._endpoints.get("delete_row").format(id=index)) + return bool(self.session.delete(url)) + + def add_rows(self, values=None, index=None): + """ + Add rows to this table. + + Multiple rows can be added at once. + This request might occasionally receive a 504 HTTP error. + The appropriate response to this error is to repeat the request. + + :param list values: Optional. a 1 or 2 dimensional array of values to add + :param int index: Optional. Specifies the relative position of the new row. + If null, the addition happens at the end. + :return: + """ + params = {} + if values is not None: + if values and not isinstance(values[0], list): + # this is a single row + values = [values] + params["values"] = values + if index is not None: + params["index"] = index + + params = params if params else None + + url = self.build_url(self._endpoints.get("add_rows")) + response = self.session.post(url, data=params) + if not response: + return None + return self.row_constructor( + parent=self, **{self._cloud_data_key: response.json()} + ) + + def update(self, *, name=None, show_headers=None, show_totals=None, style=None): + """ + Updates this table + :param str name: the name of the table + :param bool show_headers: whether or not to show the headers + :param bool show_totals: whether or not to show the totals + :param str style: the style of the table + :return: Success or Failure + """ + if ( + name is None + and show_headers is None + and show_totals is None + and style is None + ): + raise ValueError("Provide at least one parameter to update") + data = {} + if name: + data["name"] = name + if show_headers is not None: + data["showHeaders"] = show_headers + if show_totals is not None: + data["showTotals"] = show_totals + if style: + data["style"] = style + + response = self.session.patch(self.build_url(""), data=data) + if not response: + return False + + data = response.json() + self.name = data.get("name", self.name) + self.show_headers = data.get("showHeaders", self.show_headers) + self.show_totals = data.get("showTotals", self.show_totals) + self.style = data.get("style", self.style) + + return True + + def delete(self): + """Deletes this table""" + url = self.build_url(self._endpoints.get("delete")) + return bool(self.session.delete(url)) + + def _get_range(self, endpoint_name): + """Returns a Range based on the endpoint name""" + + url = self.build_url(self._endpoints.get(endpoint_name)) + response = self.session.get(url) + if not response: + return None + data = response.json() + return self.range_constructor(parent=self, **{self._cloud_data_key: data}) + + def get_data_body_range(self): + """Gets the range object associated with the data body of the table""" + return self._get_range("data_body_range") + + def get_header_row_range(self): + """Gets the range object associated with the header row of the table""" + return self._get_range("header_row_range") + + def get_total_row_range(self): + """Gets the range object associated with the totals row of the table""" + return self._get_range("total_row_range") + + def get_range(self): + """Gets the range object associated with the entire table""" + return self._get_range("entire_range") + + def convert_to_range(self): + """Converts the table into a normal range of cells. All data is preserved.""" + return self._get_range("convert_to_range") + + def clear_filters(self): + """Clears all the filters currently applied on the table.""" + url = self.build_url(self._endpoints.get("clear_filters")) + return bool(self.session.post(url)) + + def reapply_filters(self): + """Reapplies all the filters currently on the table.""" + url = self.build_url(self._endpoints.get("reapply_filters")) + return bool(self.session.post(url)) + + def get_worksheet(self): + """Returns this table worksheet""" + url = self.build_url("") + q = self.q().select("name").expand("worksheet") + response = self.session.get(url, params=q.as_params()) + if not response: + return None + data = response.json() + + ws = data.get("worksheet") + if ws is None: + return None + return WorkSheet(parent=self.parent, **{self._cloud_data_key: ws}) + + +class WorkSheet(ApiComponent): + """An Excel WorkSheet""" + + _endpoints = { + "get_tables": "/tables", + "get_table": "/tables/{id}", + "get_range": "/range", + "add_table": "/tables/add", + "get_used_range": "/usedRange(valuesOnly={})", + "get_cell": "/cell(row={row},column={column})", + "add_named_range": "/names/add", + "add_named_range_f": "/names/addFormulaLocal", + "get_named_range": "/names/{name}", + } + + table_constructor = Table #: :meta private: + range_constructor = Range #: :meta private: + named_range_constructor = NamedRange #: :meta private: + + def __init__(self, parent=None, session=None, **kwargs): + if parent and session: + raise ValueError("Need a parent or a session but not both") + + #: The parent of the worksheet. |br| **Type:** parent + self.workbook = parent + #: Thesession of the worksheet. |br| **Type:** session + self.session = parent.session if parent else session + + cloud_data = kwargs.get(self._cloud_data_key, {}) + + #: The unique identifier for the worksheet in the workbook. |br| **Type:** str + self.object_id = cloud_data.get("id", None) + + # Choose the main_resource passed in kwargs over parent main_resource + main_resource = kwargs.pop("main_resource", None) or ( + getattr(parent, "main_resource", None) if parent else None + ) + + # append the encoded worksheet path + main_resource = "{}/worksheets('{}')".format( + main_resource, quote(self.object_id) + ) + + super().__init__( + protocol=parent.protocol if parent else kwargs.get("protocol"), + main_resource=main_resource, + ) + + #: The display name of the worksheet. |br| **Type:** str + self.name = cloud_data.get("name", None) + #: The zero-based position of the worksheet within the workbook. |br| **Type:** int + self.position = cloud_data.get("position", None) + #: The visibility of the worksheet. + #: The possible values are: Visible, Hidden, VeryHidden. |br| **Type:** str + self.visibility = cloud_data.get("visibility", None) + + def __str__(self): + return self.__repr__() + + def __repr__(self): + return "Worksheet: {}".format(self.name) + + def __eq__(self, other): + return self.object_id == other.object_id + + def delete(self): + """Deletes this worksheet""" + return bool(self.session.delete(self.build_url(""))) + + def update(self, *, name=None, position=None, visibility=None): + """Changes the name, position or visibility of this worksheet""" + + if name is None and position is None and visibility is None: + raise ValueError("Provide at least one parameter to update") + data = {} + if name: + data["name"] = name + if position: + data["position"] = position + if visibility: + data["visibility"] = visibility + + response = self.session.patch(self.build_url(""), data=data) + if not response: + return False + + data = response.json() + self.name = data.get("name", self.name) + self.position = data.get("position", self.position) + self.visibility = data.get("visibility", self.visibility) + + return True + + def get_tables(self): + """Returns a collection of this worksheet tables""" + + url = self.build_url(self._endpoints.get("get_tables")) + response = self.session.get(url) + + if not response: + return [] + + data = response.json() + + return [ + self.table_constructor(parent=self, **{self._cloud_data_key: table}) + for table in data.get("value", []) + ] + + def get_table(self, id_or_name): + """ + Retrieves a Table by id or name + :param str id_or_name: The id or name of the column + :return: a Table instance + """ + url = self.build_url(self._endpoints.get("get_table").format(id=id_or_name)) + response = self.session.get(url) + if not response: + return None + return self.table_constructor( + parent=self, **{self._cloud_data_key: response.json()} + ) + + def add_table(self, address, has_headers): + """ + Adds a table to this worksheet + :param str address: a range address eg: 'A1:D4' + :param bool has_headers: if the range address includes headers or not + :return: a Table instance + """ + if address is None: + return None + params = {"address": address, "hasHeaders": has_headers} + url = self.build_url(self._endpoints.get("add_table")) + response = self.session.post(url, data=params) + if not response: + return None + return self.table_constructor( + parent=self, **{self._cloud_data_key: response.json()} + ) + + def get_range(self, address=None): + """ + Returns a Range instance from whitin this worksheet + :param str address: Optional, the range address you want + :return: a Range instance + """ + url = self.build_url(self._endpoints.get("get_range")) + if address is not None: + address = self.remove_sheet_name_from_address(address) + url = "{}(address='{}')".format(url, address) + response = self.session.get(url) + if not response: + return None + return self.range_constructor( + parent=self, **{self._cloud_data_key: response.json()} + ) + + def get_used_range(self, only_values=True): + """Returns the smallest range that encompasses any cells that + have a value or formatting assigned to them. + + :param bool only_values: Optional. Defaults to True. + Considers only cells with values as used cells (ignores formatting). + :return: Range + """ + # Format the "only_values" parameter as a lowercase string to work properly with the Graph API + url = self.build_url( + self._endpoints.get("get_used_range").format(str(only_values).lower()) + ) + response = self.session.get(url) + if not response: + return None + return self.range_constructor( + parent=self, **{self._cloud_data_key: response.json()} + ) + + def get_cell(self, row, column): + """Gets the range object containing the single cell based on row and column numbers.""" + url = self.build_url( + self._endpoints.get("get_cell").format(row=row, column=column) + ) + response = self.session.get(url) + if not response: + return None + return self.range_constructor( + parent=self, **{self._cloud_data_key: response.json()} + ) + + def add_named_range(self, name, reference, comment="", is_formula=False): + """ + Adds a new name to the collection of the given scope using the user's locale for the formula + :param str name: the name of this range + :param str reference: the reference for this range or formula + :param str comment: a comment to describe this named range + :param bool is_formula: True if the reference is a formula + :return: NamedRange instance + """ + if is_formula: + url = self.build_url(self._endpoints.get("add_named_range_f")) + else: + url = self.build_url(self._endpoints.get("add_named_range")) + params = {"name": name, "reference": reference, "comment": comment} + response = self.session.post(url, data=params) + if not response: + return None + return self.named_range_constructor( + parent=self, **{self._cloud_data_key: response.json()} + ) + + def update_cells(self, address, rows): + """ + Updates the cells at a given range in this worksheet. This is a convenience method since there is no + direct endpoint API for tableless row updates. + :param str|Range address: the address to resolve to a range which can be used for updating cells. + :param list[list[str]] rows: list of rows to push to this range. If updating a single cell, pass a list + containing a single row (list) containing a single cell worth of data. + """ + if isinstance(address, str): + address = self.get_range(address) + + if not isinstance(address, Range): + raise ValueError("address was not an accepted type: str or Range") + + if not isinstance(rows, list): + raise ValueError("rows was not an accepted type: list[list[str]]") + + # Let's not even try pushing to API if the range rectangle mismatches the input row and column count. + row_count = len(rows) + col_count = len(rows[0]) if row_count > 0 else 1 + + if address.row_count != row_count or address.column_count != col_count: + raise ValueError("rows and columns are not the same size as the range selected. This is required by the Microsoft Graph API.") + + address.values = rows + address.update() + + def append_rows(self, rows): + """ + Appends rows to the end of a worksheet. There is no direct Graph API to do this operation without a Table + instance. Instead, this method identifies the last row in the worksheet and requests a range after that row + and updates that range. + + Beware! If you open your workbook from sharepoint and delete all of the rows in one go and attempt to append + new rows, you will get undefined behavior from the Microsoft Graph API. I don't know if I did not give enough + time for the backend to synchronize from the moment of deletion on my browser and the moment I triggered my + script, but this is something I have observed. Sometimes insertion fails and sometimes it inserts where the new + row would have been if data had not been deleted from the browser side. Maybe it is an API cache issue. However, + after the first row is inserted successfully, this undefined behavior goes away on repeat calls to my scripts. + Documenting this behavior for future consumers of this API. + + :param list[list[str]] rows: list of rows to push to this range. If updating a single cell, pass a list + containing a single row (list) containing a single cell worth of data. + """ + row_count = len(rows) + col_count = len(rows[0]) if row_count > 0 else 0 + col_index = col_count - 1 + + # Find the last row index so we can grab a range after it. + current_range = self.get_used_range() + # Minor adjustment because Graph will return [['']] in an empty worksheet. + # Also, beware that Graph might report ghost values if testing using the front end site and that can be interesting + # during debugging. I ctrl + A and delete then click elsewhere before testing again. + # Might also take a moment for the backend to eventually catch up to the changes. + # Graph can be weirdly slow. It might be an institution thing. + if current_range.row_count == 1 and len(current_range.values[0]) == 1 and current_range.values[0][0] == '': + current_range.values = [] + current_range.row_count = 0 + + target_index = current_range.row_count + + # Generate the address needed to outline the bounding rectangle to use to fill in data. + col_name = col_index_to_label(col_index) + insert_range_address = 'A{}:{}{}'.format(target_index + 1, col_name, target_index + row_count) + + # Request to push the data to the given range. + self.update_cells(insert_range_address, rows) + + def get_named_range(self, name): + """Retrieves a Named range by it's name""" + url = self.build_url(self._endpoints.get("get_named_range").format(name=name)) + response = self.session.get(url) + if not response: + return None + return self.named_range_constructor( + parent=self, **{self._cloud_data_key: response.json()} + ) + + @staticmethod + def remove_sheet_name_from_address(address): + """Removes the sheet name from a given address""" + compiled = re.compile("([a-zA-Z]+[0-9]+):.*?([a-zA-Z]+[0-9]+)") + result = compiled.search(address) + if result: + return ":".join(result.groups()) + else: + return address + + +class WorkbookApplication(ApiComponent): + _endpoints = { + "get_details": "/application", + "post_calculation": "/application/calculate", + } + + def __init__(self, workbook): + """ + Create A WorkbookApplication representation + + :param workbook: A workbook object, of the workboook that you want to interact with + """ + + if not isinstance(workbook, WorkBook): + raise ValueError("workbook was not an accepted type: Workbook") + + #: The application parent. |br| **Type:** Workbook + self.parent = workbook # Not really needed currently, but saving in case we need it for future functionality + self.con = workbook.session.con + main_resource = getattr(workbook, "main_resource", None) + + super().__init__(protocol=workbook.protocol, main_resource=main_resource) + + def __str__(self): + return self.__repr__() + + def __repr__(self): + return "WorkbookApplication for Workbook: {}".format( + self.workbook_id or "Not set" + ) + + def __bool__(self): + return bool(self.parent) + + def get_details(self): + """Gets workbookApplication""" + url = self.build_url(self._endpoints.get("get_details")) + response = self.con.get(url) + + if not response: + return None + return response.json() + + def run_calculations(self, calculation_type): + """Recalculate all currently opened workbooks in Excel.""" + if calculation_type not in ["Recalculate", "Full", "FullRebuild"]: + raise ValueError( + "calculation type must be one of: Recalculate, Full, FullRebuild" + ) + + url = self.build_url(self._endpoints.get("post_calculation")) + data = {"calculationType": calculation_type} + headers = {"Content-type": "application/json"} + + if self.parent.session.session_id: + headers["workbook-session-id"] = self.parent.session.session_id + + response = self.con.post(url, headers=headers, data=data) + if not response: + return False + + return response.ok + + +class WorkBook(ApiComponent): + _endpoints = { + "get_worksheets": "/worksheets", + "get_tables": "/tables", + "get_table": "/tables/{id}", + "get_worksheet": "/worksheets/{id}", + "function": "/functions/{name}", + "get_names": "/names", + "get_named_range": "/names/{name}", + "add_named_range": "/names/add", + "add_named_range_f": "/names/addFormulaLocal", + } + + application_constructor = WorkbookApplication #: :meta private: + worksheet_constructor = WorkSheet #: :meta private: + table_constructor = Table #: :meta private: + named_range_constructor = NamedRange #: :meta private: + + def __init__(self, file_item, *, use_session=True, persist=True): + """Create a workbook representation + + :param File file_item: the Drive File you want to interact with + :param Bool use_session: Whether or not to use a session to be more efficient + :param Bool persist: Whether or not to persist this info + """ + if ( + file_item is None + or not isinstance(file_item, File) + or file_item.mime_type != EXCEL_XLSX_MIME_TYPE + ): + raise ValueError("This file is not a valid Excel xlsx file.") + + # append the workbook path + main_resource = "{}{}/workbook".format( + file_item.main_resource, + file_item._endpoints.get("item").format(id=file_item.object_id), + ) + + super().__init__(protocol=file_item.protocol, main_resource=main_resource) + + persist = persist if use_session is True else True + #: The session for the workbook. |br| **Type:** WorkbookSession + self.session = WorkbookSession( + parent=file_item, persist=persist, main_resource=main_resource + ) + + if use_session: + self.session.create_session() + + #: The name of the workbook. |br| **Type:**str** + self.name = file_item.name + #: The id of the workbook. |br| **Type:** str** + self.object_id = "Workbook:{}".format( + file_item.object_id + ) # Mangle the object id + + def __str__(self): + return self.__repr__() + + def __repr__(self): + return "Workbook: {}".format(self.name) + + def __eq__(self, other): + return self.object_id == other.object_id + + def get_tables(self): + """Returns a collection of this workbook tables""" + + url = self.build_url(self._endpoints.get("get_tables")) + response = self.session.get(url) + + if not response: + return [] + + data = response.json() + + return [ + self.table_constructor(parent=self, **{self._cloud_data_key: table}) + for table in data.get("value", []) + ] + + def get_table(self, id_or_name): + """ + Retrieves a Table by id or name + :param str id_or_name: The id or name of the column + :return: a Table instance + """ + url = self.build_url(self._endpoints.get("get_table").format(id=id_or_name)) + response = self.session.get(url) + if not response: + return None + return self.table_constructor( + parent=self, **{self._cloud_data_key: response.json()} + ) + + def get_workbookapplication(self): + return self.application_constructor(self) + + def get_worksheets(self): + """Returns a collection of this workbook worksheets""" + + url = self.build_url(self._endpoints.get("get_worksheets")) + response = self.session.get(url) + + if not response: + return [] + + data = response.json() + + return [ + self.worksheet_constructor(parent=self, **{self._cloud_data_key: ws}) + for ws in data.get("value", []) + ] + + def get_worksheet(self, id_or_name): + """Gets a specific worksheet by id or name""" + url = self.build_url( + self._endpoints.get("get_worksheet").format(id=quote(id_or_name)) + ) + response = self.session.get(url) + if not response: + return None + return self.worksheet_constructor( + parent=self, **{self._cloud_data_key: response.json()} + ) + + def add_worksheet(self, name=None): + """Adds a new worksheet""" + url = self.build_url(self._endpoints.get("get_worksheets")) + response = self.session.post(url, data={"name": name} if name else None) + if not response: + return None + data = response.json() + return self.worksheet_constructor(parent=self, **{self._cloud_data_key: data}) + + def delete_worksheet(self, worksheet_id): + """Deletes a worksheet by it's id""" + url = self.build_url( + self._endpoints.get("get_worksheet").format(id=quote(worksheet_id)) + ) + return bool(self.session.delete(url)) + + def invoke_function(self, function_name, **function_params): + """Invokes an Excel Function""" + url = self.build_url(self._endpoints.get("function").format(name=function_name)) + response = self.session.post(url, data=function_params) + if not response: + return None + data = response.json() + + error = data.get("error") + if error is None: + return data.get("value") + else: + raise FunctionException(error) + + def get_named_ranges(self): + """Returns the list of named ranges for this Workbook""" + + url = self.build_url(self._endpoints.get("get_names")) + response = self.session.get(url) + if not response: + return [] + data = response.json() + return [ + self.named_range_constructor(parent=self, **{self._cloud_data_key: nr}) + for nr in data.get("value", []) + ] + + def get_named_range(self, name): + """Retrieves a Named range by it's name""" + url = self.build_url(self._endpoints.get("get_named_range").format(name=name)) + response = self.session.get(url) + if not response: + return None + return self.named_range_constructor( + parent=self, **{self._cloud_data_key: response.json()} + ) + + def add_named_range(self, name, reference, comment="", is_formula=False): + """ + Adds a new name to the collection of the given scope using the user's locale for the formula + :param str name: the name of this range + :param str reference: the reference for this range or formula + :param str comment: a comment to describe this named range + :param bool is_formula: True if the reference is a formula + :return: NamedRange instance + """ + if is_formula: + url = self.build_url(self._endpoints.get("add_named_range_f")) + else: + url = self.build_url(self._endpoints.get("add_named_range")) + params = {"name": name, "reference": reference, "comment": comment} + response = self.session.post(url, data=params) + if not response: + return None + return self.named_range_constructor( + parent=self, **{self._cloud_data_key: response.json()} + ) diff --git a/O365/group.py b/O365/group.py deleted file mode 100644 index 368dda5e..00000000 --- a/O365/group.py +++ /dev/null @@ -1,88 +0,0 @@ -# Copyright 2015 by Toben "Narcolapser" Archer. All Rights Reserved. -# -# Permission to use, copy, modify, and distribute this software and its documentation for any purpose -# and without fee is hereby granted, provided that the above copyright notice appear in all copies and -# that both that copyright notice and this permission notice appear in supporting documentation, and -# that the name of Toben Archer not be used in advertising or publicity pertaining to distribution of -# the software without specific, written prior permission. TOBEN ARCHER DISCLAIMS ALL WARRANTIES WITH -# REGARD TO THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT -# SHALL TOBEN ARCHER BE LIABLE FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES -# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE -# OR OTHER TORTIOUS ACTION, ARISING OUT -# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. - -from contact import Contact -import logging -import json -import requests - -logging.basicConfig(filename='o365.log',level=logging.DEBUG) - -log = logging.getLogger(__name__) - -class Group( object ): - ''' - A wrapper class that handles all the contacts associated with a single Office365 account. - - Methods: - constructor -- takes your email and password for authentication. - getContacts -- begins the actual process of downloading contacts. - - Variables: - con_url -- the url that is requested for the retrival of the contacts. - con_folder_url -- the url that is used for requesting contacts from a specific folder. - folder_url -- the url that is used for finding folder Id's from folder names. - ''' - con_url = 'https://outlook.office365.com/api/v1.0/me/contacts' - con_folder_url = 'https://outlook.office365.com/api/v1.0/me/contactfolders/{0}/contacts' - folder_url = 'https://outlook.office365.com/api/v1.0/me/contactfolders?$filter=DisplayName eq \'{0}\'' - - def __init__(self, email, password, folderName=None): - ''' - Creates a group class for managing all contacts associated with email+password. - - Optional: folderName -- send the name of a contacts folder and the search will limit - it'self to only those which are in that folder. - ''' - log.debug('setting up for the schedule of the email %s',email) - self.auth = (email,password) - self.contacts = [] - self.folderName = folderName - - - def getContacts(self): - '''Begin the process of downloading contact metadata.''' - if self.folderName is None: - log.debug('fetching contacts.') - response = requests.get(self.con_url,auth=self.auth) - log.info('Response from O365: %s', str(response)) - - else: - log.debug('fetching contact folder.') - response = requests.get(self.folder_url.format(self.folderName),auth=self.auth) - fid = response.json()['value'][0]['Id'] - log.debug('got a response of {0} and an Id of {1}'.format(response.status_code,fid)) - - log.debug('fetching contacts for {0}.'.format(self.folderName)) - response = requests.get(self.con_folder_url.format(fid),auth=self.auth) - log.info('Response from O365: {0}'.format(str(response))) - - for contact in response.json()['value']: - duplicate = False - log.debug('Got a contact Named: {0}'.format(contact['DisplayName'])) - for existing in self.contacts: - if existing.json['Id'] == contact['Id']: - log.info('duplicate contact') - duplicate = True - break - - if not duplicate: - self.contacts.append(Contact(contact,self.auth)) - - log.debug('Appended Contact.') - - - log.debug('all calendars retrieved and put in to the list.') - return True - -#To the King! diff --git a/O365/groups.py b/O365/groups.py new file mode 100644 index 00000000..3fa6c5c3 --- /dev/null +++ b/O365/groups.py @@ -0,0 +1,275 @@ +import logging + +from .directory import User +from .utils import ApiComponent, NEXT_LINK_KEYWORD, Pagination + +log = logging.getLogger(__name__) + + +class Group(ApiComponent): + """ A Microsoft 365 group """ + + _endpoints = { + 'get_group_owners': '/groups/{group_id}/owners', + 'get_group_members': '/groups/{group_id}/members', + } + + member_constructor = User #: :meta private: + + def __init__(self, *, parent=None, con=None, **kwargs): + """ A Microsoft 365 group + + :param parent: parent object + :type parent: Teams + :param Connection con: connection to use if no parent specified + :param Protocol protocol: protocol to use if no parent specified + (kwargs) + :param str main_resource: use this resource instead of parent resource + (kwargs) + """ + if parent and con: + raise ValueError('Need a parent or a connection but not both') + self.con = parent.con if parent else con + + cloud_data = kwargs.get(self._cloud_data_key, {}) + + #: The unique identifier for the group. |br| **Type:** str + self.object_id = cloud_data.get('id') + + # Choose the main_resource passed in kwargs over parent main_resource + main_resource = kwargs.pop('main_resource', None) or ( + getattr(parent, 'main_resource', None) if parent else None) + + main_resource = '{}{}'.format(main_resource, '') + + super().__init__( + protocol=parent.protocol if parent else kwargs.get('protocol'), + main_resource=main_resource) + + #: The group type. |br| **Type:** str + self.type = cloud_data.get('@odata.type') + #: The display name for the group. |br| **Type:** str + self.display_name = cloud_data.get(self._cc('displayName'), '') + #: An optional description for the group. |br| **Type:** str + self.description = cloud_data.get(self._cc('description'), '') + #: The SMTP address for the group, for example, "serviceadmins@contoso.com". |br| **Type:** str + self.mail = cloud_data.get(self._cc('mail'), '') + #: The mail alias for the group, unique for Microsoft 365 groups in the organization. |br| **Type:** str + self.mail_nickname = cloud_data.get(self._cc('mailNickname'), '') + #: Specifies the group join policy and group content visibility for groups. |br| **Type:** str + self.visibility = cloud_data.get(self._cc('visibility'), '') + + def __str__(self): + return self.__repr__() + + def __repr__(self): + return 'Group: {}'.format(self.display_name) + + def __eq__(self, other): + return self.object_id == other.object_id + + def __hash__(self): + return self.object_id.__hash__() + + def get_group_members(self, recursive=False): + """ Returns members of given group + :param bool recursive: drill down to users if group has other group as a member + :rtype: list[User] + """ + if recursive: + recursive_data = self._get_group_members_raw() + for member in recursive_data: + if member['@odata.type'] == '#microsoft.graph.group': + recursive_members = Groups(con=self.con, protocol=self.protocol).get_group_by_id(member['id'])._get_group_members_raw() + recursive_data.extend(recursive_members) + return [self.member_constructor(parent=self, **{self._cloud_data_key: lst}) for lst in recursive_data] + else: + return [self.member_constructor(parent=self, **{self._cloud_data_key: lst}) for lst in self._get_group_members_raw()] + + def _get_group_members_raw(self): + url = self.build_url(self._endpoints.get('get_group_members').format(group_id=self.object_id)) + + response = self.con.get(url) + if not response: + return [] + + data = response.json() + return data.get('value', []) + + def get_group_owners(self): + """ Returns owners of given group + + :rtype: list[User] + """ + url = self.build_url(self._endpoints.get('get_group_owners').format(group_id=self.object_id)) + + response = self.con.get(url) + if not response: + return [] + + data = response.json() + + return [self.member_constructor(parent=self, **{self._cloud_data_key: lst}) for lst in data.get('value', [])] + + +class Groups(ApiComponent): + """ A microsoft groups class + In order to use the API following permissions are required. + Delegated (work or school account) - Group.Read.All, Group.ReadWrite.All + """ + + _endpoints = { + 'get_user_groups': '/users/{user_id}/memberOf', + 'get_group_by_id': '/groups/{group_id}', + 'get_group_by_mail': '/groups/?$search="mail:{group_mail}"&$count=true', + 'list_groups': '/groups', + } + + group_constructor = Group #: :meta private: + + def __init__(self, *, parent=None, con=None, **kwargs): + """ A Teams object + + :param parent: parent object + :type parent: Account + :param Connection con: connection to use if no parent specified + :param Protocol protocol: protocol to use if no parent specified + (kwargs) + :param str main_resource: use this resource instead of parent resource + (kwargs) + """ + if parent and con: + raise ValueError('Need a parent or a connection but not both') + self.con = parent.con if parent else con + + # Choose the main_resource passed in kwargs over the host_name + main_resource = kwargs.pop('main_resource', + '') # defaults to blank resource + super().__init__( + protocol=parent.protocol if parent else kwargs.get('protocol'), + main_resource=main_resource) + + def __str__(self): + return self.__repr__() + + def __repr__(self): + return 'Microsoft O365 Group parent class' + + def get_group_by_id(self, group_id = None): + """ Returns Microsoft 365/AD group with given id + + :param group_id: group id of group + + :rtype: Group + """ + + if not group_id: + raise RuntimeError('Provide the group_id') + + # get channels by the team id + url = self.build_url( + self._endpoints.get("get_group_by_id").format(group_id=group_id) + ) + + response = self.con.get(url) + + if not response: + return None + + data = response.json() + + return self.group_constructor(parent=self, **{self._cloud_data_key: data}) + + def get_group_by_mail(self, group_mail=None): + """Returns Microsoft 365/AD group by mail field + + :param group_name: mail of group + + :rtype: Group + """ + if not group_mail: + raise RuntimeError("Provide the group mail") + + # get groups by filter mail + url = self.build_url( + self._endpoints.get("get_group_by_mail").format(group_mail=group_mail) + ) + + response = self.con.get(url, headers={'ConsistencyLevel': 'eventual'}) + + if not response: + return None + + data = response.json() + + if '@odata.count' in data and data['@odata.count'] < 1: + raise RuntimeError('Not found group with provided filters') + + # mail is unique field so, we expect exact match -> always use first element from list + return self.group_constructor(parent=self, + **{self._cloud_data_key: data.get('value')[0]}) + + def get_user_groups(self, user_id=None, limit=None, batch=None): + """Returns list of groups that given user has membership + + :param user_id: user_id + :param int limit: max no. of groups to get. Over 999 uses batch. + :param int batch: batch size, retrieves items in + batches allowing to retrieve more items than the limit. + :rtype: list[Group] or Pagination + """ + + if not user_id: + raise RuntimeError("Provide the user_id") + + # get channels by the team id + url = self.build_url( + self._endpoints.get("get_user_groups").format(user_id=user_id) + ) + + params = {} + if limit is None or limit > self.protocol.max_top_value: + batch = self.protocol.max_top_value + params["$top"] = batch if batch else limit + response = self.con.get(url, params=params or None) + + if not response: + return None + + data = response.json() + + groups = [ + self.group_constructor(parent=self, **{self._cloud_data_key: group}) + for group in data.get("value", []) + ] + next_link = data.get(NEXT_LINK_KEYWORD, None) + if batch and next_link: + return Pagination( + parent=self, + data=groups, + constructor=self.group_constructor, + next_link=next_link, + limit=limit, + ) + + return groups + + def list_groups(self): + """Returns list of groups + + :rtype: list[Group] + """ + + url = self.build_url( + self._endpoints.get('list_groups')) + + response = self.con.get(url) + + if not response: + return None + + data = response.json() + + return [ + self.group_constructor(parent=self, **{self._cloud_data_key: group}) + for group in data.get('value', [])] diff --git a/O365/inbox.py b/O365/inbox.py deleted file mode 100644 index 708df0b3..00000000 --- a/O365/inbox.py +++ /dev/null @@ -1,111 +0,0 @@ -# Copyright 2015 by Toben "Narcolapser" Archer. All Rights Reserved. -# -# Permission to use, copy, modify, and distribute this software and its documentation for any purpose -# and without fee is hereby granted, provided that the above copyright notice appear in all copies and -# that both that copyright notice and this permission notice appear in supporting documentation, and -# that the name of Toben Archer not be used in advertising or publicity pertaining to distribution of -# the software without specific, written prior permission. TOBEN ARCHER DISCLAIMS ALL WARRANTIES WITH -# REGARD TO THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT -# SHALL TOBEN ARCHER BE LIABLE FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES -# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE -# OR OTHER TORTIOUS ACTION, ARISING OUT -# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. - -from O365.message import Message -import logging -import json -import requests - -logging.basicConfig(filename='o365.log',level=logging.DEBUG) - -log = logging.getLogger(__name__) - -class Inbox( object ): - ''' - Wrapper class for an inbox which mostly holds a list of messages. - - Methods: - getMessages -- downloads messages to local memory. - - Variables: - inbox_url -- url used for fetching emails. - ''' - #url for fetching emails. Takes a flag for whether they are read or not. - inbox_url = 'https://outlook.office365.com/api/v1.0/me/messages' - - def __init__(self, email, password,getNow=True): - ''' - Creates a new inbox wrapper. Send email and password for authentication. - - set getNow to false if you don't want to immedeatly download new messages. - ''' - - log.debug('creating inbox for the email %s',email) - self.auth = (email,password) - self.messages = [] - - self.filters = '' - - if getNow: - self.filters = 'IsRead eq false' - self.getMessages() - - - def getMessages(self): - ''' - Downloads messages to local memory. - - You create an inbox to be the container class for messages, this method - then pulls those messages down to the local disk. This is called in the - init method, so it's kind of pointless for you. Unless you think new - messages have come in. - - You can filter only certain emails by setting filters. See the set and - get filters methods for more information. - ''' - - log.debug('fetching messages.') - response = requests.get(self.inbox_url,auth=self.auth,params={'$filter':self.filters}) - log.info('Response from O365: %s', str(response)) - - for message in response.json()['value']: - try: - duplicate = False - for i,m in enumerate(self.messages): - if message['Id'] == m.json['Id']: - self.messages[i] = Message(message,self.auth) - duplicate = True - break - - if not duplicate: - self.messages.append(Message(message,self.auth)) - - log.debug('appended message: %s',message['Subject']) - except Exception as e: - log.info('failed to append message: %',str(e)) - - log.debug('all messages retrieved and put in to the list.') - return True - - def getFilter(self): - '''get the value set for a specific filter, if exists, else None''' - return self.filters - - def setFilter(self,f_string): - ''' - Set the value of a filter. More information on what filters are available - can be found here: - https://msdn.microsoft.com/office/office365/APi/complex-types-for-mail-contacts-calendar#RESTAPIResourcesMessage - I may in the future have the ability to add these in yourself. but right now that is to complicated. - - Arguments: - f_string -- The string that represents the filters you want to enact. - should be something like: (HasAttachments eq true) and (IsRead eq false) - or just: IsRead eq false - test your filter stirng here: https://outlook.office365.com/api/v1.0/me/messages?$filter= - if that accepts it then you know it works. - ''' - self.filters = f_string - return True - -#To the King! diff --git a/O365/mailbox.py b/O365/mailbox.py new file mode 100644 index 00000000..7f1902c4 --- /dev/null +++ b/O365/mailbox.py @@ -0,0 +1,1072 @@ +import datetime as dt +import logging +from enum import Enum + +from .message import Message +from .utils import ( + NEXT_LINK_KEYWORD, + ApiComponent, + OutlookWellKnowFolderNames, + Pagination, +) + +log = logging.getLogger(__name__) + + +class ExternalAudience(Enum): + """Valid values for externalAudience.""" + + NONE = "none" + CONTACTSONLY = "contactsOnly" + ALL = "all" + + +class AutoReplyStatus(Enum): + """Valid values for status.""" + + DISABLED = "disabled" + ALWAYSENABLED = "alwaysEnabled" + SCHEDULED = "scheduled" + + +class AutomaticRepliesSettings(ApiComponent): + """The AutomaticRepliesSettingss.""" + + def __init__(self, *, parent=None, con=None, **kwargs): + """Representation of the AutomaticRepliesSettings. + + :param parent: parent object + :type parent: Mailbox + :param Connection con: connection to use if no parent specified + :param Protocol protocol: protocol to use if no parent specified + (kwargs) + :param str main_resource: use this resource instead of parent resource + (kwargs) + """ + if parent and con: + raise ValueError("Need a parent or a connection but not both") + self.con = parent.con if parent else con + + # Choose the main_resource passed in kwargs over parent main_resource + main_resource = kwargs.pop("main_resource", None) or ( + getattr(parent, "main_resource", None) if parent else None + ) + + super().__init__( + protocol=parent.protocol if parent else kwargs.get("protocol"), + main_resource=main_resource, + ) + + cloud_data = kwargs.get(self._cloud_data_key, {}) + self.__external_audience = ExternalAudience( + cloud_data.get(self._cc("externalAudience"), "") + ) + #: The automatic reply to send to the specified external audience, + #: if Status is AlwaysEnabled or Scheduled. |br| **Type:** str + self.external_reply_message = cloud_data.get( + self._cc("externalReplyMessage"), "" + ) + #: The automatic reply to send to the audience internal to the signed-in user's + #: organization, if Status is AlwaysEnabled or Scheduled. |br| **Type:** str + self.internal_reply_message = cloud_data.get( + self._cc("internalReplyMessage"), "" + ) + scheduled_enddatetime_ob = cloud_data.get(self._cc("scheduledEndDateTime"), {}) + self.__scheduled_enddatetime = self._parse_date_time_time_zone( + scheduled_enddatetime_ob + ) + + scheduled_startdatetime_ob = cloud_data.get( + self._cc("scheduledStartDateTime"), {} + ) + self.__scheduled_startdatetime = self._parse_date_time_time_zone( + scheduled_startdatetime_ob + ) + + self.__status = AutoReplyStatus(cloud_data.get(self._cc("status"), "")) + + def __str__(self): + """Representation of the AutomaticRepliesSettings via the Graph api as a string.""" + return self.__repr__() + + @property + def scheduled_startdatetime(self): + """Scheduled Start Time of auto reply. + + :getter: get the scheduled_startdatetime time + :setter: set the scheduled_startdatetime time + :type: datetime + """ + return self.__scheduled_startdatetime + + @scheduled_startdatetime.setter + def scheduled_startdatetime(self, value): + if not isinstance(value, dt.date): + raise ValueError( + "'scheduled_startdatetime' must be a valid datetime object" + ) + if not isinstance(value, dt.datetime): + # force datetime + value = dt.datetime(value.year, value.month, value.day) + if value.tzinfo is None: + # localize datetime + value = value.replace(tzinfo=self.protocol.timezone) + elif value.tzinfo != self.protocol.timezone: + value = value.astimezone(self.protocol.timezone) + self.__scheduled_startdatetime = value + + @property + def scheduled_enddatetime(self): + """Scheduled End Time of auto reply. + + :getter: get the scheduled_enddatetime time + :setter: set the reminder time + :type: datetime + """ + return self.__scheduled_enddatetime + + @scheduled_enddatetime.setter + def scheduled_enddatetime(self, value): + if not isinstance(value, dt.date): + raise ValueError("'scheduled_enddatetime' must be a valid datetime object") + if not isinstance(value, dt.datetime): + # force datetime + value = dt.datetime(value.year, value.month, value.day) + if value.tzinfo is None: + # localize datetime + value = value.replace(tzinfo=self.protocol.timezone) + elif value.tzinfo != self.protocol.timezone: + value = value.astimezone(self.protocol.timezone) + self.__scheduled_enddatetime = value + + @property + def status(self) -> AutoReplyStatus: + """Status of auto reply. + + :getter: get the status of auto reply + :setter: set the status of auto reply + :type: autoreplystatus + """ + return self.__status + + @status.setter + def status(self, value: AutoReplyStatus = AutoReplyStatus.DISABLED): + self.__status = AutoReplyStatus(value) + + @property + def external_audience(self) -> ExternalAudience: + """External Audience of auto reply. + + :getter: get the external audience of auto reply + :setter: set the external audience of auto reply + :type: autoreplystatus + """ + return self.__external_audience + + @external_audience.setter + def external_audience(self, value: ExternalAudience = ExternalAudience.ALL): + if not value: + value = ExternalAudience.ALL + self.__external_audience = ExternalAudience(value) + + +class MailboxSettings(ApiComponent): + """The MailboxSettings.""" + + _endpoints = { + "settings": "/mailboxSettings", + } + autoreply_constructor = AutomaticRepliesSettings #: :meta private: + + def __init__(self, *, parent=None, con=None, **kwargs): + """Representation of the MailboxSettings. + + :param parent: parent object + :type parent: Mailbox + :param Connection con: connection to use if no parent specified + :param Protocol protocol: protocol to use if no parent specified + (kwargs) + :param str main_resource: use this resource instead of parent resource + (kwargs) + """ + if parent and con: + raise ValueError("Need a parent or a connection but not both") + self.con = parent.con if parent else con + + # Choose the main_resource passed in kwargs over parent main_resource + main_resource = kwargs.pop("main_resource", None) or ( + getattr(parent, "main_resource", None) if parent else None + ) + + super().__init__( + protocol=parent.protocol if parent else kwargs.get("protocol"), + main_resource=main_resource, + ) + + cloud_data = kwargs.get(self._cloud_data_key, {}) + autorepliessettings = cloud_data.get("automaticRepliesSetting") + #: Configuration settings to automatically notify the sender of + #: an incoming email with a message from the signed-in user. + #: |br| **Type:** AutomaticRepliesSettings + self.automaticrepliessettings = self.autoreply_constructor( + parent=self, **{self._cloud_data_key: autorepliessettings} + ) + #: The default time zone for the user's mailbox. |br| **Type:** str + self.timezone = cloud_data.get("timeZone") + #: The days of the week and hours in a specific time zone + #: that the user works. |br| **Type:** workingHours + self.workinghours = cloud_data.get("workingHours") + + def __str__(self): + """Representation of the MailboxSetting via the Graph api as a string.""" + return self.__repr__() + + def save(self): + """Save the MailboxSettings. + + :return: Success / Failure + :rtype: bool + """ + url = self.build_url(self._endpoints.get("settings")) + cc = self._cc + ars = self.automaticrepliessettings + automatic_reply_settings = { + cc("status"): ars.status.value, + cc("externalAudience"): ars.external_audience.value, + cc("internalReplyMessage"): ars.internal_reply_message, + cc("externalReplyMessage"): ars.external_reply_message, + } + if ars.status == AutoReplyStatus.SCHEDULED: + automatic_reply_settings[ + cc("scheduledStartDateTime") + ] = self._build_date_time_time_zone(ars.scheduled_startdatetime) + automatic_reply_settings[ + cc("scheduledEndDateTime") + ] = self._build_date_time_time_zone(ars.scheduled_enddatetime) + + data = {cc("automaticRepliesSetting"): automatic_reply_settings} + + response = self.con.patch(url, data=data) + + return bool(response) + + +class Folder(ApiComponent): + """A Mail Folder representation.""" + + _endpoints = { + "root_folders": "/mailFolders", + "child_folders": "/mailFolders/{id}/childFolders", + "get_folder": "/mailFolders/{id}", + "root_messages": "/messages", + "folder_messages": "/mailFolders/{id}/messages", + "copy_folder": "/mailFolders/{id}/copy", + "move_folder": "/mailFolders/{id}/move", + "message": "/messages/{id}", + } + message_constructor = Message #: :meta private: + + def __init__(self, *, parent=None, con=None, **kwargs): + """Create an instance to represent the specified folder in given + parent folder + + :param parent: parent folder/account for this folder + :type parent: mailbox.Folder or Account + :param Connection con: connection to use if no parent specified + :param Protocol protocol: protocol to use if no parent specified + (kwargs) + :param str main_resource: use this resource instead of parent resource + (kwargs) + :param str name: name of the folder to get under the parent (kwargs) + :param str folder_id: id of the folder to get under the parent (kwargs) + """ + if parent and con: + raise ValueError("Need a parent or a connection but not both") + self.con = parent.con if parent else con + #: The parent of the folder. |br| **Type:** str + self.parent = parent if isinstance(parent, Folder) else None + + # This folder has no parents if root = True. + #: Root folder. |br| **Type:** bool + self.root = kwargs.pop("root", False) + + # Choose the main_resource passed in kwargs over parent main_resource + main_resource = kwargs.pop("main_resource", None) or ( + getattr(parent, "main_resource", None) if parent else None + ) + + super().__init__( + protocol=parent.protocol if parent else kwargs.get("protocol"), + main_resource=main_resource, + ) + + cloud_data = kwargs.get(self._cloud_data_key, {}) + + # Fallback to manual folder if nothing available on cloud data + #: The mailFolder's display name. |br| **Type:** str + self.name = cloud_data.get(self._cc("displayName"), kwargs.get("name", "")) + if self.root is False: + # Fallback to manual folder if nothing available on cloud data + #: The mailFolder's unique identifier. |br| **Type:** str + self.folder_id = cloud_data.get( + self._cc("id"), kwargs.get("folder_id", None) + ) + #: The unique identifier for the mailFolder's parent mailFolder. |br| **Type:** str + self.parent_id = cloud_data.get(self._cc("parentFolderId"), None) + #: The number of immediate child mailFolders in the current mailFolder. + #: |br| **Type:** int + self.child_folders_count = cloud_data.get(self._cc("childFolderCount"), 0) + #: The number of items in the mailFolder marked as unread. |br| **Type:** int + self.unread_items_count = cloud_data.get(self._cc("unreadItemCount"), 0) + #: The number of items in the mailFolder. |br| **Type:** int + self.total_items_count = cloud_data.get(self._cc("totalItemCount"), 0) + #: Last time data updated |br| **Type:** datetime + self.updated_at = dt.datetime.now() + else: + #: The mailFolder's unique identifier. |br| **Type:** str + self.folder_id = "root" + + def __str__(self): + return self.__repr__() + + def __repr__(self): + return "{} from resource: {}".format(self.name, self.main_resource) + + def __eq__(self, other): + return self.folder_id == other.folder_id + + def get_folders(self, limit=None, *, query=None, order_by=None, batch=None): + """Return a list of child folders matching the query. + + :param int limit: max no. of folders to get. Over 999 uses batch. + :param query: applies a filter to the request such as + "displayName eq 'HelloFolder'" + :type query: Query or str + :param order_by: orders the result set based on this condition + :type order_by: Query or str + :param int batch: batch size, retrieves items in + batches allowing to retrieve more items than the limit. + :return: list of folders + :rtype: list[mailbox.Folder] or Pagination + """ + if self.root: + url = self.build_url(self._endpoints.get("root_folders")) + else: + url = self.build_url( + self._endpoints.get("child_folders").format(id=self.folder_id) + ) + + if limit is None or limit > self.protocol.max_top_value: + batch = self.protocol.max_top_value + + params = {"$top": batch if batch else limit} + + if order_by: + params["$orderby"] = order_by + + if query: + if isinstance(query, str): + params["$filter"] = query + else: + params.update(query.as_params()) + + response = self.con.get(url, params=params) + if not response: + return [] + + data = response.json() + + # Everything received from cloud must be passed as self._cloud_data_key + self_class = getattr(self, "folder_constructor", type(self)) + folders = [ + self_class(parent=self, **{self._cloud_data_key: folder}) + for folder in data.get("value", []) + ] + next_link = data.get(NEXT_LINK_KEYWORD, None) + if batch and next_link: + return Pagination( + parent=self, + data=folders, + constructor=self_class, + next_link=next_link, + limit=limit, + ) + else: + return folders + + def get_message(self, object_id=None, query=None, *, download_attachments=False): + """ + Get one message from the query result. + A shortcut to get_messages with limit=1 + + :param object_id: the message id to be retrieved. + :param query: applies a filter to the request such as + "displayName eq 'HelloFolder'" + :type query: Query or str + :param bool download_attachments: whether or not to download attachments + :return: one Message + :rtype: Message or None + """ + + if object_id is None and query is None: + raise ValueError("Must provide object id or query.") + + if object_id is not None: + url = self.build_url(self._endpoints.get("message").format(id=object_id)) + params = None + if query and (query.has_selects or query.has_expands): + params = query.as_params() + response = self.con.get(url, params=params) + if not response: + return None + + message = response.json() + + return self.message_constructor( + parent=self, + download_attachments=download_attachments, + **{self._cloud_data_key: message}, + ) + + else: + messages = list( + self.get_messages( + limit=1, query=query, download_attachments=download_attachments + ) + ) + + return messages[0] if messages else None + + def get_messages( + self, + limit=25, + *, + query=None, + order_by=None, + batch=None, + download_attachments=False, + ): + """ + Downloads messages from this folder + + :param int limit: limits the result set. Over 999 uses batch. + :param query: applies a filter to the request such as + "displayName eq 'HelloFolder'" + :type query: Query or str + :param order_by: orders the result set based on this condition + :type order_by: Query or str + :param int batch: batch size, retrieves items in + batches allowing to retrieve more items than the limit. + :param bool download_attachments: whether or not to download attachments + :return: list of messages + :rtype: list[Message] or Pagination + """ + + if self.root: + url = self.build_url(self._endpoints.get("root_messages")) + else: + url = self.build_url( + self._endpoints.get("folder_messages").format(id=self.folder_id) + ) + + if not batch and (limit is None or limit > self.protocol.max_top_value): + batch = self.protocol.max_top_value + + params = {"$top": batch if batch else limit} + + if order_by: + params["$orderby"] = order_by + + if query: + if isinstance(query, str): + params["$filter"] = query + else: + params.update(query.as_params()) + + response = self.con.get(url, params=params) + if not response: + return iter(()) + + data = response.json() + + # Everything received from cloud must be passed as self._cloud_data_key + messages = ( + self.message_constructor( + parent=self, + download_attachments=download_attachments, + **{self._cloud_data_key: message}, + ) + for message in data.get("value", []) + ) + + next_link = data.get(NEXT_LINK_KEYWORD, None) + if batch and next_link: + return Pagination( + parent=self, + data=messages, + constructor=self.message_constructor, + next_link=next_link, + limit=limit, + download_attachments=download_attachments, + ) + else: + return messages + + def create_child_folder(self, folder_name): + """Creates a new child folder under this folder + + :param str folder_name: name of the folder to add + :return: newly created folder + :rtype: mailbox.Folder or None + """ + if not folder_name: + return None + + if self.root: + url = self.build_url(self._endpoints.get("root_folders")) + else: + url = self.build_url( + self._endpoints.get("child_folders").format(id=self.folder_id) + ) + + response = self.con.post(url, data={self._cc("displayName"): folder_name}) + if not response: + return None + + folder = response.json() + + self_class = getattr(self, "folder_constructor", type(self)) + # Everything received from cloud must be passed as self._cloud_data_key + return self_class(parent=self, **{self._cloud_data_key: folder}) + + def get_folder(self, *, folder_id=None, folder_name=None): + """Get a folder by it's id or name + + :param str folder_id: the folder_id to be retrieved. + Can be any folder Id (child or not) + :param str folder_name: the folder name to be retrieved. + Must be a child of this folder. + :return: a single folder + :rtype: mailbox.Folder or None + """ + if folder_id and folder_name: + raise RuntimeError("Provide only one of the options") + + if not folder_id and not folder_name: + raise RuntimeError("Provide one of the options") + + if folder_id: + # get folder by it's id, independent of the parent of this folder_id + url = self.build_url(self._endpoints.get("get_folder").format(id=folder_id)) + params = None + else: + # get folder by name. Only looks up in child folders. + if self.root: + url = self.build_url(self._endpoints.get("root_folders")) + else: + url = self.build_url( + self._endpoints.get("child_folders").format(id=self.folder_id) + ) + params = { + "$filter": "{} eq '{}'".format(self._cc("displayName"), folder_name), + "$top": 1, + } + + response = self.con.get(url, params=params) + if not response: + return None + + if folder_id: + folder = response.json() + else: + folder = response.json().get("value") + folder = folder[0] if folder else None + if folder is None: + return None + + self_class = getattr(self, "folder_constructor", type(self)) + # Everything received from cloud must be passed as self._cloud_data_key + # We don't pass parent, as this folder may not be a child of self. + return self_class( + con=self.con, + protocol=self.protocol, + main_resource=self.main_resource, + **{self._cloud_data_key: folder}, + ) + + def refresh_folder(self, update_parent_if_changed=False): + """Re-download folder data + Inbox Folder will be unable to download its own data (no folder_id) + + :param bool update_parent_if_changed: updates self.parent with new + parent Folder if changed + :return: Refreshed or Not + :rtype: bool + """ + folder_id = getattr(self, "folder_id", None) + if self.root or folder_id is None: + return False + + folder = self.get_folder(folder_id=folder_id) + if folder is None: + return False + + self.name = folder.name + if folder.parent_id and self.parent_id: + if folder.parent_id != self.parent_id: + self.parent_id = folder.parent_id + self.parent = ( + self.get_parent_folder() if update_parent_if_changed else None + ) + self.child_folders_count = folder.child_folders_count + self.unread_items_count = folder.unread_items_count + self.total_items_count = folder.total_items_count + self.updated_at = folder.updated_at + + return True + + def get_parent_folder(self): + """Get the parent folder from attribute self.parent or + getting it from the cloud + + :return: Parent Folder + :rtype: mailbox.Folder or None + """ + if self.root: + return None + if self.parent: + return self.parent + + if self.parent_id: + self.parent = self.get_folder(folder_id=self.parent_id) + return self.parent + + def update_folder_name(self, name, update_folder_data=True): + """Change this folder name + + :param str name: new name to change to + :param bool update_folder_data: whether or not to re-fetch the data + :return: Updated or Not + :rtype: bool + """ + if self.root: + return False + if not name: + return False + + url = self.build_url( + self._endpoints.get("get_folder").format(id=self.folder_id) + ) + + response = self.con.patch(url, data={self._cc("displayName"): name}) + if not response: + return False + + self.name = name + if not update_folder_data: + return True + + folder = response.json() + + self.name = folder.get(self._cc("displayName"), "") + self.parent_id = folder.get(self._cc("parentFolderId"), None) + self.child_folders_count = folder.get(self._cc("childFolderCount"), 0) + self.unread_items_count = folder.get(self._cc("unreadItemCount"), 0) + self.total_items_count = folder.get(self._cc("totalItemCount"), 0) + self.updated_at = dt.datetime.now() + + return True + + def delete(self): + """Deletes this folder + + :return: Deleted or Not + :rtype: bool + """ + + if self.root or not self.folder_id: + return False + + url = self.build_url( + self._endpoints.get("get_folder").format(id=self.folder_id) + ) + + response = self.con.delete(url) + if not response: + return False + + self.folder_id = None + return True + + def copy_folder(self, to_folder): + """Copy this folder and it's contents to into another folder + + :param to_folder: the destination Folder/folder_id to copy into + :type to_folder: mailbox.Folder or str + :return: The new folder after copying + :rtype: mailbox.Folder or None + """ + to_folder_id = ( + to_folder.folder_id if isinstance(to_folder, Folder) else to_folder + ) + + if self.root or not self.folder_id or not to_folder_id: + return None + + url = self.build_url( + self._endpoints.get("copy_folder").format(id=self.folder_id) + ) + + response = self.con.post(url, data={self._cc("destinationId"): to_folder_id}) + if not response: + return None + + folder = response.json() + + self_class = getattr(self, "folder_constructor", type(self)) + # Everything received from cloud must be passed as self._cloud_data_key + return self_class( + con=self.con, + main_resource=self.main_resource, + **{self._cloud_data_key: folder}, + ) + + def move_folder(self, to_folder, *, update_parent_if_changed=True): + """Move this folder to another folder + + :param to_folder: the destination Folder/folder_id to move into + :type to_folder: mailbox.Folder or str + :param bool update_parent_if_changed: updates self.parent with the + new parent Folder if changed + :return: The new folder after copying + :rtype: mailbox.Folder or None + """ + to_folder_id = ( + to_folder.folder_id if isinstance(to_folder, Folder) else to_folder + ) + + if self.root or not self.folder_id or not to_folder_id: + return False + + url = self.build_url( + self._endpoints.get("move_folder").format(id=self.folder_id) + ) + + response = self.con.post(url, data={self._cc("destinationId"): to_folder_id}) + if not response: + return False + + folder = response.json() + + parent_id = folder.get(self._cc("parentFolderId"), None) + + if parent_id and self.parent_id: + if parent_id != self.parent_id: + self.parent_id = parent_id + self.parent = ( + self.get_parent_folder() if update_parent_if_changed else None + ) + + return True + + def new_message(self): + """Creates a new draft message under this folder + + :return: new Message + :rtype: Message + """ + + draft_message = self.message_constructor(parent=self, is_draft=True) + + if self.root: + draft_message.folder_id = OutlookWellKnowFolderNames.DRAFTS.value + else: + draft_message.folder_id = self.folder_id + + return draft_message + + def delete_message(self, message): + """Deletes a stored message + + :param message: message/message_id to delete + :type message: Message or str + :return: Success / Failure + :rtype: bool + """ + + message_id = message.object_id if isinstance(message, Message) else message + + if message_id is None: + raise RuntimeError("Provide a valid Message or a message id") + + url = self.build_url(self._endpoints.get("message").format(id=message_id)) + + response = self.con.delete(url) + + return bool(response) + + +class MailBox(Folder): + """The mailbox folder.""" + + folder_constructor = Folder #: :meta private: + mailbox_settings_constructor = MailboxSettings #: :meta private: + + def __init__(self, *, parent=None, con=None, **kwargs): + super().__init__(parent=parent, con=con, root=True, **kwargs) + self._endpoints["settings"] = "/mailboxSettings" + + def set_automatic_reply( + self, + internal_text: str, + external_text: str, + scheduled_start_date_time: dt.datetime = None, + scheduled_end_date_time: dt.datetime = None, + externalAudience: ExternalAudience = ExternalAudience.ALL, + ): + """Set an automatic reply for the mailbox. + + :return: Success / Failure + :rtype: bool + """ + mailboxsettings = self.get_settings() + ars = mailboxsettings.automaticrepliessettings + + ars.external_audience = externalAudience + ars.status = AutoReplyStatus.ALWAYSENABLED + if scheduled_start_date_time or scheduled_end_date_time: + ars.status = AutoReplyStatus.SCHEDULED + ars.scheduled_startdatetime = scheduled_start_date_time + ars.scheduled_enddatetime = scheduled_end_date_time + ars.internal_reply_message = internal_text + ars.external_reply_message = external_text + + return mailboxsettings.save() + + def _validate_datetime(self, value, erroritem): + if not isinstance(value, dt.date): + raise ValueError(f"'{erroritem} date' must be a valid datetime object") + if not isinstance(value, dt.datetime): + # force datetime + value = dt.datetime(value.year, value.month, value.day) + if value.tzinfo is None: + # localize datetime + value = value.replace(tzinfo=self.protocol.timezone) + elif value.tzinfo != self.protocol.timezone: + value = value.astimezone(self.protocol.timezone) + return value + + def set_disable_reply(self): + """Disable the automatic reply for the mailbox. + + :return: Success / Failure + :rtype: bool + """ + + mailboxsettings = self.get_settings() + ars = mailboxsettings.automaticrepliessettings + + ars.status = AutoReplyStatus.DISABLED + return mailboxsettings.save() + + def inbox_folder(self): + """Shortcut to get Inbox Folder instance + + :rtype: mailbox.Folder + """ + return self.folder_constructor( + parent=self, name="Inbox", folder_id=OutlookWellKnowFolderNames.INBOX.value + ) + + def junk_folder(self): + """Shortcut to get Junk Folder instance + + :rtype: mailbox.Folder + """ + return self.folder_constructor( + parent=self, name="Junk", folder_id=OutlookWellKnowFolderNames.JUNK.value + ) + + def deleted_folder(self): + """Shortcut to get DeletedItems Folder instance + + :rtype: mailbox.Folder + """ + return self.folder_constructor( + parent=self, + name="DeletedItems", + folder_id=OutlookWellKnowFolderNames.DELETED.value, + ) + + def drafts_folder(self): + """Shortcut to get Drafts Folder instance + + :rtype: mailbox.Folder + """ + return self.folder_constructor( + parent=self, + name="Drafts", + folder_id=OutlookWellKnowFolderNames.DRAFTS.value, + ) + + def sent_folder(self): + """Shortcut to get SentItems Folder instance + + :rtype: mailbox.Folder + """ + return self.folder_constructor( + parent=self, + name="SentItems", + folder_id=OutlookWellKnowFolderNames.SENT.value, + ) + + def outbox_folder(self): + """Shortcut to get Outbox Folder instance + + :rtype: mailbox.Folder + """ + return self.folder_constructor( + parent=self, + name="Outbox", + folder_id=OutlookWellKnowFolderNames.OUTBOX.value, + ) + + def archive_folder(self): + """Shortcut to get Archive Folder instance + + :rtype: mailbox.Folder + """ + return self.folder_constructor( + parent=self, + name="Archive", + folder_id=OutlookWellKnowFolderNames.ARCHIVE.value, + ) + + def clutter_folder(self): + """Shortcut to get Clutter Folder instance + The clutter folder low-priority messages are moved to when using the Clutter feature. + + :rtype: mailbox.Folder + """ + return self.folder_constructor( + parent=self, + name="Clutter", + folder_id=OutlookWellKnowFolderNames.CLUTTER.value, + ) + + def conflicts_folder(self): + """Shortcut to get Conflicts Folder instance + The folder that contains conflicting items in the mailbox. + + :rtype: mailbox.Folder + """ + return self.folder_constructor( + parent=self, + name="Conflicts", + folder_id=OutlookWellKnowFolderNames.CONFLICTS.value, + ) + + def conversationhistory_folder(self): + """Shortcut to get Conversation History Folder instance + The folder where Skype saves IM conversations (if Skype is configured to do so). + + :rtype: mailbox.Folder + """ + return self.folder_constructor( + parent=self, + name="Conflicts", + folder_id=OutlookWellKnowFolderNames.CONVERSATIONHISTORY.value, + ) + + def localfailures_folder(self): + """Shortcut to get Local Failure Folder instance + The folder that contains items that exist on the local client but could not be uploaded to the server. + + :rtype: mailbox.Folder + """ + return self.folder_constructor( + parent=self, + name="Local Failures", + folder_id=OutlookWellKnowFolderNames.LOCALFAILURES.value, + ) + + def recoverableitemsdeletions_folder(self): + """Shortcut to get Recoverable Items Deletions (Purges) Folder instance + The folder that contains soft-deleted items: deleted either from the Deleted Items folder, or by pressing shift+delete in Outlook. + This folder is not visible in any Outlook email client, + but end users can interact with it through the Recover Deleted Items from Server feature in Outlook or Outlook on the web. + + :rtype: mailbox.Folder + """ + return self.folder_constructor( + parent=self, + name="Recoverable Items Deletions (Purges)", + folder_id=OutlookWellKnowFolderNames.RECOVERABLEITEMSDELETIONS.value, + ) + + def scheduled_folder(self): + """Shortcut to get Scheduled Folder instance + The folder that contains messages that are scheduled to reappear in the inbox using the Schedule feature in Outlook for iOS. + + :rtype: mailbox.Folder + """ + return self.folder_constructor( + parent=self, + name="Scheduled", + folder_id=OutlookWellKnowFolderNames.SCHEDULED.value, + ) + + def searchfolders_folder(self): + """Shortcut to get Search Folders Folder instance + The parent folder for all search folders defined in the user's mailbox. + + :rtype: mailbox.Folder + """ + return self.folder_constructor( + parent=self, + name="Search Folders", + folder_id=OutlookWellKnowFolderNames.SEARCHFOLDERS.value, + ) + + def serverfailures_folder(self): + """Shortcut to get Server Failures Folder instance + The folder that contains items that exist on the server but could not be synchronized to the local client. + + :rtype: mailbox.Folder + """ + return self.folder_constructor( + parent=self, + name="Server Failures", + folder_id=OutlookWellKnowFolderNames.SERVERFAILURES.value, + ) + + def syncissues_folder(self): + """Shortcut to get Sync Issues Folder instance + The folder that contains synchronization logs created by Outlook. + + :rtype: mailbox.Folder + """ + return self.folder_constructor( + parent=self, + name="Sync Issues", + folder_id=OutlookWellKnowFolderNames.SYNCISSUES.value, + ) + + def get_settings(self): + """Return the MailboxSettings. + + :rtype: mailboxsettings + """ + url = self.build_url(self._endpoints.get("settings")) + params = {} + + response = self.con.get(url, params=params) + + if not response: + return iter(()) + + data = response.json() + + return self.mailbox_settings_constructor( + parent=self, **{self._cloud_data_key: data} + ) diff --git a/O365/message.py b/O365/message.py index 1b479e87..3271c3f0 100644 --- a/O365/message.py +++ b/O365/message.py @@ -1,237 +1,1274 @@ -# Copyright 2015 by Toben "Narcolapser" Archer. All Rights Reserved. -# -# Permission to use, copy, modify, and distribute this software and its documentation for any purpose -# and without fee is hereby granted, provided that the above copyright notice appear in all copies and -# that both that copyright notice and this permission notice appear in supporting documentation, and -# that the name of Toben Archer not be used in advertising or publicity pertaining to distribution of -# the software without specific, written prior permission. TOBEN ARCHER DISCLAIMS ALL WARRANTIES WITH -# REGARD TO THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT -# SHALL TOBEN ARCHER BE LIABLE FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES -# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE -# OR OTHER TORTIOUS ACTION, ARISING OUT -# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. - -from O365 import Attachment -from O365 import Contact +import datetime as dt import logging -import json -import requests +from enum import Enum +from pathlib import Path -logging.basicConfig(filename='o365.log',level=logging.DEBUG) +# noinspection PyPep8Naming +from bs4 import BeautifulSoup as bs +from dateutil.parser import parse + +from .calendar import Event +from .category import Category +from .utils import ( + ApiComponent, + AttachableMixin, + BaseAttachment, + BaseAttachments, + CaseEnum, + HandleRecipientsMixin, + ImportanceLevel, + OutlookWellKnowFolderNames, + Recipient, + TrackerSet, +) log = logging.getLogger(__name__) -class Message( object ): - ''' - Management of the process of sending, recieving, reading, and editing emails. - - Note: the get and set methods are technically superflous. You can get more through control over - a message you are trying to craft throught he use of editing the message.json, but these - methods provide an easy way if you don't need all the power and would like the ease. - - Methods: - constructor -- creates a new message class, using json for existing, nothing for new. - fetchAttachments -- kicks off the process that downloads attachments. - sendMessage -- take local variables and form them to send the message. - markAsRead -- marks the analougs message in the cloud as read. - getSender -- gets a dictionary with the sender's information. - getSenderEmail -- gets the email address of the sender. - getSenderName -- gets the name of the sender, if possible. - getSubject -- gets the email's subject line. - getBody -- gets contents of the body of the email. - addRecipient -- adds a person to the recipient list. - setRecipients -- sets the list of recipients. - setSubject -- sets the subject line. - setBody -- sets the body. - - Variables: - att_url -- url for requestiong attachments. takes message GUID - send_url -- url for sending an email - update_url -- url for updating an email already existing in the cloud. - - ''' - - att_url = 'https://outlook.office365.com/api/v1.0/me/messages/{0}/attachments' - send_url = 'https://outlook.office365.com/api/v1.0/me/sendmail' - draft_url = 'https://outlook.office365.com/api/v1.0/me/folders/{folder_id}/messages' - update_url = 'https://outlook.office365.com/api/v1.0/me/messages/{0}' - - def __init__(self, json=None, auth=None): - ''' - Makes a new message wrapper for sending and recieving messages. - - Keyword Arguments: - json (default = None) -- Takes json if you have a pre-existing message to create from. - this is mostly used inside the library for when new messages are downloaded. - auth (default = None) -- Takes an (email,password) tuple that will be used for - authentication with office365. - ''' - if json: - self.json = json - self.hasAttachments = json['HasAttachments'] - - else: - self.json = {'Message':{'Body':{}},'ToRecipients':{}} - self.hasAttachments = False - - self.auth = auth - self.attachments = [] - self.reciever = None - - - def fetchAttachments(self): - '''kicks off the process that downloads attachments locally.''' - if not self.hasAttachments: - log.debug('message has no attachments, skipping out early.') - return False - - response = requests.get(self.att_url.format(self.json['Id']),auth=self.auth) - log.info('response from O365 for retriving message attachments: %s',str(response)) - json = response.json() - - for att in json['value']: - try: - self.attachments.append(Attachment(att)) - log.debug('successfully downloaded attachment for: %s.',self.auth[0]) - except Exception as e: - log.info('failed to download attachment for: %s', self.auth[0]) - - return len(self.attachments) - - def sendMessage(self): - '''takes local variabls and forms them into a message to be sent.''' - - headers = {'Content-type': 'application/json', 'Accept': 'text/plain'} - - try: - data = {'Message':{'Body':{}}} - data['Message']['Subject'] = self.json['Subject'] - data['Message']['Body']['Content'] = self.json['Body']['Content'] - data['Message']['Body']['ContentType'] = self.json['Body']['ContentType'] - data['Message']['ToRecipients'] = self.json['ToRecipients'] - data['Message']['Attachments'] = [att.json for att in self.attachments] - data['SaveToSentItems'] = "false" - data = json.dumps(data) - log.debug(str(data)) - except Exception as e: - log.error(str(e)) - return False - - response = requests.post(self.send_url,data,headers=headers,auth=self.auth) - log.debug('response from server for sending message:'+str(response)) - - if response.status_code != 202: - return False - - return True - - - - - def markAsRead(self): - '''marks analogous message as read in the cloud.''' - read = '{"IsRead":true}' - headers = {'Content-type': 'application/json', 'Accept': 'application/json'} - try: - response = requests.patch(self.update_url.format(self.json['Id']),read,headers=headers,auth=self.auth) - except: - return False - return True - - - def getSender(self): - '''get all available information for the sender of the email.''' - return self.json['Sender'] - - def getSenderEmail(self): - '''get the email address of the sender.''' - return self.json['Sender']['EmailAddress']['Address'] - - def getSenderName(self): - '''try to get the name of the sender.''' - try: - return self.json['Sender']['EmailAddress']['Name'] - except: - return '' - - def getSubject(self): - '''get email subject line.''' - return self.json['Subject'] - - def getBody(self): - '''get email body.''' - return self.json['Body']['Content'] - - def setRecipients(self,val): - ''' - set the recipient list. - - val: the one argument this method takes can be very flexible. you can send: - a dictionary: this must to be a dictionary formated as such: - {"EmailAddress":{"Address":"recipient@example.com"}} - with other options such ass "Name" with address. but at minimum it must have this. - a list: this must to be a list of libraries formatted the way specified above, - or it can be a list of dictionary objects of type Contact. The method will sort - out the libraries from the contacts. - a string: this is if you just want to throw an email address. - a contact: type Contact from this dictionary. - a group: type Group, which is a list of contacts. - For each of these argument types the appropriate action will be taken to fit them to the - needs of the library. - ''' - if isinstance(val,list): - self.json['ToRecipients'] = [] - if isinstance(val,Contact): - self.addRecipient(val) - else: - self.json['ToRecipients'].append(val) - elif isinstance(val,dict): - self.json['ToRecipients'] = [val] - elif isinstance(val,str): - if '@' in val: - self.json['ToRecipients'] = [] - self.addRecipient(val) - elif isinstance(val,Contact): - self.addRecipient(val) - elif isinstance(val,Group): - for person in val: - self.addRecipient(person) - else: - return False - return True - - def addRecipient(self,address,name=None): - ''' - Adds a recipient to the recipients list. - - Arguments: - address -- the email address of the person you are sending to. <<< Important that. - Address can also be of type contact. if it is name is superflous. Else, it - uses the name passed if you sent it one. - name -- the name of the person you are sending to. mostly just a decorator. - ''' - if isinstance(address,Contact): - self.json['ToRecipients'].append(address.getFirstEmailAddress()) - else: - if name is None: - name = address[:address.index('@')] - self.json['ToRecipients'].append({'EmailAddress':{'Address':address,'Name':name}}) - - def setSubject(self,val): - '''Sets the subect line of the email.''' - self.json['Subject'] = val - - def setBody(self,val): - '''Sets the body content of the email.''' - cont = False - - while not cont: - try: - self.json['Body']['Content'] = val - self.json['Body']['ContentType'] = 'Text' - cont = True - except: - self.json['Body'] = {} - - -#To the King! + +class RecipientType(Enum): + TO = "to" + CC = "cc" + BCC = "bcc" + + +class MeetingMessageType(CaseEnum): + MeetingRequest = "meetingRequest" + MeetingCancelled = "meetingCancelled" + MeetingAccepted = "meetingAccepted" + MeetingTentativelyAccepted = "meetingTentativelyAccepted" + MeetingDeclined = "meetingDeclined" + + +class Flag(CaseEnum): + NotFlagged = "notFlagged" + Complete = "complete" + Flagged = "flagged" + + +class MessageAttachment(BaseAttachment): + _endpoints = { + "attach": "/messages/{id}/attachments", + "attachment": "/messages/{id}/attachments/{ida}", + } + + +class MessageAttachments(BaseAttachments): + _endpoints = { + "attachments": "/messages/{id}/attachments", + "attachment": "/messages/{id}/attachments/{ida}", + "get_mime": "/messages/{id}/attachments/{ida}/$value", + "create_upload_session": "/messages/{id}/attachments/createUploadSession", + } + _attachment_constructor = MessageAttachment #: :meta private: + + def save_as_eml(self, attachment, to_path=None): + """Saves this message as and EML to the file system + :param MessageAttachment attachment: the MessageAttachment to store as eml. + :param Path or str to_path: the path where to store this file + """ + mime_content = self.get_mime_content(attachment) + if not mime_content: + return False + + if to_path is None: + to_path = Path("message_eml.eml") + else: + if not isinstance(to_path, Path): + to_path = Path(to_path) + + if not to_path.suffix: + to_path = to_path.with_suffix(".eml") + + with to_path.open("wb") as file_obj: + file_obj.write(mime_content) + return True + + def _get_mime_url(self, attachment: MessageAttachment) -> str: + """ Returns the url used to get the MIME contents of this attachment""" + if ( + not attachment + or not isinstance(attachment, MessageAttachment) + or attachment.attachment_id is None + or attachment.attachment_type != "item" + ): + raise ValueError( + 'Must provide a saved "item" attachment of type MessageAttachment' + ) + + msg_id = self._parent.object_id + if msg_id is None: + raise RuntimeError( + "Attempting to get the mime contents of an unsaved message" + ) + + url = self.build_url( + self._endpoints.get("get_mime").format( + id=msg_id, ida=attachment.attachment_id + ) + ) + return url + + def get_mime_content(self, attachment: MessageAttachment): + """Returns the MIME contents of this attachment""" + + url = self._get_mime_url(attachment) + + response = self._parent.con.get(url) + + if not response: + return None + + return response.content + + def get_eml_as_object(self, attachment: MessageAttachment): + """ Returns a Message object out an eml attached message """ + + url = self._get_mime_url(attachment) + + # modify the url to retrieve the eml message contents + item_attachment_keyword = self.protocol.keyword_data_store.get("item_attachment_type").removeprefix('#') + url = f'{url.removesuffix("$value")}?$expand={item_attachment_keyword}/item' + + response = self._parent.con.get(url) + if not response: + return None + + content_item = response.json().get('item', {}) + if content_item: + return self._parent.__class__(parent=self._parent, **{self._cloud_data_key: content_item}) + else: + return None + + +class MessageFlag(ApiComponent): + """A flag on a message""" + + def __init__(self, parent, flag_data): + """An flag on a message + Not available on Outlook Rest Api v2 (only in beta) + + :param parent: parent of this + :type parent: Message + :param dict flag_data: flag data from cloud + """ + super().__init__(protocol=parent.protocol, main_resource=parent.main_resource) + + self.__message = parent + + self.__status = Flag.from_value( + flag_data.get(self._cc("flagStatus"), "notFlagged") + ) + + start_obj = flag_data.get(self._cc("startDateTime"), {}) + self.__start = self._parse_date_time_time_zone(start_obj) + + due_date_obj = flag_data.get(self._cc("dueDateTime"), {}) + self.__due_date = self._parse_date_time_time_zone(due_date_obj) + + completed_date_obj = flag_data.get(self._cc("completedDateTime"), {}) + self.__completed = self._parse_date_time_time_zone(completed_date_obj) + + def __repr__(self): + return str(self.__status) + + def __str__(self): + return self.__repr__() + + def __bool__(self): + return self.is_flagged + + def _track_changes(self): + """Update the track_changes on the message to reflect a + needed update on this field""" + self.__message._track_changes.add("flag") + + @property + def status(self): + return self.__status + + def set_flagged(self, *, start_date=None, due_date=None): + """Sets this message as flagged + :param start_date: the start datetime of the followUp + :param due_date: the due datetime of the followUp + """ + self.__status = Flag.Flagged + start_date = start_date or dt.datetime.now() + due_date = due_date or dt.datetime.now() + if start_date.tzinfo is None: + start_date = start_date.replace(tzinfo=self.protocol.timezone) + if due_date.tzinfo is None: + due_date = due_date.replace(tzinfo=self.protocol.timezone) + self.__start = start_date + self.__due_date = due_date + self._track_changes() + + def set_completed(self, *, completition_date=None): + """Sets this message flag as completed + :param completition_date: the datetime this followUp was completed + """ + self.__status = Flag.Complete + completition_date = completition_date or dt.datetime.now() + if completition_date.tzinfo is None: + completition_date = completition_date.replace(tzinfo=self.protocol.timezone) + self.__completed = completition_date + self._track_changes() + + def delete_flag(self): + """Sets this message as un flagged""" + self.__status = Flag.NotFlagged + self.__start = None + self.__due_date = None + self.__completed = None + self._track_changes() + + @property + def start_date(self): + """The start date of the message flag. + + :getter: get the start_date + :type: datetime + """ + return self.__start + + @property + def due_date(self): + """The due date of the message flag. + + :getter: get the due_date + :type: datetime + """ + return self.__due_date + + @property + def completition_date(self): + """The completion date of the message flag. + + :getter: get the completion_date + :type: datetime + """ + return self.__completed + + @property + def is_completed(self): + """Is the flag completed. + + :getter: get the is_completed status + :type: bool + """ + return self.__status is Flag.Complete + + @property + def is_flagged(self): + """Is item flagged. + + :getter: get the is_flagged status + :type: bool + """ + return self.__status is Flag.Flagged or self.__status is Flag.Complete + + def to_api_data(self): + """Returns this data as a dict to be sent to the server""" + data = {self._cc("flagStatus"): self._cc(self.__status.value)} + if self.__status is Flag.Flagged: + data[self._cc("startDateTime")] = ( + self._build_date_time_time_zone(self.__start) + if self.__start is not None + else None + ) + data[self._cc("dueDateTime")] = ( + self._build_date_time_time_zone(self.__due_date) + if self.__due_date is not None + else None + ) + + if self.__status is Flag.Complete: + data[self._cc("completedDateTime")] = self._build_date_time_time_zone( + self.__completed + ) + + return data + +class Message(ApiComponent, AttachableMixin, HandleRecipientsMixin): + """Management of the process of sending, receiving, reading, and + editing emails.""" + + _endpoints = { + "create_draft": "/messages", + "create_draft_folder": "/mailFolders/{id}/messages", + "send_mail": "/sendMail", + "send_draft": "/messages/{id}/send", + "get_message": "/messages/{id}", + "move_message": "/messages/{id}/move", + "copy_message": "/messages/{id}/copy", + "create_reply": "/messages/{id}/createReply", + "create_reply_all": "/messages/{id}/createReplyAll", + "forward_message": "/messages/{id}/createForward", + "get_mime": "/messages/{id}/$value", + } + + def __init__(self, *, parent=None, con=None, **kwargs): + """Makes a new message wrapper for sending and receiving messages. + + :param parent: parent folder/account to create the message in + :type parent: mailbox.Folder or Account + :param Connection con: connection to use if no parent specified + :param Protocol protocol: protocol to use if no parent specified + (kwargs) + :param str main_resource: use this resource instead of parent resource + (kwargs) + :param bool download_attachments: whether or not to + download attachments (kwargs) + """ + if parent and con: + raise ValueError("Need a parent or a connection but not both") + self.con = parent.con if parent else con + + # Choose the main_resource passed in kwargs over parent main_resource + main_resource = kwargs.pop("main_resource", None) or ( + getattr(parent, "main_resource", None) if parent else None + ) + + super().__init__( + protocol=parent.protocol if parent else kwargs.get("protocol"), + main_resource=main_resource, + attachment_name_property="subject", + attachment_type="message_type", + ) + + download_attachments = kwargs.get("download_attachments") + + cloud_data = kwargs.get(self._cloud_data_key, {}) + cc = self._cc # alias to shorten the code + + # internal to know which properties need to be updated on the server + self._track_changes = TrackerSet(casing=cc) + #: Unique identifier for the message. |br| **Type:** str + self.object_id = cloud_data.get(cc("id"), kwargs.get("object_id", None)) + + self.__inference_classification = cloud_data.get( + cc("inferenceClassification"), None + ) + + self.__created = cloud_data.get(cc("createdDateTime"), None) + self.__modified = cloud_data.get(cc("lastModifiedDateTime"), None) + self.__received = cloud_data.get(cc("receivedDateTime"), None) + self.__sent = cloud_data.get(cc("sentDateTime"), None) + + local_tz = self.protocol.timezone + self.__created = ( + parse(self.__created).astimezone(local_tz) if self.__created else None + ) + self.__modified = ( + parse(self.__modified).astimezone(local_tz) if self.__modified else None + ) + self.__received = ( + parse(self.__received).astimezone(local_tz) if self.__received else None + ) + self.__sent = parse(self.__sent).astimezone(local_tz) if self.__sent else None + + self.__attachments = MessageAttachments(parent=self, attachments=[]) + self.__attachments.add( + {self._cloud_data_key: cloud_data.get(cc("attachments"), [])} + ) + self.__has_attachments = cloud_data.get(cc("hasAttachments"), False) + self.__subject = cloud_data.get(cc("subject"), "") + self.__body_preview = cloud_data.get(cc("bodyPreview"), "") + body = cloud_data.get(cc("body"), {}) + self.__body = body.get(cc("content"), "") + #: The body type of the message. |br| **Type:** bodyType + self.body_type = body.get( + cc("contentType"), "HTML" + ) # default to HTML for new messages + + unique_body = cloud_data.get(cc("uniqueBody"), {}) + self.__unique_body = unique_body.get(cc("content"), "") + self.unique_body_type = unique_body.get( + cc("contentType"), "HTML" + ) # default to HTML for new messages + + if download_attachments and self.has_attachments: + self.attachments.download_attachments() + + self.__sender = self._recipient_from_cloud( + cloud_data.get(cc("from"), None), field=cc("from") + ) + self.__to = self._recipients_from_cloud( + cloud_data.get(cc("toRecipients"), []), field=cc("toRecipients") + ) + self.__cc = self._recipients_from_cloud( + cloud_data.get(cc("ccRecipients"), []), field=cc("ccRecipients") + ) + self.__bcc = self._recipients_from_cloud( + cloud_data.get(cc("bccRecipients"), []), field=cc("bccRecipients") + ) + self.__reply_to = self._recipients_from_cloud( + cloud_data.get(cc("replyTo"), []), field=cc("replyTo") + ) + self.__categories = cloud_data.get(cc("categories"), []) + + self.__importance = ImportanceLevel.from_value( + cloud_data.get(cc("importance"), "normal") or "normal" + ) + self.__is_read = cloud_data.get(cc("isRead"), None) + + self.__is_read_receipt_requested = cloud_data.get( + cc("isReadReceiptRequested"), False + ) + self.__is_delivery_receipt_requested = cloud_data.get( + cc("isDeliveryReceiptRequested"), False + ) + + self.__single_value_extended_properties = cloud_data.get( + cc("singleValueExtendedProperties"), [] + ) + + # if this message is an EventMessage: + meeting_mt = cloud_data.get(cc("meetingMessageType"), "none") + + # hack to avoid typo in EventMessage between Api v1.0 and beta: + meeting_mt = meeting_mt.replace("Tenatively", "Tentatively") + + self.__meeting_message_type = ( + MeetingMessageType.from_value(meeting_mt) if meeting_mt != "none" else None + ) + + # a message is a draft by default + self.__is_draft = cloud_data.get(cc("isDraft"), kwargs.get("is_draft", True)) + #: The ID of the conversation the email belongs to. |br| **Type:** str + self.conversation_id = cloud_data.get(cc("conversationId"), None) + #: Indicates the position of the message within the conversation. |br| **Type:** any + self.conversation_index = cloud_data.get(cc("conversationIndex"), None) + #: The unique identifier for the message's parent mailFolder. |br| **Type:** str + self.folder_id = cloud_data.get(cc("parentFolderId"), None) + + flag_data = cloud_data.get(cc("flag"), {}) + self.__flag = MessageFlag(parent=self, flag_data=flag_data) + + #: The message ID in the format specified by RFC2822. |br| **Type:** str + self.internet_message_id = cloud_data.get(cc("internetMessageId"), "") + #: The URL to open the message in Outlook on the web. |br| **Type:** str + self.web_link = cloud_data.get(cc("webLink"), "") + + # Headers only retrieved when selecting 'internetMessageHeaders' + self.__message_headers = cloud_data.get(cc("internetMessageHeaders"), []) + + def __str__(self): + return self.__repr__() + + def __repr__(self): + return "Subject: {}".format(self.subject) + + def __eq__(self, other): + return self.object_id == other.object_id + + @property + def is_read(self): + """Check if the message is read or not + + :getter: Get the status of message read + :setter: Mark the message as read + :type: bool + """ + return self.__is_read + + @is_read.setter + def is_read(self, value): + self.__is_read = value + self._track_changes.add("isRead") + + @property + def has_attachments(self): + """Check if the message contains attachments + + :type: bool + """ + if self.__has_attachments is False and self.body_type.upper() == "HTML": + # test for inline attachments (Azure responds with hasAttachments=False when there are only inline attachments): + if any( + img.get("src", "").startswith("cid:") + for img in self.get_body_soup().find_all("img") + ): + self.__has_attachments = True + return self.__has_attachments + + @property + def is_draft(self): + """Check if the message is marked as draft + + :type: bool + """ + return self.__is_draft + + @property + def subject(self): + """Subject of the email message + + :getter: Get the current subject + :setter: Assign a new subject + :type: str + """ + return self.__subject + + @subject.setter + def subject(self, value): + self.__subject = value + self._track_changes.add("subject") + + @property + def body_preview(self): + """Returns the body preview""" + return self.__body_preview + + @property + def body(self): + """Body of the email message + + :getter: Get body text of current message + :setter: set html body of the message + :type: str + """ + return self.__body + + @property + def inference_classification(self): + """Message is focused or not""" + return self.__inference_classification + + @body.setter + def body(self, value): + if self.__body: + if not value: + self.__body = "" + elif self.body_type == "html": + soup = bs(self.__body, "html.parser") + soup.body.insert(0, bs(value, "html.parser")) + self.__body = str(soup) + else: + self.__body = "".join((value, "\n", self.__body)) + else: + self.__body = value + self._track_changes.add("body") + + @property + def unique_body(self): + """The unique body of this message + + Requires a select to retrieve it. + + :rtype: str + """ + return self.__unique_body + + @property + def created(self): + """Created time of the message""" + return self.__created + + @property + def modified(self): + """Message last modified time""" + return self.__modified + + @property + def received(self): + """Message received time""" + return self.__received + + @property + def sent(self): + """Message sent time""" + return self.__sent + + @property + def attachments(self): + """List of attachments""" + return self.__attachments + + @property + def sender(self): + """Sender of the message + + :getter: Get the current sender + :setter: Update the from address with new value + :type: str or Recipient + """ + return self.__sender + + @sender.setter + def sender(self, value): + """sender is a property to force to be always a Recipient class""" + if isinstance(value, Recipient): + if value._parent is None: + value._parent = self + value._field = "from" + self.__sender = value + elif isinstance(value, str): + self.__sender.address = value + self.__sender.name = "" + else: + raise ValueError("sender must be an address string or a Recipient object") + self._track_changes.add("from") + + @property + def to(self): + """'TO' list of recipients""" + return self.__to + + @property + def cc(self): + """'CC' list of recipients""" + return self.__cc + + @property + def bcc(self): + """'BCC' list of recipients""" + return self.__bcc + + @property + def reply_to(self): + """Reply to address""" + return self.__reply_to + + @property + def categories(self): + """Categories of this message + + :getter: Current list of categories + :setter: Set new categories for the message + :type: list[str] or str + """ + return self.__categories + + @categories.setter + def categories(self, value): + if isinstance(value, list): + self.__categories = [] + for val in value: + if isinstance(val, Category): + self.__categories.append(val.name) + else: + self.__categories.append(val) + elif isinstance(value, str): + self.__categories = [value] + elif isinstance(value, Category): + self.__categories = [value.name] + else: + raise ValueError("categories must be a list") + self._track_changes.add("categories") + + def add_category(self, category): + """Adds a category to this message current categories list""" + + if isinstance(category, Category): + self.__categories.append(category.name) + else: + self.__categories.append(category) + self._track_changes.add("categories") + + @property + def importance(self): + """Importance of the message + + :getter: Get the current priority of the message + :setter: Set a different importance level + :type: str or ImportanceLevel + """ + return self.__importance + + @importance.setter + def importance(self, value): + self.__importance = ( + value + if isinstance(value, ImportanceLevel) + else ImportanceLevel.from_value(value) + ) + self._track_changes.add("importance") + + @property + def is_read_receipt_requested(self): + """if the read receipt is requested for this message + + :getter: Current state of isReadReceiptRequested + :setter: Set isReadReceiptRequested for the message + :type: bool + """ + return self.__is_read_receipt_requested + + @is_read_receipt_requested.setter + def is_read_receipt_requested(self, value): + self.__is_read_receipt_requested = bool(value) + self._track_changes.add("isReadReceiptRequested") + + @property + def is_delivery_receipt_requested(self): + """if the delivery receipt is requested for this message + + :getter: Current state of isDeliveryReceiptRequested + :setter: Set isDeliveryReceiptRequested for the message + :type: bool + """ + return self.__is_delivery_receipt_requested + + @is_delivery_receipt_requested.setter + def is_delivery_receipt_requested(self, value): + self.__is_delivery_receipt_requested = bool(value) + self._track_changes.add("isDeliveryReceiptRequested") + + @property + def meeting_message_type(self): + """If this message is a EventMessage, returns the + meeting type: meetingRequest, meetingCancelled, meetingAccepted, + meetingTentativelyAccepted, meetingDeclined + """ + return self.__meeting_message_type + + @property + def is_event_message(self): + """Returns if this message is of type EventMessage + and therefore can return the related event. + """ + return self.__meeting_message_type is not None + + @property + def flag(self): + """The Message Flag instance""" + return self.__flag + + @property + def single_value_extended_properties(self): + """singleValueExtendedProperties""" + return self.__single_value_extended_properties + + @property + def message_headers(self): + """Custom message headers + + List of internetMessageHeaders, see definition: https://learn.microsoft.com/en-us/graph/api/resources/internetmessageheader?view=graph-rest-1.0 + + :type: list[dict[str, str]] + """ + + return self.__message_headers + + @message_headers.setter + def message_headers(self, value): + if not isinstance(value, list): + raise ValueError('"message_header" must be a list') + + self.__message_headers = value + self._track_changes.add('message_headers') + + def add_message_header(self, name, value): + # Look if we already have the key. If we do, update it, otherwise write + for header in self.__message_headers: + if header["name"] == name: + header["value"] = value + return + self.__message_headers.append({"name": name, "value": value}) + + def delay_delivery(self, delay_seconds_or_absolute_datetime): + if isinstance(delay_seconds_or_absolute_datetime, int): + self.single_value_extended_properties.append({ + "id": "SystemTime 0x3FEF", + "value": (dt.datetime.now(dt.timezone.utc) + dt.timedelta(seconds=delay_seconds_or_absolute_datetime)).strftime("%Y-%m-%dT%H:%M:%SZ") + }) + else: + self.single_value_extended_properties.append({ + "id": "SystemTime 0x3FEF", + "value": delay_seconds_or_absolute_datetime + }) + + def to_api_data(self, restrict_keys=None): + """ Returns a dict representation of this message prepared to be sent + to the cloud + + :param restrict_keys: a set of keys to restrict the returned + data to + :type restrict_keys: dict or set + :return: converted to cloud based keys + :rtype: dict + """ + + cc = self._cc # alias to shorten the code + + message = { + cc('subject'): self.subject, + cc('body'): { + cc('contentType'): self.body_type, + cc('content'): self.body}, + cc('importance'): cc(self.importance.value), + cc('flag'): self.flag.to_api_data(), + cc('isReadReceiptRequested'): self.is_read_receipt_requested, + cc('isDeliveryReceiptRequested'): self.is_delivery_receipt_requested, + } + + if self.to: + message[cc('toRecipients')] = [self._recipient_to_cloud(recipient) + for recipient in self.to] + else: + message[cc("toRecipients")] = [] + if self.cc: + message[cc('ccRecipients')] = [self._recipient_to_cloud(recipient) + for recipient in self.cc] + else: + message[cc("ccRecipients")] = [] + if self.bcc: + message[cc('bccRecipients')] = [self._recipient_to_cloud(recipient) + for recipient in self.bcc] + else: + message[cc("bccRecipients")] = [] + if self.reply_to: + message[cc('replyTo')] = [self._recipient_to_cloud(recipient) for + recipient in self.reply_to] + else: + message[cc("replyTo")] = [] + if self.attachments: + message[cc('attachments')] = self.attachments.to_api_data() + if self.sender and self.sender.address: + message[cc('from')] = self._recipient_to_cloud(self.sender) + + if self.categories or 'categories' in (restrict_keys or {}): + message[cc('categories')] = self.categories + + if self.object_id and not self.__is_draft: + # return the whole signature of this message + + message[cc('id')] = self.object_id + if self.created: + message[cc('createdDateTime')] = self.created.astimezone( + dt.timezone.utc).isoformat() + if self.received: + message[cc('receivedDateTime')] = self.received.astimezone( + dt.timezone.utc).isoformat() + if self.sent: + message[cc('sentDateTime')] = self.sent.astimezone( + dt.timezone.utc).isoformat() + message[cc('hasAttachments')] = bool(self.attachments) + message[cc('isRead')] = self.is_read + message[cc('isDraft')] = self.__is_draft + message[cc('conversationId')] = self.conversation_id + # this property does not form part of the message itself + message[cc('parentFolderId')] = self.folder_id + + if self.message_headers: + message[cc('internetMessageHeaders')] = self.message_headers + + if self.single_value_extended_properties: + message[cc('singleValueExtendedProperties')] = self.single_value_extended_properties + + if restrict_keys: + for key in list(message.keys()): + if key not in restrict_keys: + del message[key] + + return message + + def send(self, save_to_sent_folder=True): + """ Sends this message + + :param bool save_to_sent_folder: whether or not to save it to + sent folder + :return: Success / Failure + :rtype: bool + """ + + if self.object_id and not self.__is_draft: + return RuntimeError('Not possible to send a message that is not ' + 'new or a draft. Use Reply or Forward instead.') + + if self.__is_draft and self.object_id: + url = self.build_url( + self._endpoints.get('send_draft').format(id=self.object_id)) + if self._track_changes: + # there are pending changes to be committed + self.save_draft() + data = None + + else: + url = self.build_url(self._endpoints.get('send_mail')) + data = {self._cc('message'): self.to_api_data()} + if save_to_sent_folder is False: + data[self._cc('saveToSentItems')] = False + + response = self.con.post(url, data=data) + # response evaluates to false if 4XX or 5XX status codes are returned + if not response: + return False + + self.object_id = 'sent_message' if not self.object_id else self.object_id + self.__is_draft = False + + return True + + def reply(self, to_all=True): + """ Creates a new message that is a reply to this message + + :param bool to_all: whether or not to replies to all the recipients + instead to just the sender + :return: new message + :rtype: Message + """ + if not self.object_id or self.__is_draft: + raise RuntimeError("Can't reply to this message") + + if to_all: + url = self.build_url(self._endpoints.get('create_reply_all').format( + id=self.object_id)) + else: + url = self.build_url( + self._endpoints.get('create_reply').format(id=self.object_id)) + + # set prefer timezone header to protocol timezone + headers = {'Prefer': self.protocol.get_service_keyword('prefer_timezone_header')} + response = self.con.post(url, headers=headers) + + if not response: + return None + + message = response.json() + + # Everything received from cloud must be passed as self._cloud_data_key + return self.__class__(parent=self, **{self._cloud_data_key: message}) + + def forward(self): + """ Creates a new message that is a forward this message + + :return: new message + :rtype: Message + """ + if not self.object_id or self.__is_draft: + raise RuntimeError("Can't forward this message") + + url = self.build_url( + self._endpoints.get('forward_message').format(id=self.object_id)) + + response = self.con.post(url) + if not response: + return None + + message = response.json() + + # Everything received from cloud must be passed as self._cloud_data_key + return self.__class__(parent=self, **{self._cloud_data_key: message}) + + def delete(self): + """ Deletes a stored message + + :return: Success / Failure + :rtype: bool + """ + if self.object_id is None: + raise RuntimeError('Attempting to delete an unsaved Message') + + url = self.build_url( + self._endpoints.get('get_message').format(id=self.object_id)) + + response = self.con.delete(url) + + return bool(response) + + def mark_as_read(self): + """ Marks this message as read in the cloud + + :return: Success / Failure + :rtype: bool + """ + if self.object_id is None or self.__is_draft: + raise RuntimeError('Attempting to mark as read an unsaved Message') + + data = {self._cc('isRead'): True} + + url = self.build_url( + self._endpoints.get('get_message').format(id=self.object_id)) + + response = self.con.patch(url, data=data) + if not response: + return False + + self.__is_read = True + + return True + + def mark_as_unread(self): + """ Marks this message as unread in the cloud + + :return: Success / Failure + :rtype: bool + """ + if self.object_id is None or self.__is_draft: + raise RuntimeError('Attempting to mark as unread an unsaved Message') + + data = {self._cc('isRead'): False} + + url = self.build_url( + self._endpoints.get('get_message').format(id=self.object_id)) + + response = self.con.patch(url, data=data) + if not response: + return False + + self.__is_read = False + + return True + + def move(self, folder): + """ Move the message to a given folder + + :param folder: Folder object or Folder id or Well-known name to + move this message to + :type folder: str or mailbox.Folder + :return: Success / Failure + :rtype: bool + """ + if self.object_id is None: + raise RuntimeError('Attempting to move an unsaved Message') + + url = self.build_url( + self._endpoints.get('move_message').format(id=self.object_id)) + + if isinstance(folder, str): + folder_id = folder + else: + folder_id = getattr(folder, 'folder_id', None) + + if not folder_id: + raise RuntimeError('Must Provide a valid folder_id') + + data = {self._cc('destinationId'): folder_id} + + response = self.con.post(url, data=data) + if not response: + return False + + message = response.json() + + self.folder_id = folder_id + self.object_id = message.get('id') + + return True + + def copy(self, folder): + """ Copy the message to a given folder + + :param folder: Folder object or Folder id or Well-known name to + copy this message to + :type folder: str or mailbox.Folder + :returns: the copied message + :rtype: Message + """ + if self.object_id is None: + raise RuntimeError('Attempting to move an unsaved Message') + + url = self.build_url( + self._endpoints.get('copy_message').format(id=self.object_id)) + + if isinstance(folder, str): + folder_id = folder + else: + folder_id = getattr(folder, 'folder_id', None) + + if not folder_id: + raise RuntimeError('Must Provide a valid folder_id') + + data = {self._cc('destinationId'): folder_id} + + response = self.con.post(url, data=data) + if not response: + return None + + message = response.json() + + # Everything received from cloud must be passed as self._cloud_data_key + return self.__class__(parent=self, **{self._cloud_data_key: message}) + + def save_message(self): + """Saves changes to a message. + If the message is a new or saved draft it will call 'save_draft' otherwise + this will save only properties of a message that are draft-independent such as: + + - is_read + - category + - flag + + :return: Success / Failure + :rtype: bool + """ + if self.object_id and not self.__is_draft: + # we are only allowed to save some properties: + allowed_changes = {self._cc('isRead'), self._cc('categories'), + self._cc('flag'), self._cc('subject')} # allowed changes to be saved by this method + changes = {tc for tc in self._track_changes if tc in allowed_changes} + + if not changes: + return True # there's nothing to update + + url = self.build_url(self._endpoints.get('get_message').format(id=self.object_id)) + + data = self.to_api_data(restrict_keys=changes) + + response = self.con.patch(url, data=data) + + if not response: + return False + + self._track_changes.clear() # reset the tracked changes as they are all saved + self.__modified = dt.datetime.now().replace(tzinfo=self.protocol.timezone) + + return True + else: + # fallback to save_draft + return self.save_draft() + + def save_draft(self, target_folder=OutlookWellKnowFolderNames.DRAFTS): + """ Save this message as a draft on the cloud + + :param target_folder: name of the drafts folder + :return: Success / Failure + :rtype: bool + """ + + if self.object_id: + # update message. Attachments are NOT included nor saved. + if not self._track_changes: + return True # there's nothing to update + url = self.build_url( + self._endpoints.get('get_message').format(id=self.object_id)) + method = self.con.patch + data = self.to_api_data(restrict_keys=self._track_changes) + + data.pop(self._cc('attachments'), + None) # attachments are handled by the next method call + # noinspection PyProtectedMember + self.attachments._update_attachments_to_cloud() + else: + # new message. Attachments are included and saved. + if not self.__is_draft: + raise RuntimeError('Only draft messages can be saved as drafts') + + target_folder = target_folder or OutlookWellKnowFolderNames.DRAFTS + if isinstance(target_folder, OutlookWellKnowFolderNames): + target_folder = target_folder.value + elif not isinstance(target_folder, str): + # a Folder instance + target_folder = getattr(target_folder, 'folder_id', + OutlookWellKnowFolderNames.DRAFTS.value) + + url = self.build_url( + self._endpoints.get('create_draft_folder').format( + id=target_folder)) + method = self.con.post + data = self.to_api_data() + + if not data: + return True + + response = method(url, data=data) + if not response: + return False + + self._track_changes.clear() # reset the tracked changes as they are all saved + + if not self.object_id: + # new message + message = response.json() + + self.object_id = message.get(self._cc('id'), None) + self.folder_id = message.get(self._cc('parentFolderId'), None) + + self.__created = message.get(self._cc('createdDateTime'),None) + self.__modified = message.get(self._cc('lastModifiedDateTime'),None) + + self.__created = parse(self.__created).astimezone( + self.protocol.timezone) if self.__created else None + self.__modified = parse(self.__modified).astimezone( + self.protocol.timezone) if self.__modified else None + + self.web_link = message.get(self._cc('webLink'), '') + else: + self.__modified = dt.datetime.now().replace(tzinfo=self.protocol.timezone) + + return True + + def get_body_text(self): + """ Parse the body html and returns the body text using bs4 + + :return: body as text + :rtype: str + """ + if self.body_type.upper() != 'HTML': + return self.body + + try: + soup = bs(self.body, 'html.parser') + except RuntimeError: + return self.body + else: + return soup.body.text + + def get_body_soup(self): + """ Returns the beautifulsoup4 of the html body + + :return: BeautifulSoup object of body + :rtype: BeautifulSoup + """ + if self.body_type.upper() != 'HTML': + return None + else: + return bs(self.body, 'html.parser') + + def get_event(self): + """ If this is a EventMessage it should return the related Event""" + + if not self.is_event_message: + return None + + # select a dummy field (eg. subject) to avoid pull unneccesary data + query = self.q().expand('event') + + url = self.build_url(self._endpoints.get('get_message').format(id=self.object_id)) + + response = self.con.get(url, params=query.as_params()) + + if not response: + return None + + data = response.json() + event_data = data.get(self._cc('event')) + + return Event(parent=self, **{self._cloud_data_key: event_data}) + + def get_mime_content(self): + """ Returns the MIME contents of this message """ + if self.object_id is None: + raise RuntimeError('Attempting to get the mime contents of an unsaved message') + + url = self.build_url(self._endpoints.get('get_mime').format(id=self.object_id)) + + response = self.con.get(url) + + if not response: + return None + + return response.content + + def save_as_eml(self, to_path=None): + """ Saves this message as and EML to the file system + :param Path or str to_path: the path where to store this file + """ + + if to_path is None: + to_path = Path('message_eml.eml') + else: + if not isinstance(to_path, Path): + to_path = Path(to_path) + + if not to_path.suffix: + to_path = to_path.with_suffix('.eml') + + mime_content = self.get_mime_content() + + if mime_content: + with to_path.open('wb') as file_obj: + file_obj.write(mime_content) + return True + return False diff --git a/O365/planner.py b/O365/planner.py new file mode 100644 index 00000000..0144ad4e --- /dev/null +++ b/O365/planner.py @@ -0,0 +1,1190 @@ +import logging +from datetime import date, datetime + +from dateutil.parser import parse + +from .utils import NEXT_LINK_KEYWORD, ApiComponent, Pagination + +log = logging.getLogger(__name__) + + +class TaskDetails(ApiComponent): + _endpoints = {"task_detail": "/planner/tasks/{id}/details"} + + def __init__(self, *, parent=None, con=None, **kwargs): + """A Microsoft 365 plan details + + :param parent: parent object + :type parent: Task + :param Connection con: connection to use if no parent specified + :param Protocol protocol: protocol to use if no parent specified + (kwargs) + :param str main_resource: use this resource instead of parent resource + (kwargs) + """ + + if parent and con: + raise ValueError("Need a parent or a connection but not both") + self.con = parent.con if parent else con + + cloud_data = kwargs.get(self._cloud_data_key, {}) + + #: ID of the task details. |br| **Type:** str + self.object_id = cloud_data.get("id") + + # Choose the main_resource passed in kwargs over parent main_resource + main_resource = kwargs.pop("main_resource", None) or ( + getattr(parent, "main_resource", None) if parent else None + ) + + main_resource = "{}{}".format(main_resource, "") + + super().__init__( + protocol=parent.protocol if parent else kwargs.get("protocol"), + main_resource=main_resource, + ) + + #: Description of the task. |br| **Type:** str + self.description = cloud_data.get(self._cc("description"), "") + #: The collection of references on the task. |br| **Type:** any + self.references = cloud_data.get(self._cc("references"), "") + #: The collection of checklist items on the task. |br| **Type:** any + self.checklist = cloud_data.get(self._cc("checklist"), "") + #: This sets the type of preview that shows up on the task. + #: The possible values are: automatic, noPreview, checklist, description, reference. + #: When set to automatic the displayed preview is chosen by the app viewing the task. + #: |br| **Type:** str + self.preview_type = cloud_data.get(self._cc("previewType"), "") + self._etag = cloud_data.get("@odata.etag", "") + + def __str__(self): + return self.__repr__() + + def __repr__(self): + return "Task Details" + + def __eq__(self, other): + return self.object_id == other.object_id + + def update(self, **kwargs): + """Updates this task detail + + :param kwargs: all the properties to be updated. + :param dict checklist: the collection of checklist items on the task. + + .. code-block:: + + e.g. checklist = { + "string GUID": { + "isChecked": bool, + "orderHint": string, + "title": string + } + } (kwargs) + + :param str description: description of the task + :param str preview_type: this sets the type of preview that shows up on the task. + + The possible values are: automatic, noPreview, checklist, description, reference. + + :param dict references: the collection of references on the task. + + .. code-block:: + + e.g. references = { + "URL of the resource" : { + "alias": string, + "previewPriority": string, #same as orderHint + "type": string, #e.g. PowerPoint, Excel, Word, Pdf... + } + } + + :return: Success / Failure + :rtype: bool + """ + if not self.object_id: + return False + + _unsafe = ".:@#" + + url = self.build_url( + self._endpoints.get("task_detail").format(id=self.object_id) + ) + + data = { + self._cc(key): value + for key, value in kwargs.items() + if key + in ( + "checklist", + "description", + "preview_type", + "references", + ) + } + if not data: + return False + + if "references" in data and isinstance(data["references"], dict): + for key in list(data["references"].keys()): + if ( + isinstance(data["references"][key], dict) + and not "@odata.type" in data["references"][key] + ): + data["references"][key]["@odata.type"] = ( + "#microsoft.graph.plannerExternalReference" + ) + + if any(u in key for u in _unsafe): + sanitized_key = "".join( + [ + chr(b) + if b not in _unsafe.encode("utf-8", "strict") + else "%{:02X}".format(b) + for b in key.encode("utf-8", "strict") + ] + ) + data["references"][sanitized_key] = data["references"].pop(key) + + if "checklist" in data: + for key in data["checklist"].keys(): + if ( + isinstance(data["checklist"][key], dict) + and not "@odata.type" in data["checklist"][key] + ): + data["checklist"][key]["@odata.type"] = ( + "#microsoft.graph.plannerChecklistItem" + ) + + response = self.con.patch( + url, + data=data, + headers={"If-Match": self._etag, "Prefer": "return=representation"}, + ) + if not response: + return False + + new_data = response.json() + + for key in data: + value = new_data.get(key, None) + if value is not None: + setattr(self, self.protocol.to_api_case(key), value) + + self._etag = new_data.get("@odata.etag") + + return True + + +class PlanDetails(ApiComponent): + _endpoints = {"plan_detail": "/planner/plans/{id}/details"} + + def __init__(self, *, parent=None, con=None, **kwargs): + """A Microsoft 365 plan details + + :param parent: parent object + :type parent: Plan + :param Connection con: connection to use if no parent specified + :param Protocol protocol: protocol to use if no parent specified + (kwargs) + :param str main_resource: use this resource instead of parent resource + (kwargs) + """ + + if parent and con: + raise ValueError("Need a parent or a connection but not both") + self.con = parent.con if parent else con + + cloud_data = kwargs.get(self._cloud_data_key, {}) + + #: The unique identifier for the plan details. |br| **Type:** str + self.object_id = cloud_data.get("id") + + # Choose the main_resource passed in kwargs over parent main_resource + main_resource = kwargs.pop("main_resource", None) or ( + getattr(parent, "main_resource", None) if parent else None + ) + + main_resource = "{}{}".format(main_resource, "") + + super().__init__( + protocol=parent.protocol if parent else kwargs.get("protocol"), + main_resource=main_resource, + ) + + #: Set of user IDs that this plan is shared with. |br| **Type:** any + self.shared_with = cloud_data.get(self._cc("sharedWith"), "") + #: An object that specifies the descriptions of the 25 categories + #: that can be associated with tasks in the plan. |br| **Type:** any + self.category_descriptions = cloud_data.get( + self._cc("categoryDescriptions"), "" + ) + self._etag = cloud_data.get("@odata.etag", "") + + def __str__(self): + return self.__repr__() + + def __repr__(self): + return "Plan Details" + + def __eq__(self, other): + return self.object_id == other.object_id + + def update(self, **kwargs): + """Updates this plan detail + + :param kwargs: all the properties to be updated. + :param dict shared_with: dict where keys are user_ids and values are boolean (kwargs) + :param dict category_descriptions: dict where keys are category1, category2, ..., category25 and values are the label associated with (kwargs) + :return: Success / Failure + :rtype: bool + """ + if not self.object_id: + return False + + url = self.build_url( + self._endpoints.get("plan_detail").format(id=self.object_id) + ) + + data = { + self._cc(key): value + for key, value in kwargs.items() + if key in ("shared_with", "category_descriptions") + } + if not data: + return False + + response = self.con.patch( + url, + data=data, + headers={"If-Match": self._etag, "Prefer": "return=representation"}, + ) + if not response: + return False + + new_data = response.json() + + for key in data: + value = new_data.get(key, None) + if value is not None: + setattr(self, self.protocol.to_api_case(key), value) + + self._etag = new_data.get("@odata.etag") + + return True + + +class Task(ApiComponent): + """A Microsoft Planner task""" + + _endpoints = { + "get_details": "/planner/tasks/{id}/details", + "task": "/planner/tasks/{id}", + } + + task_details_constructor = TaskDetails #: :meta private: + + def __init__(self, *, parent=None, con=None, **kwargs): + """A Microsoft planner task + + :param parent: parent object + :type parent: Planner or Plan or Bucket + :param Connection con: connection to use if no parent specified + :param Protocol protocol: protocol to use if no parent specified + (kwargs) + :param str main_resource: use this resource instead of parent resource + (kwargs) + """ + if parent and con: + raise ValueError("Need a parent or a connection but not both") + self.con = parent.con if parent else con + + cloud_data = kwargs.get(self._cloud_data_key, {}) + + #: ID of the task. |br| **Type:** str + self.object_id = cloud_data.get("id") + + # Choose the main_resource passed in kwargs over parent main_resource + main_resource = kwargs.pop("main_resource", None) or ( + getattr(parent, "main_resource", None) if parent else None + ) + + main_resource = "{}{}".format(main_resource, "") + + super().__init__( + protocol=parent.protocol if parent else kwargs.get("protocol"), + main_resource=main_resource, + ) + + #: Plan ID to which the task belongs. |br| **Type:** str + self.plan_id = cloud_data.get("planId") + #: Bucket ID to which the task belongs. |br| **Type:** str + self.bucket_id = cloud_data.get("bucketId") + #: Title of the task. |br| **Type:** str + self.title = cloud_data.get(self._cc("title"), "") + #: Priority of the task. |br| **Type:** int + self.priority = cloud_data.get(self._cc("priority"), "") + #: The set of assignees the task is assigned to. |br| **Type:** plannerAssignments + self.assignments = cloud_data.get(self._cc("assignments"), "") + #: Hint used to order items of this type in a list view. |br| **Type:** str + self.order_hint = cloud_data.get(self._cc("orderHint"), "") + #: Hint used to order items of this type in a list view. |br| **Type:** str + self.assignee_priority = cloud_data.get(self._cc("assigneePriority"), "") + #: Percentage of task completion. |br| **Type:** int + self.percent_complete = cloud_data.get(self._cc("percentComplete"), "") + #: Value is true if the details object of the task has a + #: nonempty description and false otherwise. |br| **Type:** bool + self.has_description = cloud_data.get(self._cc("hasDescription"), "") + created = cloud_data.get(self._cc("createdDateTime"), None) + due_date_time = cloud_data.get(self._cc("dueDateTime"), None) + start_date_time = cloud_data.get(self._cc("startDateTime"), None) + completed_date = cloud_data.get(self._cc("completedDateTime"), None) + local_tz = self.protocol.timezone + #: Date and time at which the task starts. |br| **Type:** datetime + self.start_date_time = ( + parse(start_date_time).astimezone(local_tz) if start_date_time else None + ) + #: Date and time at which the task is created. |br| **Type:** datetime + self.created_date = parse(created).astimezone(local_tz) if created else None + #: Date and time at which the task is due. |br| **Type:** datetime + self.due_date_time = ( + parse(due_date_time).astimezone(local_tz) if due_date_time else None + ) + #: Date and time at which the 'percentComplete' of the task is set to '100'. + #: |br| **Type:** datetime + self.completed_date = ( + parse(completed_date).astimezone(local_tz) if completed_date else None + ) + #: his sets the type of preview that shows up on the task. + #: The possible values are: automatic, noPreview, checklist, description, reference. + #: |br| **Type:** str + self.preview_type = cloud_data.get(self._cc("previewType"), None) + #: Number of external references that exist on the task. |br| **Type:** int + self.reference_count = cloud_data.get(self._cc("referenceCount"), None) + #: Number of checklist items that are present on the task. |br| **Type:** int + self.checklist_item_count = cloud_data.get(self._cc("checklistItemCount"), None) + #: Number of checklist items with value set to false, representing incomplete items. + #: |br| **Type:** int + self.active_checklist_item_count = cloud_data.get( + self._cc("activeChecklistItemCount"), None + ) + #: Thread ID of the conversation on the task. |br| **Type:** str + self.conversation_thread_id = cloud_data.get( + self._cc("conversationThreadId"), None + ) + #: The categories to which the task has been applied. |br| **Type:** plannerAppliedCategories + self.applied_categories = cloud_data.get(self._cc("appliedCategories"), None) + self._etag = cloud_data.get("@odata.etag", "") + + def __str__(self): + return self.__repr__() + + def __repr__(self): + return "Task: {}".format(self.title) + + def __eq__(self, other): + return self.object_id == other.object_id + + def get_details(self): + """Returns Microsoft 365/AD plan with given id + + :rtype: PlanDetails + """ + + if not self.object_id: + raise RuntimeError("Plan is not initialized correctly. Id is missing...") + + url = self.build_url( + self._endpoints.get("get_details").format(id=self.object_id) + ) + + response = self.con.get(url) + + if not response: + return None + + data = response.json() + + return self.task_details_constructor( + parent=self, + **{self._cloud_data_key: data}, + ) + + def update(self, **kwargs): + """Updates this task + + :param kwargs: all the properties to be updated. + :return: Success / Failure + :rtype: bool + """ + if not self.object_id: + return False + + url = self.build_url(self._endpoints.get("task").format(id=self.object_id)) + + for k, v in kwargs.items(): + if k in ("start_date_time", "due_date_time"): + kwargs[k] = ( + v.strftime("%Y-%m-%dT%H:%M:%SZ") + if isinstance(v, (datetime, date)) + else v + ) + + data = { + self._cc(key): value + for key, value in kwargs.items() + if key + in ( + "title", + "priority", + "assignments", + "order_hint", + "assignee_priority", + "percent_complete", + "has_description", + "start_date_time", + "created_date", + "due_date_time", + "completed_date", + "preview_type", + "reference_count", + "checklist_item_count", + "active_checklist_item_count", + "conversation_thread_id", + "applied_categories", + "bucket_id", + ) + } + if not data: + return False + + response = self.con.patch( + url, + data=data, + headers={"If-Match": self._etag, "Prefer": "return=representation"}, + ) + if not response: + return False + + new_data = response.json() + + for key in data: + value = new_data.get(key, None) + if value is not None: + setattr(self, self.protocol.to_api_case(key), value) + + self._etag = new_data.get("@odata.etag") + + return True + + def delete(self): + """Deletes this task + + :return: Success / Failure + :rtype: bool + """ + + if not self.object_id: + return False + + url = self.build_url(self._endpoints.get("task").format(id=self.object_id)) + + response = self.con.delete(url, headers={"If-Match": self._etag}) + if not response: + return False + + self.object_id = None + + return True + + +class Bucket(ApiComponent): + _endpoints = { + "list_tasks": "/planner/buckets/{id}/tasks", + "create_task": "/planner/tasks", + "bucket": "/planner/buckets/{id}", + } + task_constructor = Task #: :meta private: + + def __init__(self, *, parent=None, con=None, **kwargs): + """A Microsoft 365 bucket + + :param parent: parent object + :type parent: Planner or Plan + :param Connection con: connection to use if no parent specified + :param Protocol protocol: protocol to use if no parent specified + (kwargs) + :param str main_resource: use this resource instead of parent resource + (kwargs) + """ + + if parent and con: + raise ValueError("Need a parent or a connection but not both") + self.con = parent.con if parent else con + + cloud_data = kwargs.get(self._cloud_data_key, {}) + + #: ID of the bucket. |br| **Type:** str + self.object_id = cloud_data.get("id") + + # Choose the main_resource passed in kwargs over parent main_resource + main_resource = kwargs.pop("main_resource", None) or ( + getattr(parent, "main_resource", None) if parent else None + ) + + main_resource = "{}{}".format(main_resource, "") + + super().__init__( + protocol=parent.protocol if parent else kwargs.get("protocol"), + main_resource=main_resource, + ) + + #: Name of the bucket. |br| **Type:** str + self.name = cloud_data.get(self._cc("name"), "") + #: Hint used to order items of this type in a list view. |br| **Type:** str + self.order_hint = cloud_data.get(self._cc("orderHint"), "") + #: Plan ID to which the bucket belongs. |br| **Type:** str + self.plan_id = cloud_data.get(self._cc("planId"), "") + self._etag = cloud_data.get("@odata.etag", "") + + def __str__(self): + return self.__repr__() + + def __repr__(self): + return "Bucket: {}".format(self.name) + + def __eq__(self, other): + return self.object_id == other.object_id + + def list_tasks(self): + """Returns list of tasks that given plan has + :rtype: list[Task] + """ + + if not self.object_id: + raise RuntimeError("Bucket is not initialized correctly. Id is missing...") + + url = self.build_url( + self._endpoints.get("list_tasks").format(id=self.object_id) + ) + + response = self.con.get(url) + + if not response: + return None + + data = response.json() + + return [ + self.task_constructor(parent=self, **{self._cloud_data_key: task}) + for task in data.get("value", []) + ] + + def create_task(self, title, assignments=None, **kwargs): + """Creates a Task + + :param str title: the title of the task + :param dict assignments: the dict of users to which tasks are to be assigned. + + .. code-block:: python + + e.g. assignments = { + "ca2a1df2-e36b-4987-9f6b-0ea462f4eb47": null, + "4e98f8f1-bb03-4015-b8e0-19bb370949d8": { + "@odata.type": "microsoft.graph.plannerAssignment", + "orderHint": "String" + } + } + if "user_id": null -> task is unassigned to user. + if "user_id": dict -> task is assigned to user + + :param dict kwargs: optional extra parameters to include in the task + :param int priority: priority of the task. The valid range of values is between 0 and 10. + + 1 -> "urgent", 3 -> "important", 5 -> "medium", 9 -> "low" (kwargs) + + :param str order_hint: the order of the bucket. Default is on top (kwargs) + :param datetime or str start_date_time: the starting date of the task. If str format should be: "%Y-%m-%dT%H:%M:%SZ" (kwargs) + :param datetime or str due_date_time: the due date of the task. If str format should be: "%Y-%m-%dT%H:%M:%SZ" (kwargs) + :param str conversation_thread_id: thread ID of the conversation on the task. + + This is the ID of the conversation thread object created in the group (kwargs) + + :param str assignee_priority: hint used to order items of this type in a list view (kwargs) + :param int percent_complete: percentage of task completion. When set to 100, the task is considered completed (kwargs) + :param dict applied_categories: The categories (labels) to which the task has been applied. + + Format should be e.g. {"category1": true, "category3": true, "category5": true } should (kwargs) + + :return: newly created task + :rtype: Task + """ + if not title: + raise RuntimeError("Provide a title for the Task") + + if not self.object_id and not self.plan_id: + return None + + url = self.build_url(self._endpoints.get("create_task")) + + if not assignments: + assignments = {"@odata.type": "microsoft.graph.plannerAssignments"} + + for k, v in kwargs.items(): + if k in ("start_date_time", "due_date_time"): + kwargs[k] = ( + v.strftime("%Y-%m-%dT%H:%M:%SZ") + if isinstance(v, (datetime, date)) + else v + ) + + kwargs = { + self._cc(key): value + for key, value in kwargs.items() + if key + in ( + "priority" + "order_hint" + "assignee_priority" + "percent_complete" + "has_description" + "start_date_time" + "created_date" + "due_date_time" + "completed_date" + "preview_type" + "reference_count" + "checklist_item_count" + "active_checklist_item_count" + "conversation_thread_id" + "applied_categories" + ) + } + + data = { + "title": title, + "assignments": assignments, + "bucketId": self.object_id, + "planId": self.plan_id, + **kwargs, + } + + response = self.con.post(url, data=data) + if not response: + return None + + task = response.json() + + return self.task_constructor(parent=self, **{self._cloud_data_key: task}) + + def update(self, **kwargs): + """Updates this bucket + + :param kwargs: all the properties to be updated. + :return: Success / Failure + :rtype: bool + """ + if not self.object_id: + return False + + url = self.build_url(self._endpoints.get("bucket").format(id=self.object_id)) + + data = { + self._cc(key): value + for key, value in kwargs.items() + if key in ("name", "order_hint") + } + if not data: + return False + + response = self.con.patch( + url, + data=data, + headers={"If-Match": self._etag, "Prefer": "return=representation"}, + ) + if not response: + return False + + new_data = response.json() + + for key in data: + value = new_data.get(key, None) + if value is not None: + setattr(self, self.protocol.to_api_case(key), value) + + self._etag = new_data.get("@odata.etag") + + return True + + def delete(self): + """Deletes this bucket + + :return: Success / Failure + :rtype: bool + """ + + if not self.object_id: + return False + + url = self.build_url(self._endpoints.get("bucket").format(id=self.object_id)) + + response = self.con.delete(url, headers={"If-Match": self._etag}) + if not response: + return False + + self.object_id = None + + return True + + +class Plan(ApiComponent): + _endpoints = { + "list_buckets": "/planner/plans/{id}/buckets", + "list_tasks": "/planner/plans/{id}/tasks", + "get_details": "/planner/plans/{id}/details", + "plan": "/planner/plans/{id}", + "create_bucket": "/planner/buckets", + } + + bucket_constructor = Bucket #: :meta private: + task_constructor = Task #: :meta private: + plan_details_constructor = PlanDetails #: :meta private: + + def __init__(self, *, parent=None, con=None, **kwargs): + """A Microsoft 365 plan + + :param parent: parent object + :type parent: Planner + :param Connection con: connection to use if no parent specified + :param Protocol protocol: protocol to use if no parent specified + (kwargs) + :param str main_resource: use this resource instead of parent resource + (kwargs) + """ + + if parent and con: + raise ValueError("Need a parent or a connection but not both") + self.con = parent.con if parent else con + + cloud_data = kwargs.get(self._cloud_data_key, {}) + + #: ID of the plan. |br| **Type:** str + self.object_id = cloud_data.get("id") + + # Choose the main_resource passed in kwargs over parent main_resource + main_resource = kwargs.pop("main_resource", None) or ( + getattr(parent, "main_resource", None) if parent else None + ) + + main_resource = "{}{}".format(main_resource, "") + + super().__init__( + protocol=parent.protocol if parent else kwargs.get("protocol"), + main_resource=main_resource, + ) + + #: Date and time at which the plan is created. |br| **Type:** datetime + self.created_date_time = cloud_data.get(self._cc("createdDateTime"), "") + container = cloud_data.get(self._cc("container"), {}) + #: The identifier of the resource that contains the plan. |br| **Type:** str + self.group_id = container.get(self._cc("containerId"), "") + #: Title of the plan. |br| **Type:** str + self.title = cloud_data.get(self._cc("title"), "") + self._etag = cloud_data.get("@odata.etag", "") + + def __str__(self): + return self.__repr__() + + def __repr__(self): + return "Plan: {}".format(self.title) + + def __eq__(self, other): + return self.object_id == other.object_id + + def list_buckets(self): + """Returns list of buckets that given plan has + :rtype: list[Bucket] + """ + + if not self.object_id: + raise RuntimeError("Plan is not initialized correctly. Id is missing...") + + url = self.build_url( + self._endpoints.get("list_buckets").format(id=self.object_id) + ) + + response = self.con.get(url) + + if not response: + return None + + data = response.json() + + return [ + self.bucket_constructor(parent=self, **{self._cloud_data_key: bucket}) + for bucket in data.get("value", []) + ] + + def list_tasks(self): + """Returns list of tasks that given plan has + :rtype: list[Task] or Pagination of Task + """ + + if not self.object_id: + raise RuntimeError("Plan is not initialized correctly. Id is missing...") + + url = self.build_url( + self._endpoints.get("list_tasks").format(id=self.object_id) + ) + + response = self.con.get(url) + + if not response: + return [] + + data = response.json() + next_link = data.get(NEXT_LINK_KEYWORD, None) + + tasks = [ + self.task_constructor(parent=self, **{self._cloud_data_key: task}) + for task in data.get("value", []) + ] + + if next_link: + return Pagination( + parent=self, + data=tasks, + constructor=self.task_constructor, + next_link=next_link, + ) + else: + return tasks + + def get_details(self): + """Returns Microsoft 365/AD plan with given id + + :rtype: PlanDetails + """ + + if not self.object_id: + raise RuntimeError("Plan is not initialized correctly. Id is missing...") + + url = self.build_url( + self._endpoints.get("get_details").format(id=self.object_id) + ) + + response = self.con.get(url) + + if not response: + return None + + data = response.json() + + return self.plan_details_constructor( + parent=self, + **{self._cloud_data_key: data}, + ) + + def create_bucket(self, name, order_hint=" !"): + """Creates a Bucket + + :param str name: the name of the bucket + :param str order_hint: the order of the bucket. Default is on top. + How to use order hints here: https://docs.microsoft.com/en-us/graph/api/resources/planner-order-hint-format?view=graph-rest-1.0 + :return: newly created bucket + :rtype: Bucket + """ + + if not name: + raise RuntimeError("Provide a name for the Bucket") + + if not self.object_id: + return None + + url = self.build_url(self._endpoints.get("create_bucket")) + + data = {"name": name, "orderHint": order_hint, "planId": self.object_id} + + response = self.con.post(url, data=data) + if not response: + return None + + bucket = response.json() + + return self.bucket_constructor(parent=self, **{self._cloud_data_key: bucket}) + + def update(self, **kwargs): + """Updates this plan + + :param kwargs: all the properties to be updated. + :return: Success / Failure + :rtype: bool + """ + if not self.object_id: + return False + + url = self.build_url(self._endpoints.get("plan").format(id=self.object_id)) + + data = { + self._cc(key): value for key, value in kwargs.items() if key in ("title") + } + if not data: + return False + + response = self.con.patch( + url, + data=data, + headers={"If-Match": self._etag, "Prefer": "return=representation"}, + ) + if not response: + return False + + new_data = response.json() + + for key in data: + value = new_data.get(key, None) + if value is not None: + setattr(self, self.protocol.to_api_case(key), value) + + self._etag = new_data.get("@odata.etag") + + return True + + def delete(self): + """Deletes this plan + + :return: Success / Failure + :rtype: bool + """ + + if not self.object_id: + return False + + url = self.build_url(self._endpoints.get("plan").format(id=self.object_id)) + + response = self.con.delete(url, headers={"If-Match": self._etag}) + if not response: + return False + + self.object_id = None + + return True + + +class Planner(ApiComponent): + """A microsoft planner class + + In order to use the API following permissions are required. + Delegated (work or school account) - Group.Read.All, Group.ReadWrite.All + """ + + _endpoints = { + "get_my_tasks": "/me/planner/tasks", + "get_plan_by_id": "/planner/plans/{plan_id}", + "get_bucket_by_id": "/planner/buckets/{bucket_id}", + "get_task_by_id": "/planner/tasks/{task_id}", + "list_user_tasks": "/users/{user_id}/planner/tasks", + "list_group_plans": "/groups/{group_id}/planner/plans", + "create_plan": "/planner/plans", + } + plan_constructor = Plan #: :meta private: + bucket_constructor = Bucket #: :meta private: + task_constructor = Task #: :meta private: + + def __init__(self, *, parent=None, con=None, **kwargs): + """A Planner object + + :param parent: parent object + :type parent: Account + :param Connection con: connection to use if no parent specified + :param Protocol protocol: protocol to use if no parent specified + (kwargs) + :param str main_resource: use this resource instead of parent resource + (kwargs) + """ + if parent and con: + raise ValueError("Need a parent or a connection but not both") + self.con = parent.con if parent else con + + # Choose the main_resource passed in kwargs over the host_name + main_resource = kwargs.pop("main_resource", "") # defaults to blank resource + super().__init__( + protocol=parent.protocol if parent else kwargs.get("protocol"), + main_resource=main_resource, + ) + + def __str__(self): + return self.__repr__() + + def __repr__(self): + return "Microsoft Planner" + + def get_my_tasks(self, *args): + """Returns a list of open planner tasks assigned to me + + :rtype: tasks + """ + + url = self.build_url(self._endpoints.get("get_my_tasks")) + + response = self.con.get(url) + + if not response: + return None + + data = response.json() + + return [ + self.task_constructor(parent=self, **{self._cloud_data_key: site}) + for site in data.get("value", []) + ] + + def get_plan_by_id(self, plan_id=None): + """Returns Microsoft 365/AD plan with given id + + :param plan_id: plan id of plan + + :rtype: Plan + """ + + if not plan_id: + raise RuntimeError("Provide the plan_id") + + url = self.build_url( + self._endpoints.get("get_plan_by_id").format(plan_id=plan_id) + ) + + response = self.con.get(url) + + if not response: + return None + + data = response.json() + + return self.plan_constructor( + parent=self, + **{self._cloud_data_key: data}, + ) + + def get_bucket_by_id(self, bucket_id=None): + """Returns Microsoft 365/AD plan with given id + + :param bucket_id: bucket id of buckets + + :rtype: Bucket + """ + + if not bucket_id: + raise RuntimeError("Provide the bucket_id") + + url = self.build_url( + self._endpoints.get("get_bucket_by_id").format(bucket_id=bucket_id) + ) + + response = self.con.get(url) + + if not response: + return None + + data = response.json() + + return self.bucket_constructor(parent=self, **{self._cloud_data_key: data}) + + def get_task_by_id(self, task_id=None): + """Returns Microsoft 365/AD plan with given id + + :param task_id: task id of tasks + + :rtype: Task + """ + + if not task_id: + raise RuntimeError("Provide the task_id") + + url = self.build_url( + self._endpoints.get("get_task_by_id").format(task_id=task_id) + ) + + response = self.con.get(url) + + if not response: + return None + + data = response.json() + + return self.task_constructor(parent=self, **{self._cloud_data_key: data}) + + def list_user_tasks(self, user_id=None): + """Returns Microsoft 365/AD plan with given id + + :param user_id: user id + + :rtype: list[Task] + """ + + if not user_id: + raise RuntimeError("Provide the user_id") + + url = self.build_url( + self._endpoints.get("list_user_tasks").format(user_id=user_id) + ) + + response = self.con.get(url) + + if not response: + return None + + data = response.json() + + return [ + self.task_constructor(parent=self, **{self._cloud_data_key: task}) + for task in data.get("value", []) + ] + + def list_group_plans(self, group_id=None): + """Returns list of plans that given group has + :param group_id: group id + :rtype: list[Plan] + """ + + if not group_id: + raise RuntimeError("Provide the group_id") + + url = self.build_url( + self._endpoints.get("list_group_plans").format(group_id=group_id) + ) + + response = self.con.get(url) + + if not response: + return None + + data = response.json() + + return [ + self.plan_constructor(parent=self, **{self._cloud_data_key: plan}) + for plan in data.get("value", []) + ] + + def create_plan(self, owner, title="Tasks"): + """Creates a Plan + + :param str owner: the id of the group that will own the plan + :param str title: the title of the new plan. Default set to "Tasks" + :return: newly created plan + :rtype: Plan + """ + if not owner: + raise RuntimeError("Provide the owner (group_id)") + + url = self.build_url(self._endpoints.get("create_plan")) + + data = {"owner": owner, "title": title} + + response = self.con.post(url, data=data) + if not response: + return None + + plan = response.json() + + return self.plan_constructor(parent=self, **{self._cloud_data_key: plan}) diff --git a/O365/schedule.py b/O365/schedule.py deleted file mode 100644 index e64f17cb..00000000 --- a/O365/schedule.py +++ /dev/null @@ -1,74 +0,0 @@ -# Copyright 2015 by Toben "Narcolapser" Archer. All Rights Reserved. -# -# Permission to use, copy, modify, and distribute this software and its documentation for any purpose -# and without fee is hereby granted, provided that the above copyright notice appear in all copies and -# that both that copyright notice and this permission notice appear in supporting documentation, and -# that the name of Toben Archer not be used in advertising or publicity pertaining to distribution of -# the software without specific, written prior permission. TOBEN ARCHER DISCLAIMS ALL WARRANTIES WITH -# REGARD TO THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT -# SHALL TOBEN ARCHER BE LIABLE FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES -# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE -# OR OTHER TORTIOUS ACTION, ARISING OUT -# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. - -from O365 import Calendar -import logging -import json -import requests - -logging.basicConfig(filename='o365.log',level=logging.DEBUG) - -log = logging.getLogger(__name__) - -class Schedule( object ): - ''' - A wrapper class that handles all the Calendars associated with a sngle Office365 account. - - Methods: - constructor -- takes your email and password for authentication. - getCalendars -- begins the actual process of downloading calendars. - - Variables: - cal_url -- the url that is requested for the retrival of the calendar GUIDs. - ''' - cal_url = 'https://outlook.office365.com/EWS/OData/Me/Calendars' - - def __init__(self, email, password): - '''Creates a Schedule class for managing all calendars associated with email+password.''' - log.debug('setting up for the schedule of the email %s',email) - self.auth = (email,password) - self.calendars = [] - - - def getCalendars(self): - '''Begin the process of downloading calendar metadata.''' - log.debug('fetching calendars.') - response = requests.get(self.cal_url,auth=self.auth) - log.info('Response from O365: %s', str(response)) - - for calendar in response.json()['value']: - try: - duplicate = False - log.debug('Got a calendar with Name: {0} and Id: {1}'.format(calendar['Name'],calendar['Id'])) - for i,c in enumerate(self.calendars): - if c.json['Id'] == calendar['Id']: - c.json = calendar - c.name = calendar['Name'] - c.calendarId = calendar['Id'] - duplicate = True - log.debug('Calendar: {0} is a duplicate',calendar['Name']) - break - - if not duplicate: - self.calendars.append(Calendar(calendar,self.auth)) - log.debug('appended calendar: %s',calendar['Name']) - - log.debug('Finished with calendar {0} moving on.'.format(calendar['Name'])) - - except Exception as e: - log.info('failed to append calendar: {0}'.format(str(e))) - - log.debug('all calendars retrieved and put in to the list.') - return True - -#To the King! diff --git a/O365/sharepoint.py b/O365/sharepoint.py new file mode 100644 index 00000000..31ea10da --- /dev/null +++ b/O365/sharepoint.py @@ -0,0 +1,730 @@ +import logging + +from dateutil.parser import parse + +from .address_book import Contact +from .drive import Storage +from .utils import NEXT_LINK_KEYWORD, ApiComponent, Pagination, TrackerSet + +log = logging.getLogger(__name__) + + +class SharepointListColumn(ApiComponent): + """ A Sharepoint List column within a SharepointList """ + + _endpoints = {} + + def __init__(self, *, parent=None, con=None, **kwargs): + if parent and con: + raise ValueError('Need a parent or a connection but not both') + self.con = parent.con if parent else con + + # Choose the main_resource passed in kwargs over the parent main_resource + main_resource = kwargs.pop('main_resource', None) or ( + getattr(parent, 'main_resource', None) if parent else None) + + super().__init__(protocol=parent.protocol if parent else kwargs.get('protocol'), main_resource=main_resource) + + cloud_data = kwargs.get(self._cloud_data_key, {}) + + #: The unique identifier for the column. |br| **Type:** str + self.object_id = cloud_data.get('id') + #:For site columns, the name of the group this column belongs to. |br| **Type:** str + self.column_group = cloud_data.get(self._cc('columnGroup'), None) + #: The user-facing description of the column. |br| **Type:** str + self.description = cloud_data.get(self._cc('description'), None) + #: he user-facing name of the column. |br| **Type:** str + self.display_name = cloud_data.get(self._cc('displayName'), None) + #: If true, no two list items may have the same value for this column. |br| **Type:** bool + self.enforce_unique_values = cloud_data.get(self._cc('enforceUniqueValues'), None) + #: Specifies whether the column is displayed in the user interface. |br| **Type:** bool + self.hidden = cloud_data.get(self._cc('hidden'), None) + #: Specifies whether the column values can be used for sorting and searching. + #: |br| **Type:** bool + self.indexed = cloud_data.get(self._cc('indexed'), None) + #: The API-facing name of the column as it appears in the fields on a listItem. + #: |br| **Type:** str + self.internal_name = cloud_data.get(self._cc('name'), None) + #: Specifies whether the column values can be modified. |br| **Type:** bool + self.read_only = cloud_data.get(self._cc('readOnly'), None) + #: Specifies whether the column value isn't optional. |br| **Type:** bool + self.required = cloud_data.get(self._cc('required'), None) + + # identify the sharepoint column type and set it + # Graph api doesn't return the type for managed metadata and link column + if cloud_data.get(self._cc('text'), None) is not None: + #: Field type of the column. |br| **Type:** str + self.field_type = 'text' + elif cloud_data.get(self._cc('choice'), None) is not None: + self.field_type = 'choice' + elif cloud_data.get(self._cc('number'), None) is not None: + self.field_type = 'number' + elif cloud_data.get(self._cc('currency'), None) is not None: + self.field_type = 'currency' + elif cloud_data.get(self._cc('dateTime'), None) is not None: + self.field_type = 'dateTime' + elif cloud_data.get(self._cc('lookup'), None) is not None: + self.field_type = 'lookup' + elif cloud_data.get(self._cc('boolean'), None) is not None: + self.field_type = 'boolean' + elif cloud_data.get(self._cc('calculated'), None) is not None: + self.field_type = 'calculated' + elif cloud_data.get(self._cc('personOrGroup'), None) is not None: + self.field_type = 'personOrGroup' + else: + self.field_type = None + + def __repr__(self): + return 'List Column: {0}-{1}'.format(self.display_name, self.field_type) + + def __eq__(self, other): + return self.object_id == other.object_id + + +class SharepointListItem(ApiComponent): + _endpoints = {'update_list_item': '/items/{item_id}/fields', + 'delete_list_item': '/items/{item_id}'} + + def __init__(self, *, parent=None, con=None, **kwargs): + """ A Sharepoint ListItem within a SharepointList + + :param parent: parent object + :type parent: SharepointList + :param Connection con: connection to use if no parent specified + :param Protocol protocol: protocol to use if no parent specified + (kwargs) + :param str main_resource: use this resource instead of parent resource + (kwargs) + """ + if parent and con: + raise ValueError('Need a parent or a connection but not both') + self.con = parent.con if parent else con + self._parent = parent + + # Choose the main_resource passed in kwargs over parent main_resource + main_resource = kwargs.pop('main_resource', None) or ( + getattr(parent, 'main_resource', None) if parent else None) + + super().__init__( + protocol=parent.protocol if parent else kwargs.get('protocol'), + main_resource=main_resource) + + cloud_data = kwargs.get(self._cloud_data_key, {}) + + self._track_changes = TrackerSet(casing=self._cc) + #: The unique identifier of the item. |br| **Type:** str + self.object_id = cloud_data.get('id') + created = cloud_data.get(self._cc('createdDateTime'), None) + modified = cloud_data.get(self._cc('lastModifiedDateTime'), None) + local_tz = self.protocol.timezone + #: The date and time the item was created. |br| **Type:** datetime + self.created = parse(created).astimezone(local_tz) if created else None + #: The date and time the item was last modified. |br| **Type:** datetime + self.modified = parse(modified).astimezone(local_tz) if modified else None + + created_by = cloud_data.get(self._cc('createdBy'), {}).get('user', None) + #: Identity of the creator of this item. |br| **Type:** contact + self.created_by = Contact(con=self.con, protocol=self.protocol, + **{self._cloud_data_key: created_by}) if created_by else None + modified_by = cloud_data.get(self._cc('lastModifiedBy'), {}).get('user', None) + #: Identity of the last modifier of this item. |br| **Type:** Contact + self.modified_by = Contact(con=self.con, protocol=self.protocol, + **{self._cloud_data_key: modified_by}) if modified_by else None + + #: URL that displays the item in the browser. |br| **Type:** str + self.web_url = cloud_data.get(self._cc('webUrl'), None) + + #: The ID of the content type. |br| **Type:** str + self.content_type_id = cloud_data.get(self._cc('contentType'), {}).get('id', None) + + #: The fields of the item. |br| **Type:** any + self.fields = cloud_data.get(self._cc('fields'), None) + + def __repr__(self): + return 'List Item: {}'.format(self.web_url) + + def __eq__(self, other): + return self.object_id == other.object_id + + def _clear_tracker(self): + self._track_changes = TrackerSet(casing=self._cc) + + def _valid_field(self, field): + # Verify the used field names are valid internal field names + valid_field_names = self.fields if self.fields \ + else self._parent.column_name_cw.values() \ + if self._parent \ + else None + if valid_field_names: + return field in valid_field_names + + # If no parent is given, and no internal fields are defined assume correct, API will check + return True + + def update_fields(self, updates): + """ + Update the value for a field(s) in the listitem + + :param update: A dict of {'field name': newvalue} + """ + + for field in updates: + if self._valid_field(field): + self._track_changes.add(field) + else: + raise ValueError('"{}" is not a valid internal field name'.format(field)) + + # Update existing instance of fields, or create a fields instance if needed + if self.fields: + self.fields.update(updates) + else: + self.fields = updates + + def save_updates(self): + """Save the updated fields to the cloud""" + + if not self._track_changes: + return True # there's nothing to update + + url = self.build_url(self._endpoints.get('update_list_item').format(item_id=self.object_id)) + update = {field: value for field, value in self.fields.items() + if self._cc(field) in self._track_changes} + + response = self.con.patch(url, update) + if not response: + return False + self._clear_tracker() + return True + + def delete(self): + url = self.build_url(self._endpoints.get('delete_list_item').format(item_id=self.object_id)) + response = self.con.delete(url) + return bool(response) + + +class SharepointList(ApiComponent): + _endpoints = { + 'get_items': '/items', + 'get_item_by_id': '/items/{item_id}', + 'get_list_columns': '/columns' + } + list_item_constructor = SharepointListItem #: :meta private: + list_column_constructor = SharepointListColumn #: :meta private: + + def __init__(self, *, parent=None, con=None, **kwargs): + """ A Sharepoint site List + + :param parent: parent object + :type parent: Site + :param Connection con: connection to use if no parent specified + :param Protocol protocol: protocol to use if no parent specified + (kwargs) + :param str main_resource: use this resource instead of parent resource + (kwargs) + """ + if parent and con: + raise ValueError('Need a parent or a connection but not both') + self.con = parent.con if parent else con + + cloud_data = kwargs.get(self._cloud_data_key, {}) + + #: The ID of the content type. |br| **Type:** str + self.object_id = cloud_data.get('id') + + # Choose the main_resource passed in kwargs over parent main_resource + main_resource = kwargs.pop('main_resource', None) or ( + getattr(parent, 'main_resource', None) if parent else None) + + # prefix with the current known list + resource_prefix = '/lists/{list_id}'.format(list_id=self.object_id) + main_resource = '{}{}'.format(main_resource, resource_prefix) + + super().__init__( + protocol=parent.protocol if parent else kwargs.get('protocol'), + main_resource=main_resource) + + #: The name of the item. |br| **Type:** str + self.name = cloud_data.get(self._cc('name'), '') + #: The displayable title of the list. |br| **Type:** str + self.display_name = cloud_data.get(self._cc('displayName'), '') + if not self.name: + self.name = self.display_name + #: The descriptive text for the item. |br| **Type:** str + self.description = cloud_data.get(self._cc('description'), '') + #: URL that displays the item in the browser. |br| **Type:** str + self.web_url = cloud_data.get(self._cc('webUrl')) + + created = cloud_data.get(self._cc('createdDateTime'), None) + modified = cloud_data.get(self._cc('lastModifiedDateTime'), None) + local_tz = self.protocol.timezone + #: The date and time when the item was created. |br| **Type:** datetime + self.created = parse(created).astimezone(local_tz) if created else None + #: The date and time when the item was last modified. |br| **Type:** datetime + self.modified = parse(modified).astimezone( + local_tz) if modified else None + + created_by = cloud_data.get(self._cc('createdBy'), {}).get('user', None) + #: Identity of the creator of this item. |br| **Type:** Contact + self.created_by = (Contact(con=self.con, protocol=self.protocol, + **{self._cloud_data_key: created_by}) + if created_by else None) + modified_by = cloud_data.get(self._cc('lastModifiedBy'), {}).get('user', + None) + #: Identity of the last modifier of this item. |br| **Type:** Contact + self.modified_by = (Contact(con=self.con, protocol=self.protocol, + **{self._cloud_data_key: modified_by}) + if modified_by else None) + + # list info + lst_info = cloud_data.get('list', {}) + #: If true, indicates that content types are enabled for this list. |br| **Type:** bool + self.content_types_enabled = lst_info.get( + self._cc('contentTypesEnabled'), False) + #: If true, indicates that the list isn't normally visible in the SharePoint + #: user experience. + #: |br| **Type:** bool + self.hidden = lst_info.get(self._cc('hidden'), False) + #: An enumerated value that represents the base list template used in creating + #: the list. Possible values include documentLibrary, genericList, task, + #: survey, announcements, contacts, and more. + #: |br| **Type:** str + self.template = lst_info.get(self._cc('template'), False) + + # Crosswalk between display name of user defined columns to internal name + #: Column names |br| **Type:** dict + self.column_name_cw = {col.display_name: col.internal_name for + col in self.get_list_columns() if not col.read_only} + + def __eq__(self, other): + return self.object_id == other.object_id + + def build_field_filter(self, expand_fields): + if expand_fields == True: + return 'fields' + elif isinstance(expand_fields, list): + result = '' + for field in expand_fields: + if field in self.column_name_cw.values(): + result += field + ',' + elif field in self.column_name_cw: + result += self.column_name_cw[field] + ',' + else: + log.warning('"{}" is not a valid field name - check case'.format(field)) + if result != '': + return 'fields(select=' + result.rstrip(',') + ')' + + def get_items(self, limit=None, *, query=None, order_by=None, batch=None, expand_fields=None): + """Returns a collection of Sharepoint Items + + :param int limit: max no. of items to get. Over 999 uses batch. + :param query: applies a filter to the request. + :type query: Query or str + :param order_by: orders the result set based on this condition + :type order_by: Query or str + :param int batch: batch size, retrieves items in + batches allowing to retrieve more items than the limit. + :param expand_fields: specify user-defined fields to return, + True will return all fields + :type expand_fields: list or bool + :return: list of Sharepoint Items + :rtype: list[SharepointListItem] or Pagination + """ + + url = self.build_url(self._endpoints.get('get_items')) + + if limit is None or limit > self.protocol.max_top_value: + batch = self.protocol.max_top_value + + params = {'$top': batch if batch else limit} + + if expand_fields is not None: + params['expand'] = self.build_field_filter(expand_fields) + + if order_by: + params['$orderby'] = order_by + + if query: + if isinstance(query, str): + params['$filter'] = query + else: + params.update(query.as_params()) + + response = self.con.get(url, params=params) + + if not response: + return [] + + data = response.json() + next_link = data.get(NEXT_LINK_KEYWORD, None) + + items = [self.list_item_constructor(parent=self, **{self._cloud_data_key: item}) + for item in data.get('value', [])] + + if batch and next_link: + return Pagination(parent=self, data=items, constructor=self.list_item_constructor, + next_link=next_link, limit=limit) + else: + return items + + def get_item_by_id(self, item_id, expand_fields=None): + """Returns a sharepoint list item based on id + + :param int item_id: item id to search for + :param expand_fields: specify user-defined fields to return, + True will return all fields + :type expand_fields: list or bool + :return: Sharepoint Item + :rtype: SharepointListItem + """ + + url = self.build_url(self._endpoints.get('get_item_by_id').format(item_id=item_id)) + + params = {} + + if expand_fields is not None: + params['expand'] = self.build_field_filter(expand_fields) + + response = self.con.get(url, params=params) + + if not response: + return [] + + data = response.json() + + return self.list_item_constructor(parent=self, **{self._cloud_data_key: data}) + + def get_list_columns(self): + """ Returns the sharepoint list columns """ + + url = self.build_url(self._endpoints.get('get_list_columns')) + + response = self.con.get(url) + + if not response: + return [] + + data = response.json() + + return [self.list_column_constructor(parent=self, **{self._cloud_data_key: column}) + for column in data.get('value', [])] + + def create_list_item(self, new_data): + """Create new list item + + :param new_data: dictionary of {'col_name': col_value} + + :rtype: SharepointListItem + """ + + url = self.build_url(self._endpoints.get('get_items')) + + response = self.con.post(url, {'fields': new_data}) + if not response: + return False + + data = response.json() + + return self.list_item_constructor(parent=self, **{self._cloud_data_key: data}) + + def delete_list_item(self, item_id): + """ Delete an existing list item + + :param item_id: Id of the item to be delted + """ + + url = self.build_url(self._endpoints.get('get_item_by_id').format(item_id=item_id)) + + response = self.con.delete(url) + + return bool(response) + + +class Site(ApiComponent): + """ A Sharepoint Site """ + + _endpoints = { + 'get_subsites': '/sites', + 'get_lists': '/lists', + 'get_list_by_name': '/lists/{display_name}' + } + list_constructor = SharepointList #: :meta private: + + def __init__(self, *, parent=None, con=None, **kwargs): + """ A Sharepoint site List + + :param parent: parent object + :type parent: Sharepoint + :param Connection con: connection to use if no parent specified + :param Protocol protocol: protocol to use if no parent specified + (kwargs) + :param str main_resource: use this resource instead of parent resource + (kwargs) + """ + if parent and con: + raise ValueError('Need a parent or a connection but not both') + self.con = parent.con if parent else con + + cloud_data = kwargs.get(self._cloud_data_key, {}) + + #: The unique identifier of the item. |br| **Type:** str + self.object_id = cloud_data.get('id') + + # Choose the main_resource passed in kwargs over parent main_resource + main_resource = kwargs.pop('main_resource', None) or ( + getattr(parent, 'main_resource', None) if parent else None) + + # prefix with the current known site + resource_prefix = 'sites/{site_id}'.format(site_id=self.object_id) + main_resource = (resource_prefix if isinstance(parent, Site) + else '{}{}'.format(main_resource, resource_prefix)) + + super().__init__( + protocol=parent.protocol if parent else kwargs.get('protocol'), + main_resource=main_resource) + + #: Indicates if this is the root site. |br| **Type:** bool + self.root = 'root' in cloud_data # True or False + # Fallback to manual site + #: The name/title of the item. |br| **Type:** str + self.name = cloud_data.get(self._cc('name'), kwargs.get('name', '')) + #: The full title for the site. |br| **Type:** str + self.display_name = cloud_data.get(self._cc('displayName'), '') + if not self.name: + self.name = self.display_name + #: The descriptive text for the site. |br| **Type:** str + self.description = cloud_data.get(self._cc('description'), '') + #: URL that displays the item in the browser. |br| **Type:** str + self.web_url = cloud_data.get(self._cc('webUrl')) + + created = cloud_data.get(self._cc('createdDateTime'), None) + modified = cloud_data.get(self._cc('lastModifiedDateTime'), None) + local_tz = self.protocol.timezone + #: The date and time the item was created. |br| **Type:** datetime + self.created = parse(created).astimezone(local_tz) if created else None + #: The date and time the item was last modified. |br| **Type:** datttime + self.modified = parse(modified).astimezone( + local_tz) if modified else None + + # site storage to access Drives and DriveItems + #: The storage for the site. |br| **Type:** Storage + self.site_storage = Storage(parent=self, + main_resource='/sites/{id}'.format( + id=self.object_id)) + + def __str__(self): + return self.__repr__() + + def __repr__(self): + return 'Site: {}'.format(self.name) + + def __eq__(self, other): + return self.object_id == other.object_id + + def get_default_document_library(self, request_drive=False): + """ Returns the default document library of this site (Drive instance) + + :param request_drive: True will make an api call to retrieve + the drive data + :rtype: Drive + """ + return self.site_storage.get_default_drive(request_drive=request_drive) + + def get_document_library(self, drive_id): + """ Returns a Document Library (a Drive instance) + + :param drive_id: the drive_id to be retrieved. + :rtype: Drive + """ + return self.site_storage.get_drive(drive_id=drive_id) + + def list_document_libraries(self): + """ Returns a collection of document libraries for this site + (a collection of Drive instances) + :return: list of items in this folder + :rtype: list[Drive] or Pagination + """ + return self.site_storage.get_drives() + + def get_subsites(self): + """ Returns a list of subsites defined for this site + + :rtype: list[Site] + """ + url = self.build_url( + self._endpoints.get('get_subsites').format(id=self.object_id)) + + response = self.con.get(url) + if not response: + return [] + + data = response.json() + + # Everything received from cloud must be passed as self._cloud_data_key + return [self.__class__(parent=self, **{self._cloud_data_key: site}) for + site in data.get('value', [])] + + def get_lists(self): + """ Returns a collection of lists within this site + + :rtype: list[SharepointList] + """ + url = self.build_url(self._endpoints.get('get_lists')) + + response = self.con.get(url) + if not response: + return [] + + data = response.json() + + return [self.list_constructor(parent=self, **{self._cloud_data_key: lst}) for lst in data.get('value', [])] + + def get_list_by_name(self, display_name): + """ + Returns a sharepoint list based on the display name of the list + """ + + if not display_name: + raise ValueError('Must provide a valid list display name') + + url = self.build_url(self._endpoints.get('get_list_by_name').format(display_name=display_name)) + + response = self.con.get(url) + if not response: + return [] + + data = response.json() + + return self.list_constructor(parent=self, **{self._cloud_data_key: data}) + + def create_list(self, list_data): + """ + Creates a SharePoint list. + :param list_data: Dict representation of list. + :type list_data: Dict + :rtype: list[SharepointList] + """ + url = self.build_url(self._endpoints.get('get_lists')) + response = self.con.post(url, data=list_data) + + if not response: + return None + + data = response.json() + return self.list_constructor(parent=self, **{self._cloud_data_key: data}) + + +class Sharepoint(ApiComponent): + """ A Sharepoint parent class to group functionality """ + + _endpoints = { + 'get_site': '/sites/{id}', + 'search': '/sites?search={keyword}' + } + site_constructor = Site #: :meta private: + + def __init__(self, *, parent=None, con=None, **kwargs): + """ A Sharepoint site List + + :param parent: parent object + :type parent: Account + :param Connection con: connection to use if no parent specified + :param Protocol protocol: protocol to use if no parent specified + (kwargs) + :param str main_resource: use this resource instead of parent resource + (kwargs) + """ + if parent and con: + raise ValueError('Need a parent or a connection but not both') + self.con = parent.con if parent else con + + # Choose the main_resource passed in kwargs over the host_name + main_resource = kwargs.pop('main_resource', + '') # defaults to blank resource + super().__init__( + protocol=parent.protocol if parent else kwargs.get('protocol'), + main_resource=main_resource) + + def __str__(self): + return self.__repr__() + + def __repr__(self): + return 'Sharepoint' + + def search_site(self, keyword): + """ Search a sharepoint host for sites with the provided keyword + + :param keyword: a keyword to search sites + :rtype: list[Site] + """ + if not keyword: + raise ValueError('Must provide a valid keyword') + + next_link = self.build_url( + self._endpoints.get('search').format(keyword=keyword)) + + sites = [] + while next_link: + response = self.con.get(next_link) + if not response: + break + + data = response.json() + + # Everything received from cloud must be passed as self._cloud_data_key + sites += [ + self.site_constructor(parent=self, **{self._cloud_data_key: site}) + for site in data.get('value', []) + ] + + next_link = data.get("@odata.nextLink") + + return sites + + def get_root_site(self): + """ Returns the root site + + :rtype: Site + """ + return self.get_site('root') + + def get_site(self, *args): + """ Returns a sharepoint site + + :param args: It accepts multiple ways of retrieving a site: + + get_site(host_name): the host_name: host_name ej. + 'contoso.sharepoint.com' or 'root' + + get_site(site_id): the site_id: a comma separated string of + (host_name, site_collection_id, site_id) + + get_site(host_name, path_to_site): host_name ej. 'contoso. + sharepoint.com', path_to_site: a url path (with a leading slash) + + get_site(host_name, site_collection_id, site_id): + host_name ej. 'contoso.sharepoint.com' + :rtype: Site + """ + num_args = len(args) + if num_args == 1: + site = args[0] + elif num_args == 2: + host_name, path_to_site = args + path_to_site = '/' + path_to_site if not path_to_site.startswith( + '/') else path_to_site + site = '{}:{}:'.format(host_name, path_to_site) + elif num_args == 3: + site = ','.join(args) + else: + raise ValueError('Incorrect number of arguments') + + url = self.build_url(self._endpoints.get('get_site').format(id=site)) + + response = self.con.get(url) + if not response: + return None + + data = response.json() + + return self.site_constructor(parent=self, + **{self._cloud_data_key: data}) diff --git a/O365/subscriptions.py b/O365/subscriptions.py new file mode 100644 index 00000000..d587bcbf --- /dev/null +++ b/O365/subscriptions.py @@ -0,0 +1,280 @@ +import datetime as dt +from typing import Iterable, Mapping, Optional, Union + +from .utils import ApiComponent, NEXT_LINK_KEYWORD, Pagination + + +class Subscriptions(ApiComponent): + """Subscription operations for Microsoft Graph webhooks.""" + + _endpoints = { + "subscriptions": "/subscriptions", + } + + def __init__(self, *, parent=None, con=None, **kwargs): + if parent and con: + raise ValueError("Need a parent or a connection but not both") + self.con = parent.con if parent else con + + main_resource = kwargs.pop("main_resource", None) or ( + getattr(parent, "main_resource", None) if parent else None + ) + + super().__init__( + protocol=parent.protocol if parent else kwargs.get("protocol"), + main_resource=main_resource, + ) + + def _build_subscription_url(self, subscription_id: Optional[str] = None) -> str: + """Build the Microsoft Graph subscriptions endpoint.""" + endpoint = self._endpoints.get("subscriptions") + if endpoint is None: + raise ValueError("Subscriptions endpoint is not configured.") + base_url = self.protocol.service_url.rstrip("/") + if subscription_id: + return f"{base_url}{endpoint}/{subscription_id}" + return f"{base_url}{endpoint}" + + @staticmethod + def _format_subscription_expiration( + expiration_datetime: Optional[dt.datetime] = None, + expiration_minutes: Optional[int] = None, + ) -> str: + """Return an ISO 8601 UTC expiration string as required by Graph webhooks.""" + if expiration_datetime and expiration_minutes is not None: + raise ValueError( + "Provide either expiration_datetime or expiration_minutes, not both." + ) + if expiration_datetime is None: + minutes = expiration_minutes if expiration_minutes is not None else 60 + if minutes <= 0: + raise ValueError("expiration_minutes must be a positive integer.") + expiration_datetime = dt.datetime.now(dt.timezone.utc) + dt.timedelta( + minutes=minutes + ) + else: + if expiration_datetime.tzinfo is None: + expiration_datetime = expiration_datetime.replace(tzinfo=dt.timezone.utc) + else: + expiration_datetime = expiration_datetime.astimezone(dt.timezone.utc) + return expiration_datetime.isoformat(timespec="microseconds").replace("+00:00", "Z") + + @staticmethod + def _stringify_change_type(change_type: Union[str, Iterable[str]]) -> str: + """Normalize changeType into the comma-separated string Graph expects.""" + if isinstance(change_type, str): + value = change_type.strip() + else: + try: + parts = [str(part).strip() for part in change_type] + except TypeError as exc: + raise ValueError( + "change_type must be a string or an iterable of strings." + ) from exc + value = ",".join(part for part in parts if part) + if not value: + raise ValueError("change_type must contain at least one value.") + return value + + def get_subscription( + self, + subscription_id: str, + *, + params: Optional[Mapping[str, object]] = None, + **request_kwargs, + ) -> Optional[dict]: + """Retrieve a single webhook subscription by id.""" + if not subscription_id: + raise ValueError("subscription_id must be provided.") + if params is not None and not isinstance(params, Mapping): + raise ValueError("params must be a mapping if provided.") + + url = self._build_subscription_url(subscription_id) + response = self.con.get(url, params=params, **request_kwargs) + + if not response: + return None + + return response.json() + + def create_subscription( + self, + notification_url: str, + resource: Optional[str] = None, + change_type: Union[str, Iterable[str]] = "created", + *, + expiration_datetime: Optional[dt.datetime] = None, + expiration_minutes: Optional[int] = None, + client_state: Optional[str] = None, + include_resource_data: Optional[bool] = None, + encryption_certificate: Optional[str] = None, + encryption_certificate_id: Optional[str] = None, + lifecycle_notification_url: Optional[str] = None, + latest_supported_tls_version: Optional[str] = None, + additional_data: Optional[Mapping[str, object]] = None, + **request_kwargs, + ) -> Optional[dict]: + """Create a Microsoft Graph webhook subscription. + + See subscriptions usage documentation for webhook setup requirements. + """ + if not notification_url: + raise ValueError("notification_url must be provided.") + + resource = resource or self.main_resource + if not resource: + raise ValueError("resource must be provided.") + if not resource.startswith("/"): + resource = f"/{resource}" + + expiration_value = self._format_subscription_expiration( + expiration_datetime=expiration_datetime, + expiration_minutes=expiration_minutes, + ) + change_type_value = self._stringify_change_type(change_type) + + payload = { + self._cc("change_type"): change_type_value, + self._cc("notification_url"): notification_url, + self._cc("resource"): resource, + self._cc("expiration_date_time"): expiration_value, + } + + if client_state is not None: + payload[self._cc("client_state")] = client_state + if include_resource_data is not None: + payload[self._cc("include_resource_data")] = include_resource_data + if encryption_certificate is not None: + payload[self._cc("encryption_certificate")] = encryption_certificate + if encryption_certificate_id is not None: + payload[self._cc("encryption_certificate_id")] = encryption_certificate_id + if lifecycle_notification_url is not None: + payload[self._cc("lifecycle_notification_url")] = lifecycle_notification_url + if latest_supported_tls_version is not None: + payload[ + self._cc("latest_supported_tls_version") + ] = latest_supported_tls_version + if additional_data: + if not isinstance(additional_data, Mapping): + raise ValueError("additional_data must be a mapping if provided.") + payload.update({str(key): value for key, value in additional_data.items()}) + + url = self._build_subscription_url() + response = self.con.post(url, data=payload, **request_kwargs) + + if not response: + return None + + return response.json() + + def list_subscriptions( + self, + *, + limit: Optional[int] = None, + **request_kwargs, + ) -> Union[Iterable[dict], Pagination]: + """List webhook subscriptions visible to the current app/context.""" + if limit is not None and limit <= 0: + raise ValueError("limit must be a positive integer.") + + url = self._build_subscription_url() + response = self.con.get(url, **request_kwargs) + if not response: + return iter(()) + + data = response.json() + subscriptions = data.get("value", []) + next_link = data.get(NEXT_LINK_KEYWORD) + + if next_link: + return Pagination( + parent=self, + data=subscriptions, + next_link=next_link, + limit=limit, + ) + + if limit is not None: + return subscriptions[:limit] + + return subscriptions + + def renew_subscription( + self, + subscription_id: str, + *, + expiration_datetime: Optional[dt.datetime] = None, + expiration_minutes: Optional[int] = None, + **request_kwargs, + ) -> Optional[dict]: + """Renew an existing webhook subscription.""" + if not subscription_id: + raise ValueError("subscription_id must be provided.") + + expiration_value = self._format_subscription_expiration( + expiration_datetime=expiration_datetime, + expiration_minutes=expiration_minutes, + ) + + payload = { + self._cc("expiration_date_time"): expiration_value, + } + + url = self._build_subscription_url(subscription_id) + response = self.con.patch(url, data=payload, **request_kwargs) + + if not response: + return None + + return response.json() + + def update_subscription( + self, + subscription_id: str, + *, + notification_url: Optional[str] = None, + expiration_datetime: Optional[dt.datetime] = None, + expiration_minutes: Optional[int] = None, + **request_kwargs, + ) -> Optional[dict]: + """Update subscription fields (expiration and/or notification URL).""" + if not subscription_id: + raise ValueError("subscription_id must be provided.") + + payload = {} + + if expiration_datetime is not None or expiration_minutes is not None: + payload[self._cc("expiration_date_time")] = self._format_subscription_expiration( + expiration_datetime=expiration_datetime, + expiration_minutes=expiration_minutes, + ) + + if notification_url is not None: + if not notification_url: + raise ValueError("notification_url, if provided, cannot be empty.") + payload[self._cc("notification_url")] = notification_url + + if not payload: + raise ValueError("At least one of expiration or notification_url must be provided.") + + url = self._build_subscription_url(subscription_id) + response = self.con.patch(url, data=payload, **request_kwargs) + + if not response: + return None + + return response.json() + + def delete_subscription( + self, + subscription_id: str, + **request_kwargs, + ) -> bool: + """Delete an existing webhook subscription.""" + if not subscription_id: + raise ValueError("subscription_id must be provided.") + + url = self._build_subscription_url(subscription_id) + response = self.con.delete(url, **request_kwargs) + + return bool(response) diff --git a/O365/tasks.py b/O365/tasks.py new file mode 100644 index 00000000..56fc43a4 --- /dev/null +++ b/O365/tasks.py @@ -0,0 +1,1175 @@ +"""Methods for accessing MS Tasks/Todos via the MS Graph api.""" + +import datetime as dt +import logging + +# noinspection PyPep8Naming +from bs4 import BeautifulSoup as bs +from dateutil.parser import parse + +from .utils import ApiComponent, TrackerSet + +log = logging.getLogger(__name__) + +CONST_CHECKLIST_ITEM = "checklistitem" +CONST_CHECKLIST_ITEMS = "checklistitems" +CONST_FOLDER = "folder" +CONST_GET_CHECKLIST = "get_checklist" +CONST_GET_CHECKLISTS = "get_checklists" +CONST_GET_FOLDER = "get_folder" +CONST_GET_TASK = "get_task" +CONST_GET_TASKS = "get_tasks" +CONST_ROOT_FOLDERS = "root_folders" +CONST_ROOT_FOLDERS_DELTA = "root_folders_delta" +CONST_TASK = "task" +CONST_TASK_FOLDER = "task_folder" + + +class ChecklistItem(ApiComponent): + """A Microsoft To-Do task CheckList Item.""" + + _endpoints = { + CONST_CHECKLIST_ITEM: "/todo/lists/{folder_id}/tasks/{task_id}/checklistItems/{id}", + CONST_TASK: "/todo/lists/{folder_id}/tasks/{task_id}/checklistItems", + } + + def __init__(self, *, parent=None, con=None, **kwargs): + """Representation of a Microsoft To-Do task CheckList Item. + + :param parent: parent object + :type parent: Task + :param Connection con: connection to use if no parent specified + :param Protocol protocol: protocol to use if no parent specified + (kwargs) + :param str main_resource: use this resource instead of parent resource + (kwargs) + :param str task_id: id of the task to add this item in + (kwargs) + :param str displayName: display name of the item (kwargs) + """ + if parent and con: + raise ValueError("Need a parent or a connection but not both") + self.con = parent.con if parent else con + + cloud_data = kwargs.get(self._cloud_data_key, {}) + + # Choose the main_resource passed in kwargs over parent main_resource + main_resource = kwargs.pop("main_resource", None) or ( + getattr(parent, "main_resource", None) if parent else None + ) + + super().__init__( + protocol=parent.protocol if parent else kwargs.get("protocol"), + main_resource=main_resource, + ) + + # Choose the main_resource passed in kwargs over parent main_resource + main_resource = kwargs.pop("main_resource", None) or ( + getattr(parent, "main_resource", None) if parent else None + ) + + cc = self._cc # pylint: disable=invalid-name + # internal to know which properties need to be updated on the server + self._track_changes = TrackerSet(casing=cc) + #: Identifier of the folder of the containing task. |br| **Type:** str + self.folder_id = parent.folder_id + #: Identifier of the containing task. |br| **Type:** str + self.task_id = kwargs.get("task_id") or parent.task_id + cloud_data = kwargs.get(self._cloud_data_key, {}) + + #: Unique identifier for the item. |br| **Type:** str + self.item_id = cloud_data.get(cc("id"), None) + + self.__displayname = cloud_data.get( + cc("displayName"), kwargs.get("displayname", None) + ) + + checked_obj = cloud_data.get(cc("checkedDateTime"), {}) + self.__checked = self._parse_date_time_time_zone(checked_obj) + created_obj = cloud_data.get(cc("createdDateTime"), {}) + self.__created = self._parse_date_time_time_zone(created_obj) + + self.__is_checked = cloud_data.get(cc("isChecked"), False) + + def __str__(self): + """Representation of the Checklist Item via the Graph api as a string.""" + return self.__repr__() + + def __repr__(self): + """Representation of the Checklist Item via the Graph api.""" + marker = "x" if self.__is_checked else "o" + if self.__checked: + checked_str = ( + f"(checked: {self.__checked.date()} at {self.__checked.time()}) " + ) + else: + checked_str = "" + + return f"Checklist Item: ({marker}) {self.__displayname} {checked_str}" + + def __eq__(self, other): + """Comparison of tasks.""" + return self.item_id == other.item_id + + def to_api_data(self, restrict_keys=None): + """Return a dict to communicate with the server. + + :param restrict_keys: a set of keys to restrict the returned data to + :rtype: dict + """ + cc = self._cc # pylint: disable=invalid-name + + data = { + cc("displayName"): self.__displayname, + cc("isChecked"): self.__is_checked, + } + + if restrict_keys: + for key in list(data.keys()): + if key not in restrict_keys: + del data[key] + return data + + @property + def displayname(self): + """Return Display Name of the task. + + :type: str + """ + return self.__displayname + + @property + def created(self): + """Return Created time of the task. + + :type: datetime + """ + return self.__created + + @property + def checked(self): + """Return Checked time of the task. + + :type: datetime + """ + return self.__checked + + @property + def is_checked(self): + """Is the item checked. + + :type: bool + """ + return self.__is_checked + + def mark_checked(self): + """Mark the checklist item as checked.""" + self.__is_checked = True + self._track_changes.add(self._cc("isChecked")) + + def mark_unchecked(self): + """Mark the checklist item as unchecked.""" + self.__is_checked = False + self._track_changes.add(self._cc("isChecked")) + + def delete(self): + """Delete a stored checklist item. + + :return: Success / Failure + :rtype: bool + """ + if self.item_id is None: + raise RuntimeError("Attempting to delete an unsaved checklist item") + + url = self.build_url( + self._endpoints.get(CONST_CHECKLIST_ITEM).format( + folder_id=self.folder_id, task_id=self.task_id, id=self.item_id + ) + ) + + response = self.con.delete(url) + + return bool(response) + + def save(self): + """Create a new checklist item or update an existing one. + + Does update by checking what values have changed and update them on the server + :return: Success / Failure + :rtype: bool + """ + if self.item_id: + # update checklist item + if not self._track_changes: + return True # there's nothing to update + url = self.build_url( + self._endpoints.get(CONST_CHECKLIST_ITEM).format( + folder_id=self.folder_id, task_id=self.task_id, id=self.item_id + ) + ) + method = self.con.patch + data = self.to_api_data(restrict_keys=self._track_changes) + else: + # new task + url = self.build_url( + self._endpoints.get(CONST_TASK).format( + folder_id=self.folder_id, task_id=self.task_id + ) + ) + + method = self.con.post + data = self.to_api_data() + + response = method(url, data=data) + if not response: + return False + + self._track_changes.clear() # clear the tracked changes + item = response.json() + + if not self.item_id: + # new checklist item + self.item_id = item.get(self._cc("id"), None) + + self.__created = item.get(self._cc("createdDateTime"), None) + self.__checked = item.get(self._cc("checkedDateTime"), None) + self.__is_checked = item.get(self._cc("isChecked"), False) + + self.__created = ( + parse(self.__created).astimezone(self.protocol.timezone) + if self.__created + else None + ) + self.__checked = ( + parse(self.__checked).astimezone(self.protocol.timezone) + if self.__checked + else None + ) + else: + self.__checked = item.get(self._cc("checkedDateTime"), None) + self.__checked = ( + parse(self.__checked).astimezone(self.protocol.timezone) + if self.__checked + else None + ) + + return True + + +class Task(ApiComponent): + """A Microsoft To-Do task.""" + + _endpoints = { + CONST_GET_CHECKLIST: "/todo/lists/{folder_id}/tasks/{id}/checklistItems/{ide}", + CONST_GET_CHECKLISTS: "/todo/lists/{folder_id}/tasks/{id}/checklistItems", + CONST_TASK: "/todo/lists/{folder_id}/tasks/{id}", + CONST_TASK_FOLDER: "/todo/lists/{folder_id}/tasks", + } + checklist_item_constructor = ChecklistItem #: :meta private: + + def __init__(self, *, parent=None, con=None, **kwargs): + """Representation of a Microsoft To-Do task. + + :param parent: parent object + :type parent: Folder + :param Connection con: connection to use if no parent specified + :param Protocol protocol: protocol to use if no parent specified + (kwargs) + :param str main_resource: use this resource instead of parent resource + (kwargs) + :param str folder_id: id of the calender to add this task in + (kwargs) + :param str subject: subject of the task (kwargs) + """ + if parent and con: + raise ValueError("Need a parent or a connection but not both") + self.con = parent.con if parent else con + + cloud_data = kwargs.get(self._cloud_data_key, {}) + + # Choose the main_resource passed in kwargs over parent main_resource + main_resource = kwargs.pop("main_resource", None) or ( + getattr(parent, "main_resource", None) if parent else None + ) + + super().__init__( + protocol=parent.protocol if parent else kwargs.get("protocol"), + main_resource=main_resource, + ) + + cc = self._cc # pylint: disable=invalid-name + # internal to know which properties need to be updated on the server + self._track_changes = TrackerSet(casing=cc) + #: Identifier of the containing folder. |br| **Type:** str + self.folder_id = kwargs.get("folder_id") or parent.folder_id + cloud_data = kwargs.get(self._cloud_data_key, {}) + + #: Unique identifier for the task. |br| **Type:** str + self.task_id = cloud_data.get(cc("id"), None) + self.__subject = cloud_data.get(cc("title"), kwargs.get("subject", "") or "") + body = cloud_data.get(cc("body"), {}) + self.__body = body.get(cc("content"), "") + #: The type of the content. Possible values are text and html. |br| **Type:** str + self.body_type = body.get( + cc("contentType"), "html" + ) # default to HTML for new messages + + self.__created = cloud_data.get(cc("createdDateTime"), None) + self.__modified = cloud_data.get(cc("lastModifiedDateTime"), None) + self.__status = cloud_data.get(cc("status"), None) + self.__is_completed = self.__status == "completed" + self.__importance = cloud_data.get(cc("importance"), None) + + local_tz = self.protocol.timezone + self.__created = ( + parse(self.__created).astimezone(local_tz) if self.__created else None + ) + self.__modified = ( + parse(self.__modified).astimezone(local_tz) if self.__modified else None + ) + + due_obj = cloud_data.get(cc("dueDateTime"), {}) + self.__due = self._parse_date_time_time_zone(due_obj) + + reminder_obj = cloud_data.get(cc("reminderDateTime"), {}) + self.__reminder = self._parse_date_time_time_zone(reminder_obj) + self.__is_reminder_on = cloud_data.get(cc("isReminderOn"), False) + + completed_obj = cloud_data.get(cc("completedDateTime"), {}) + self.__completed = self._parse_date_time_time_zone(completed_obj) + + self.__checklist_items = ( + self.checklist_item_constructor(parent=self, **{self._cloud_data_key: item}) + for item in cloud_data.get(cc("checklistItems"), []) + ) + + def __str__(self): + """Representation of the Task via the Graph api as a string.""" + return self.__repr__() + + def __repr__(self): + """Representation of the Task via the Graph api.""" + marker = "x" if self.__is_completed else "o" + if self.__due: + due_str = f"(due: {self.__due.date()} at {self.__due.time()}) " + else: + due_str = "" + + if self.__completed: + compl_str = ( + f"(completed: {self.__completed.date()} at {self.__completed.time()}) " + ) + + else: + compl_str = "" + + return f"Task: ({marker}) {self.__subject} {due_str} {compl_str}" + + def __eq__(self, other): + """Comparison of tasks.""" + return self.task_id == other.task_id + + def to_api_data(self, restrict_keys=None): + """Return a dict to communicate with the server. + + :param restrict_keys: a set of keys to restrict the returned data to + :rtype: dict + """ + cc = self._cc # pylint: disable=invalid-name + + data = { + cc("title"): self.__subject, + cc("status"): "completed" if self.__is_completed else "notStarted", + } + + if self.__body: + data[cc("body")] = { + cc("contentType"): self.body_type, + cc("content"): self.__body, + } + else: + data[cc("body")] = None + + if self.__due: + data[cc("dueDateTime")] = self._build_date_time_time_zone(self.__due) + else: + data[cc("dueDateTime")] = None + + if self.__reminder: + data[cc("reminderDateTime")] = self._build_date_time_time_zone( + self.__reminder + ) + else: + data[cc("reminderDateTime")] = None + + if self.__completed: + data[cc("completedDateTime")] = self._build_date_time_time_zone( + self.__completed + ) + + if restrict_keys: + for key in list(data.keys()): + if key not in restrict_keys: + del data[key] + return data + + @property + def created(self): + """Return Created time of the task. + + :type: datetime + """ + return self.__created + + @property + def modified(self): + """Return Last modified time of the task. + + :type: datetime + """ + return self.__modified + + @property + def body(self): + """Return Body of the task. + + :getter: Get body text + :setter: Set body of task + :type: str + """ + return self.__body + + @body.setter + def body(self, value): + self.__body = value + self._track_changes.add(self._cc("body")) + + @property + def importance(self): + """Return Task importance. + + :getter: Get importance level (Low, Normal, High) + :type: str + """ + return self.__importance + + @property + def is_starred(self): + """Is the task starred (high importance). + + :getter: Check if importance is high + :type: bool + """ + return self.__importance.casefold() == "high".casefold() + + @property + def subject(self): + """Subject of the task. + + :getter: Get subject + :setter: Set subject of task + :type: str + """ + return self.__subject + + @subject.setter + def subject(self, value): + self.__subject = value + self._track_changes.add(self._cc("title")) + + @property + def due(self): + """Due Time of task. + + :getter: Get the due time + :setter: Set the due time + :type: datetime + """ + return self.__due + + @due.setter + def due(self, value): + if value: + if not isinstance(value, dt.date): + raise ValueError("'due' must be a valid datetime object") + if not isinstance(value, dt.datetime): + # force datetime + value = dt.datetime(value.year, value.month, value.day) + if value.tzinfo is None: + # localize datetime + value = value.replace(tzinfo=self.protocol.timezone) + elif value.tzinfo != self.protocol.timezone: + value = value.astimezone(self.protocol.timezone) + self.__due = value + self._track_changes.add(self._cc("dueDateTime")) + + @property + def reminder(self): + """Reminder Time of task. + + :getter: Get the reminder time + :setter: Set the reminder time + :type: datetime + """ + return self.__reminder + + @reminder.setter + def reminder(self, value): + if value: + if not isinstance(value, dt.date): + raise ValueError("'reminder' must be a valid datetime object") + if not isinstance(value, dt.datetime): + # force datetime + value = dt.datetime(value.year, value.month, value.day) + if value.tzinfo is None: + # localize datetime + value = value.replace(tzinfo=self.protocol.timezone) + elif value.tzinfo != self.protocol.timezone: + value = value.astimezone(self.protocol.timezone) + self.__reminder = value + self._track_changes.add(self._cc("reminderDateTime")) + + @property + def is_reminder_on(self): + """Return isReminderOn of the task. + + :getter: Get isReminderOn + :type: bool + """ + return self.__is_reminder_on + + @property + def status(self): + """Status of task + + :getter: Get status + :type: str + """ + return self.__status + + @property + def completed(self): + """Completed Time of task. + + :getter: Get the completed time + :setter: Set the completed time + :type: datetime + """ + return self.__completed + + @completed.setter + def completed(self, value): + if value is None: + self.mark_uncompleted() + else: + if not isinstance(value, dt.date): + raise ValueError("'completed' must be a valid datetime object") + if not isinstance(value, dt.datetime): + # force datetime + value = dt.datetime(value.year, value.month, value.day) + if value.tzinfo is None: + # localize datetime + value = value.replace(tzinfo=self.protocol.timezone) + elif value.tzinfo != self.protocol.timezone: + value = value.astimezone(self.protocol.timezone) + self.mark_completed() + + self.__completed = value + self._track_changes.add(self._cc("completedDateTime")) + + @property + def is_completed(self): + """Is task completed or not. + + :getter: Is completed + :setter: Set the task to completed + :type: bool + """ + return self.__is_completed + + @property + def checklist_items(self): + """Checklist items for the task. + + :getter: Get checklistItems + :type: list[ChecklistItem] + """ + return self.__checklist_items + + def mark_completed(self): + """Mark the task as completed.""" + self.__is_completed = True + self._track_changes.add(self._cc("status")) + + def mark_uncompleted(self): + """Mark the task as uncompleted.""" + self.__is_completed = False + self._track_changes.add(self._cc("status")) + + def delete(self): + """Delete a stored task. + + :return: Success / Failure + :rtype: bool + """ + if self.task_id is None: + raise RuntimeError("Attempting to delete an unsaved task") + + url = self.build_url( + self._endpoints.get(CONST_TASK).format( + folder_id=self.folder_id, id=self.task_id + ) + ) + + response = self.con.delete(url) + + return bool(response) + + def save(self): + """Create a new task or update an existing one. + + Does update by checking what values have changed and update them on the server + :return: Success / Failure + :rtype: bool + """ + if self.task_id: + # update task + if not self._track_changes: + return True # there's nothing to update + url = self.build_url( + self._endpoints.get(CONST_TASK).format( + folder_id=self.folder_id, id=self.task_id + ) + ) + method = self.con.patch + data = self.to_api_data(restrict_keys=self._track_changes) + else: + # new task + url = self.build_url( + self._endpoints.get(CONST_TASK_FOLDER).format(folder_id=self.folder_id) + ) + + method = self.con.post + data = self.to_api_data() + + response = method(url, data=data) + if not response: + return False + + self._track_changes.clear() # clear the tracked changes + + if not self.task_id: + # new task + task = response.json() + + self.task_id = task.get(self._cc("id"), None) + + self.__created = task.get(self._cc("createdDateTime"), None) + self.__modified = task.get(self._cc("lastModifiedDateTime"), None) + self.__completed = task.get(self._cc("completed"), None) + + self.__created = ( + parse(self.__created).astimezone(self.protocol.timezone) + if self.__created + else None + ) + self.__modified = ( + parse(self.__modified).astimezone(self.protocol.timezone) + if self.__modified + else None + ) + self.__is_completed = task.get(self._cc("status"), None) == "completed" + else: + self.__modified = dt.datetime.now().replace(tzinfo=self.protocol.timezone) + + return True + + def get_body_text(self): + """Parse the body html and returns the body text using bs4. + + :return: body text + :rtype: str + """ + if self.body_type != "html": + return self.body + + try: + soup = bs(self.body, "html.parser") + except RuntimeError: + return self.body + else: + return soup.body.text + + def get_body_soup(self): + """Return the beautifulsoup4 of the html body. + + :return: Html body + :rtype: BeautifulSoup + """ + return bs(self.body, "html.parser") if self.body_type == "html" else None + + def get_checklist_items(self, query=None, batch=None, order_by=None): + """Return list of checklist items of a specified task. + + :param query: the query string or object to query items + :param batch: the batch on to retrieve items. + :param order_by: the order clause to apply to returned items. + + :rtype: checklistItems + """ + url = self.build_url( + self._endpoints.get(CONST_GET_CHECKLISTS).format( + folder_id=self.folder_id, id=self.task_id + ) + ) + + # get checklist items by the task id + params = {} + if batch: + params["$top"] = batch + + if order_by: + params["$orderby"] = order_by + + if query: + if isinstance(query, str): + params["$filter"] = query + else: + params |= query.as_params() + + response = self.con.get(url, params=params) + + if not response: + return iter(()) + + data = response.json() + + return ( + self.checklist_item_constructor(parent=self, **{self._cloud_data_key: item}) + for item in data.get("value", []) + ) + + def get_checklist_item(self, param): + """Return a Checklist Item instance by it's id. + + :param param: an item_id or a Query instance + :return: Checklist Item for the specified info + :rtype: ChecklistItem + """ + if param is None: + return None + if isinstance(param, str): + url = self.build_url( + self._endpoints.get(CONST_GET_CHECKLIST).format( + folder_id=self.folder_id, id=self.task_id, ide=param + ) + ) + params = None + by_id = True + else: + url = self.build_url( + self._endpoints.get(CONST_GET_CHECKLISTS).format( + folder_id=self.folder_id, id=self.task_id + ) + ) + params = {"$top": 1} + params |= param.as_params() + by_id = False + + response = self.con.get(url, params=params) + + if not response: + return None + + if by_id: + item = response.json() + else: + item = response.json().get("value", []) + if item: + item = item[0] + else: + return None + return self.checklist_item_constructor( + parent=self, **{self._cloud_data_key: item} + ) + + def new_checklist_item(self, displayname=None): + """Create a checklist item within a specified task.""" + return self.checklist_item_constructor( + parent=self, displayname=displayname, task_id=self.task_id + ) + + +class Folder(ApiComponent): + """A Microsoft To-Do folder.""" + + _endpoints = { + CONST_FOLDER: "/todo/lists/{id}", + CONST_GET_TASKS: "/todo/lists/{id}/tasks", + CONST_GET_TASK: "/todo/lists/{id}/tasks/{ide}", + } + task_constructor = Task #: :meta private: + + def __init__(self, *, parent=None, con=None, **kwargs): + """Representation of a Microsoft To-Do Folder. + + :param parent: parent object + :type parent: ToDo + :param Connection con: connection to use if no parent specified + :param Protocol protocol: protocol to use if no parent specified + (kwargs) + :param str main_resource: use this resource instead of parent resource + (kwargs) + """ + if parent and con: + raise ValueError("Need a parent or a connection but not both") + self.con = parent.con if parent else con + + # Choose the main_resource passed in kwargs over parent main_resource + main_resource = kwargs.pop("main_resource", None) or ( + getattr(parent, "main_resource", None) if parent else None + ) + + super().__init__( + protocol=parent.protocol if parent else kwargs.get("protocol"), + main_resource=main_resource, + ) + + cloud_data = kwargs.get(self._cloud_data_key, {}) + + #: The name of the task list. |br| **Type:** str + self.name = cloud_data.get(self._cc("displayName"), "") + #: The identifier of the task list, unique in the user's mailbox. |br| **Type:** str + self.folder_id = cloud_data.get(self._cc("id"), None) + #: Is the `defaultList`. |br| **Type:** bool + self.is_default = False + if cloud_data.get(self._cc("wellknownListName"), "") == "defaultList": + self.is_default = True + + def __str__(self): + """Representation of the Folder via the Graph api as a string.""" + return self.__repr__() + + def __repr__(self): + """Representation of the folder via the Graph api.""" + suffix = " (default)" if self.is_default else "" + return f"Folder: {self.name}{suffix}" + + def __eq__(self, other): + """Comparison of folders.""" + return self.folder_id == other.folder_id + + def update(self): + """Update this folder. Only name can be changed. + + :return: Success / Failure + :rtype: bool + """ + if not self.folder_id: + return False + + url = self.build_url( + self._endpoints.get(CONST_FOLDER).format(id=self.folder_id) + ) + + data = { + self._cc("displayName"): self.name, + } + + response = self.con.patch(url, data=data) + + return bool(response) + + def delete(self): + """Delete this folder. + + :return: Success / Failure + :rtype: bool + """ + if not self.folder_id: + return False + + url = self.build_url( + self._endpoints.get(CONST_FOLDER).format(id=self.folder_id) + ) + + response = self.con.delete(url) + if not response: + return False + + self.folder_id = None + + return True + + def get_tasks(self, query=None, batch=None, order_by=None): + """Return list of tasks of a specified folder. + + :param query: the query string or object to query tasks + :param batch: the batch on to retrieve tasks. + :param order_by: the order clause to apply to returned tasks. + + :rtype: tasks + """ + url = self.build_url( + self._endpoints.get(CONST_GET_TASKS).format(id=self.folder_id) + ) + + # get tasks by the folder id + params = {} + if batch: + params["$top"] = batch + + if order_by: + params["$orderby"] = order_by + + if query: + if isinstance(query, str): + params["$filter"] = query + else: + params |= query.as_params() + + response = self.con.get(url, params=params) + + if not response: + return iter(()) + + data = response.json() + + return ( + self.task_constructor(parent=self, **{self._cloud_data_key: task}) + for task in data.get("value", []) + ) + + def new_task(self, subject=None): + """Create a task within a specified folder.""" + return self.task_constructor( + parent=self, subject=subject, folder_id=self.folder_id + ) + + def get_task(self, param): + """Return a Task instance by it's id. + + :param param: an task_id or a Query instance + :return: task for the specified info + :rtype: Task + """ + if param is None: + return None + if isinstance(param, str): + url = self.build_url( + self._endpoints.get(CONST_GET_TASK).format(id=self.folder_id, ide=param) + ) + params = None + by_id = True + else: + url = self.build_url( + self._endpoints.get(CONST_GET_TASKS).format(id=self.folder_id) + ) + params = {"$top": 1} + params |= param.as_params() + by_id = False + + response = self.con.get(url, params=params) + + if not response: + return None + + if by_id: + task = response.json() + else: + task = response.json().get("value", []) + if task: + task = task[0] + else: + return None + return self.task_constructor(parent=self, **{self._cloud_data_key: task}) + + +class ToDo(ApiComponent): + """A Microsoft To-Do class for MS Graph API. + + In order to use the API following permissions are required. + Delegated (work or school account) - Tasks.Read, Tasks.ReadWrite + """ + + _endpoints = { + CONST_ROOT_FOLDERS: "/todo/lists", + CONST_ROOT_FOLDERS_DELTA: "/todo/lists/delta", + CONST_GET_FOLDER: "/todo/lists/{id}", + } + + folder_constructor = Folder #: :meta private: + task_constructor = Task #: :meta private: + + def __init__(self, *, parent=None, con=None, **kwargs): + """Initialise the ToDo object. + + :param parent: parent object + :type parent: Account + :param Connection con: connection to use if no parent specified + :param Protocol protocol: protocol to use if no parent specified + (kwargs) + :param str main_resource: use this resource instead of parent resource + (kwargs) + """ + if parent and con: + raise ValueError("Need a parent or a connection but not both") + self.con = parent.con if parent else con + + # Choose the main_resource passed in kwargs over parent main_resource + main_resource = kwargs.pop("main_resource", None) or ( + getattr(parent, "main_resource", None) if parent else None + ) + + super().__init__( + protocol=parent.protocol if parent else kwargs.get("protocol"), + main_resource=main_resource, + ) + + def __str__(self): + """Representation of the ToDo via the Graph api as a string.""" + return self.__repr__() + + def __repr__(self): + """Representation of the ToDo via the Graph api as.""" + return "Microsoft To-Do" + + def _list_folders_with_url(self, url, query=None, limit=None): + """Return a list of folders using the provided url.""" + + params = {} + if limit: + params["$top"] = limit + + if query: + if isinstance(query, str): + params["$filter"] = query + else: + params |= query.as_params() + + response = self.con.get(url, params=params or None) + if not response: + return [] + + data = response.json() + + return [ + self.folder_constructor(parent=self, **{self._cloud_data_key: x}) + for x in data.get("value", []) + ] + + def list_folders(self, query=None, limit=None): + """Return a list of folders. + + To use query an order_by check the OData specification here: + https://docs.oasis-open.org/odata/odata/v4.0/errata03/os/complete/ + part2-url-conventions/odata-v4.0-errata03-os-part2-url-conventions + -complete.html + :param query: the query string or object to list folders + :param int limit: max no. of folders to get. Over 999 uses batch. + :rtype: list[Folder] + """ + url = self.build_url(self._endpoints.get(CONST_ROOT_FOLDERS)) + + return self._list_folders_with_url(url, query, limit) + + def list_folders_delta(self, query=None, limit=None): + """Return a list of folders using the delta endpoint. + + To use query an order_by check the OData specification here: + https://docs.oasis-open.org/odata/odata/v4.0/errata03/os/complete/ + part2-url-conventions/odata-v4.0-errata03-os-part2-url-conventions + -complete.html + :param query: the query string or object to list folders + :param int limit: max no. of folders to get. Over 999 uses batch. + :rtype: list[Folder] + """ + url = self.build_url(self._endpoints.get(CONST_ROOT_FOLDERS_DELTA)) + + return self._list_folders_with_url(url, query, limit) + + def new_folder(self, folder_name): + """Create a new folder. + + :param str folder_name: name of the new folder + :return: a new folder instance + :rtype: Folder + """ + if not folder_name: + return None + + url = self.build_url(self._endpoints.get(CONST_ROOT_FOLDERS)) + + response = self.con.post(url, data={self._cc("displayName"): folder_name}) + if not response: + return None + + data = response.json() + + # Everything received from cloud must be passed as self._cloud_data_key + return self.folder_constructor(parent=self, **{self._cloud_data_key: data}) + + def get_folder(self, folder_id=None, folder_name=None): + """Return a folder by it's id or name. + + :param str folder_id: the folder id to be retrieved. + :param str folder_name: the folder name to be retrieved. + :return: folder for the given info + :rtype: Folder + """ + if folder_id and folder_name: + raise RuntimeError("Provide only one of the options") + + if not folder_id and not folder_name: + raise RuntimeError("Provide one of the options") + + if folder_id: + url = self.build_url( + self._endpoints.get(CONST_GET_FOLDER).format(id=folder_id) + ) + response = self.con.get(url) + + return ( + self.folder_constructor( + parent=self, **{self._cloud_data_key: response.json()} + ) + if response + else None + ) + + query = self.new_query("displayName").equals(folder_name) + folders = self.list_folders(query=query) + return folders[0] + + def get_default_folder(self): + """Return the default folder for the current user. + + :rtype: Folder + """ + folders = self.list_folders() + for folder in folders: + if folder.is_default: + return folder + + def get_tasks(self, batch=None, order_by=None): + """Get tasks from the default Folder. + + :param order_by: orders the result set based on this condition + :param int batch: batch size, retrieves items in + batches allowing to retrieve more items than the limit. + :return: list of items in this folder + :rtype: list[Task] or Pagination + """ + default_folder = self.get_default_folder() + + return default_folder.get_tasks(order_by=order_by, batch=batch) + + def new_task(self, subject=None): + """Return a new (unsaved) Task object in the default folder. + + :param str subject: subject text for the new task + :return: new task + :rtype: Task + """ + default_folder = self.get_default_folder() + return default_folder.new_task(subject=subject) diff --git a/O365/teams.py b/O365/teams.py new file mode 100644 index 00000000..018fc990 --- /dev/null +++ b/O365/teams.py @@ -0,0 +1,1061 @@ +import logging +from enum import Enum + +from dateutil.parser import parse + +from .utils import NEXT_LINK_KEYWORD, ApiComponent, Pagination + +log = logging.getLogger(__name__) + +MAX_BATCH_CHAT_MESSAGES = 50 +MAX_BATCH_CHATS = 50 + + +class Availability(Enum): + """Valid values for Availability.""" + + AVAILABLE = "Available" + BUSY = "Busy" + AWAY = "Away" + DONOTDISTURB = "DoNotDisturb" + + +class Activity(Enum): + """Valid values for Activity.""" + + AVAILABLE = "Available" + INACALL = "InACall" + INACONFERENCECALL = "InAConferenceCall" + AWAY = "Away" + PRESENTING = "Presenting" + +class PreferredAvailability(Enum): + """Valid values for Availability.""" + + AVAILABLE = "Available" + BUSY = "Busy" + DONOTDISTURB = "DoNotDisturb" + BERIGHTBACK = "BeRightBack" + AWAY = "Away" + OFFLINE = "Offline" + + +class PreferredActivity(Enum): + """Valid values for Activity.""" + + AVAILABLE = "Available" + BUSY = "Busy" + DONOTDISTURB = "DoNotDisturb" + BERIGHTBACK = "BeRightBack" + AWAY = "Away" + OFFWORK = "OffWork" + +class ConversationMember(ApiComponent): + """ A Microsoft Teams conversation member """ + + def __init__(self, *, parent=None, con=None, **kwargs): + """ A Microsoft Teams conversation member + :param parent: parent object + :type parent: Chat + :param Connection con: connection to use if no parent specified + :param Protocol protocol: protocol to use if no parent specified (kwargs) + :param str main_resource: use this resource instead of parent resource (kwargs) + """ + if parent and con: + raise ValueError('Need a parent or a connection but not both') + self.con = parent.con if parent else con + cloud_data = kwargs.get(self._cloud_data_key, {}) + self.object_id = cloud_data.get('id') + + # Choose the main_resource passed in kwargs over parent main_resource + main_resource = kwargs.pop('main_resource', None) or ( + getattr(parent, 'main_resource', None) if parent else None) + resource_prefix = '/members/{membership_id}'.format( + membership_id=self.object_id) + main_resource = '{}{}'.format(main_resource, resource_prefix) + + super().__init__( + protocol=parent.protocol if parent else kwargs.get('protocol'), + main_resource=main_resource) + self.roles = cloud_data.get('roles') + self.display_name = cloud_data.get('displayName') + self.user_id = cloud_data.get('userId') + self.email = cloud_data.get('email') + self.tenant_id = cloud_data.get('tenantId') + + def __repr__(self): + return 'ConversationMember: {} - {}'.format(self.display_name, + self.email) + + def __str__(self): + return self.__repr__() + + +class ChatMessage(ApiComponent): + """ A Microsoft Teams chat message """ + + def __init__(self, *, parent=None, con=None, **kwargs): + """ A Microsoft Teams chat message + :param parent: parent object + :type parent: Channel, Chat, or ChannelMessage + :param Connection con: connection to use if no parent specified + :param Protocol protocol: protocol to use if no parent specified (kwargs) + :param str main_resource: use this resource instead of parent resource (kwargs) + """ + if parent and con: + raise ValueError('Need a parent or a connection but not both') + self.con = parent.con if parent else con + cloud_data = kwargs.get(self._cloud_data_key, {}) + #: Unique ID of the message. |br| **Type:** str + self.object_id = cloud_data.get('id') + + # Choose the main_resource passed in kwargs over parent main_resource + main_resource = kwargs.pop('main_resource', None) or ( + getattr(parent, 'main_resource', None) if parent else None) + + # determine proper resource prefix based on whether the message is a reply + #: ID of the parent chat message or root chat message of the thread. + #: |br| **Type:** str + self.reply_to_id = cloud_data.get('replyToId') + if self.reply_to_id: + resource_prefix = '/replies/{message_id}'.format( + message_id=self.object_id) + else: + resource_prefix = '/messages/{message_id}'.format( + message_id=self.object_id) + + main_resource = '{}{}'.format(main_resource, resource_prefix) + super().__init__( + protocol=parent.protocol if parent else kwargs.get('protocol'), + main_resource=main_resource) + + #: The type of chat message. |br| **Type:** chatMessageType + self.message_type = cloud_data.get('messageType') + #: The subject of the chat message, in plaintext. |br| **Type:** str + self.subject = cloud_data.get('subject') + #: Summary text of the chat message that could be used for + #: push notifications and summary views or fall back views. |br| **Type:** str + self.summary = cloud_data.get('summary') + #: The importance of the chat message. |br| **Type:** str + self.importance = cloud_data.get('importance') + #: Link to the message in Microsoft Teams. |br| **Type:** str + self.web_url = cloud_data.get('webUrl') + + local_tz = self.protocol.timezone + created = cloud_data.get('createdDateTime') + last_modified = cloud_data.get('lastModifiedDateTime') + last_edit = cloud_data.get('lastEditedDateTime') + deleted = cloud_data.get('deletedDateTime') + #: Timestamp of when the chat message was created. |br| **Type:** datetime + self.created_date = parse(created).astimezone( + local_tz) if created else None + #: Timestamp when the chat message is created (initial setting) + #: or modified, including when a reaction is added or removed. + #: |br| **Type:** datetime + self.last_modified_date = parse(last_modified).astimezone( + local_tz) if last_modified else None + #: Timestamp when edits to the chat message were made. + #: Triggers an "Edited" flag in the Teams UI. |br| **Type:** datetime + self.last_edited_date = parse(last_edit).astimezone( + local_tz) if last_edit else None + #: Timestamp at which the chat message was deleted, or null if not deleted. + #: |br| **Type:** datetime + self.deleted_date = parse(deleted).astimezone( + local_tz) if deleted else None + + #: If the message was sent in a chat, represents the identity of the chat. + #: |br| **Type:** str + self.chat_id = cloud_data.get('chatId') + #: If the message was sent in a channel, represents identity of the channel. + #: |br| **Type:** channelIdentity + self.channel_identity = cloud_data.get('channelIdentity') + + sent_from = cloud_data.get('from') + if sent_from: + from_key = 'user' if sent_from.get('user', None) else 'application' + from_data = sent_from.get(from_key) + else: + from_data = {} + from_key = None + + #: Id of the user or application message was sent from. + #: |br| **Type:** str + self.from_id = from_data.get('id') if sent_from else None + #: Name of the user or application message was sent from. + #: |br| **Type:** str + self.from_display_name = from_data.get('displayName', + None) if sent_from else None + #: Type of the user or application message was sent from. + #: |br| **Type:** any + self.from_type = from_data.get( + '{}IdentityType'.format(from_key)) if sent_from else None + + body = cloud_data.get('body') + #: The type of the content. Possible values are text and html. + #: |br| **Type:** bodyType + self.content_type = body.get('contentType') + #: The content of the item. |br| **Type:** str + self.content = body.get('content') + + def __repr__(self): + return 'ChatMessage: {}'.format(self.from_display_name) + + def __str__(self): + return self.__repr__() + + +class ChannelMessage(ChatMessage): + """ A Microsoft Teams chat message that is the start of a channel thread """ + _endpoints = {'get_replies': '/replies', + 'get_reply': '/replies/{message_id}'} + + message_constructor = ChatMessage #: :meta private: + + def __init__(self, **kwargs): + """ A Microsoft Teams chat message that is the start of a channel thread """ + super().__init__(**kwargs) + + cloud_data = kwargs.get(self._cloud_data_key, {}) + channel_identity = cloud_data.get('channelIdentity') + #: The identity of the channel in which the message was posted. |br| **Type:** str + self.team_id = channel_identity.get('teamId') + #: The identity of the team in which the message was posted. |br| **Type:** str + self.channel_id = channel_identity.get('channelId') + + def get_reply(self, message_id): + """ Returns a specified reply to the channel chat message + :param message_id: the message_id of the reply to retrieve + :type message_id: str or int + :rtype: ChatMessage + """ + url = self.build_url( + self._endpoints.get('get_reply').format(message_id=message_id)) + response = self.con.get(url) + + if not response: + return None + + data = response.json() + + return self.message_constructor(parent=self, + **{self._cloud_data_key: data}) + + def get_replies(self, limit=None, batch=None): + """ Returns a list of replies to the channel chat message + :param int limit: number of replies to retrieve + :param int batch: number of replies to be in each data set + :rtype: list or Pagination + """ + url = self.build_url(self._endpoints.get('get_replies')) + + if not batch and (limit is None or limit > MAX_BATCH_CHAT_MESSAGES): + batch = MAX_BATCH_CHAT_MESSAGES + + params = {'$top': batch if batch else limit} + response = self.con.get(url, params=params) + if not response: + return [] + + data = response.json() + next_link = data.get(NEXT_LINK_KEYWORD, None) + + replies = [self.message_constructor(parent=self, + **{self._cloud_data_key: reply}) + for reply in data.get('value', [])] + + if batch and next_link: + return Pagination(parent=self, data=replies, + constructor=self.message_constructor, + next_link=next_link, limit=limit) + else: + return replies + + def send_reply(self, content=None, content_type='text'): + """ Sends a reply to the channel chat message + :param content: str of text, str of html, or dict representation of json body + :type content: str or dict + :param str content_type: 'text' to render the content as text or 'html' to render the content as html + """ + data = content if isinstance(content, dict) else { + 'body': {'contentType': content_type, 'content': content}} + url = self.build_url(self._endpoints.get('get_replies')) + response = self.con.post(url, data=data) + + if not response: + return None + + data = response.json() + return self.message_constructor(parent=self, + **{self._cloud_data_key: data}) + + +class Chat(ApiComponent): + """ A Microsoft Teams chat """ + _endpoints = {'get_messages': '/messages', + 'get_message': '/messages/{message_id}', + 'get_members': '/members', + 'get_member': '/members/{membership_id}'} + + message_constructor = ChatMessage #: :meta private: + member_constructor = ConversationMember #: :meta private: + + def __init__(self, *, parent=None, con=None, **kwargs): + """ A Microsoft Teams chat + :param parent: parent object + :type parent: Teams + :param Connection con: connection to use if no parent specified + :param Protocol protocol: protocol to use if no parent specified (kwargs) + :param str main_resource: use this resource instead of parent resource (kwargs) + """ + if parent and con: + raise ValueError('Need a parent or a connection but not both') + self.con = parent.con if parent else con + + cloud_data = kwargs.get(self._cloud_data_key, {}) + #: The chat's unique identifier. |br| **Type:** str + self.object_id = cloud_data.get('id') + + # Choose the main_resource passed in kwargs over parent main_resource + main_resource = kwargs.pop('main_resource', None) or ( + getattr(parent, 'main_resource', None) if parent else None) + resource_prefix = '/chats/{chat_id}'.format(chat_id=self.object_id) + main_resource = '{}{}'.format(main_resource, resource_prefix) + super().__init__( + protocol=parent.protocol if parent else kwargs.get('protocol'), + main_resource=main_resource) + + #: Subject or topic for the chat. Only available for group chats. + #: |br| **Type:** str + self.topic = cloud_data.get('topic') + #: Specifies the type of chat. + #: Possible values are: group, oneOnOne, meeting, unknownFutureValue. + #: |br| **Type:** chatType + self.chat_type = cloud_data.get('chatType') + #: The URL for the chat in Microsoft Teams. |br| **Type:** str + self.web_url = cloud_data.get('webUrl') + created = cloud_data.get('createdDateTime') + last_update = cloud_data.get('lastUpdatedDateTime') + local_tz = self.protocol.timezone + #: Date and time at which the chat was created. |br| **Type:** datetime + self.created_date = parse(created).astimezone( + local_tz) if created else None + #: Date and time at which the chat was renamed or + #: the list of members was last changed. |br| **Type:** datetime + self.last_update_date = parse(last_update).astimezone( + local_tz) if last_update else None + + def get_messages(self, limit=None, batch=None): + """ Returns a list of chat messages from the chat + :param int limit: number of replies to retrieve + :param int batch: number of replies to be in each data set + :rtype: list[ChatMessage] or Pagination of ChatMessage + """ + url = self.build_url(self._endpoints.get('get_messages')) + + if not batch and (limit is None or limit > MAX_BATCH_CHAT_MESSAGES): + batch = MAX_BATCH_CHAT_MESSAGES + + params = {'$top': batch if batch else limit} + response = self.con.get(url, params=params) + if not response: + return [] + + data = response.json() + next_link = data.get(NEXT_LINK_KEYWORD, None) + + messages = [self.message_constructor(parent=self, + **{self._cloud_data_key: message}) + for message in data.get('value', [])] + + if batch and next_link: + return Pagination(parent=self, data=messages, + constructor=self.message_constructor, + next_link=next_link, limit=limit) + else: + return messages + + def get_message(self, message_id): + """ Returns a specified message from the chat + :param message_id: the message_id of the message to receive + :type message_id: str or int + :rtype: ChatMessage + """ + url = self.build_url( + self._endpoints.get('get_message').format(message_id=message_id)) + response = self.con.get(url) + if not response: + return None + data = response.json() + return self.message_constructor(parent=self, + **{self._cloud_data_key: data}) + + def send_message(self, content=None, content_type='text'): + """ Sends a message to the chat + :param content: str of text, str of html, or dict representation of json body + :type content: str or dict + :param str content_type: 'text' to render the content as text or 'html' to render the content as html + :rtype: ChatMessage + """ + data = content if isinstance(content, dict) else { + 'body': {'contentType': content_type, 'content': content}} + + url = self.build_url(self._endpoints.get('get_messages')) + response = self.con.post(url, data=data) + + if not response: + return None + + data = response.json() + return self.message_constructor(parent=self, + **{self._cloud_data_key: data}) + + def get_members(self): + """ Returns a list of conversation members + :rtype: list[ConversationMember] + """ + url = self.build_url(self._endpoints.get('get_members')) + response = self.con.get(url) + if not response: + return None + data = response.json() + members = [self.member_constructor(parent=self, + **{self._cloud_data_key: member}) + for member in data.get('value', [])] + return members + + def get_member(self, membership_id): + """Returns a specified conversation member + :param str membership_id: membership_id of member to retrieve + :rtype: ConversationMember + """ + url = self.build_url(self._endpoints.get('get_member').format( + membership_id=membership_id)) + response = self.con.get(url) + if not response: + return None + data = response.json() + return self.member_constructor(parent=self, + **{self._cloud_data_key: data}) + + def __repr__(self): + return 'Chat: {}'.format(self.chat_type) + + def __str__(self): + return self.__repr__() + + +class Presence(ApiComponent): + """ Microsoft Teams Presence """ + + _endpoints = {} + + def __init__(self, *, parent=None, con=None, **kwargs): + """ Microsoft Teams Presence + + :param parent: parent object + :type parent: Teams + :param Connection con: connection to use if no parent specified + :param Protocol protocol: protocol to use if no parent specified + (kwargs) + :param str main_resource: use this resource instead of parent resource + (kwargs) + """ + if parent and con: + raise ValueError('Need a parent or a connection but not both') + self.con = parent.con if parent else con + + cloud_data = kwargs.get(self._cloud_data_key, {}) + + #: The unique identifier for the user. |br| **Type:** str + self.object_id = cloud_data.get('id') + + # Choose the main_resource passed in kwargs over parent main_resource + main_resource = kwargs.pop('main_resource', None) or ( + getattr(parent, 'main_resource', None) if parent else None) + + main_resource = '{}{}'.format(main_resource, '') + + super().__init__( + protocol=parent.protocol if parent else kwargs.get('protocol'), + main_resource=main_resource) + + #: The base presence information for a user. + #: Possible values are Available, AvailableIdle, Away, BeRightBack, + #: Busy, BusyIdle, DoNotDisturb, Offline, PresenceUnknown + #: |br| **Type:** list[str] + self.availability = cloud_data.get('availability') + #: The supplemental information to a user's availability. + #: Possible values are Available, Away, BeRightBack, Busy, DoNotDisturb, + #: InACall, InAConferenceCall, Inactive, InAMeeting, Offline, OffWork, + #: OutOfOffice, PresenceUnknown, Presenting, UrgentInterruptionsOnly. + #: |br| **Type:** list[str] + self.activity = cloud_data.get('activity') + + def __str__(self): + return self.__repr__() + + def __repr__(self): + return 'availability: {}'.format(self.availability) + + def __eq__(self, other): + return self.object_id == other.object_id + + +class Channel(ApiComponent): + """ A Microsoft Teams channel """ + + _endpoints = {'get_messages': '/messages', + 'get_message': '/messages/{message_id}'} + + message_constructor = ChannelMessage #: :meta private: + + def __init__(self, *, parent=None, con=None, **kwargs): + """ A Microsoft Teams channel + + :param parent: parent object + :type parent: Teams or Team + :param Connection con: connection to use if no parent specified + :param Protocol protocol: protocol to use if no parent specified + (kwargs) + :param str main_resource: use this resource instead of parent resource + (kwargs) + """ + if parent and con: + raise ValueError('Need a parent or a connection but not both') + self.con = parent.con if parent else con + + cloud_data = kwargs.get(self._cloud_data_key, {}) + #: The channel's unique identifier. |br| **Type:** str + self.object_id = cloud_data.get('id') + + # Choose the main_resource passed in kwargs over parent main_resource + main_resource = kwargs.pop('main_resource', None) or ( + getattr(parent, 'main_resource', None) if parent else None) + + resource_prefix = '/channels/{channel_id}'.format( + channel_id=self.object_id) + main_resource = '{}{}'.format(main_resource, resource_prefix) + super().__init__( + protocol=parent.protocol if parent else kwargs.get('protocol'), + main_resource=main_resource) + + #: Channel name as it will appear to the user in Microsoft Teams. + #: |br| **Type:** str + self.display_name = cloud_data.get(self._cc('displayName'), '') + #: Optional textual description for the channel. |br| **Type:** str + self.description = cloud_data.get('description') + #: The email address for sending messages to the channel. |br| **Type:** str + self.email = cloud_data.get('email') + + def get_message(self, message_id): + """ Returns a specified channel chat messages + :param message_id: number of messages to retrieve + :type message_id: int or str + :rtype: ChannelMessage + """ + url = self.build_url( + self._endpoints.get('get_message').format(message_id=message_id)) + response = self.con.get(url) + + if not response: + return None + + data = response.json() + return self.message_constructor(parent=self, + **{self._cloud_data_key: data}) + + def get_messages(self, limit=None, batch=None): + """ Returns a list of channel chat messages + :param int limit: number of messages to retrieve + :param int batch: number of messages to be in each data set + :rtype: list[ChannelMessage] or Pagination of ChannelMessage + """ + url = self.build_url(self._endpoints.get('get_messages')) + + if not batch and (limit is None or limit > MAX_BATCH_CHAT_MESSAGES): + batch = MAX_BATCH_CHAT_MESSAGES + + params = {'$top': batch if batch else limit} + response = self.con.get(url, params=params) + if not response: + return [] + + data = response.json() + next_link = data.get(NEXT_LINK_KEYWORD, None) + + messages = [self.message_constructor(parent=self, + **{self._cloud_data_key: message}) + for message in data.get('value', [])] + + if batch and next_link: + return Pagination(parent=self, data=messages, + constructor=self.message_constructor, + next_link=next_link, limit=limit) + else: + return messages + + def send_message(self, content=None, content_type='text'): + """ Sends a message to the channel + :param content: str of text, str of html, or dict representation of json body + :type content: str or dict + :param str content_type: 'text' to render the content as text or 'html' to render the content as html + :rtype: ChannelMessage + """ + data = content if isinstance(content, dict) else { + 'body': {'contentType': content_type, 'content': content}} + + url = self.build_url(self._endpoints.get('get_messages')) + response = self.con.post(url, data=data) + + if not response: + return None + + data = response.json() + return self.message_constructor(parent=self, + **{self._cloud_data_key: data}) + + def __str__(self): + return self.__repr__() + + def __repr__(self): + return 'Channel: {}'.format(self.display_name) + + def __eq__(self, other): + return self.object_id == other.object_id + + +class Team(ApiComponent): + """ A Microsoft Teams team """ + + _endpoints = {'get_channels': '/channels', + 'get_channel': '/channels/{channel_id}'} + + channel_constructor = Channel #: :meta private: + + def __init__(self, *, parent=None, con=None, **kwargs): + """ A Microsoft Teams team + + :param parent: parent object + :type parent: Teams + :param Connection con: connection to use if no parent specified + :param Protocol protocol: protocol to use if no parent specified + (kwargs) + :param str main_resource: use this resource instead of parent resource + (kwargs) + """ + if parent and con: + raise ValueError('Need a parent or a connection but not both') + self.con = parent.con if parent else con + + cloud_data = kwargs.get(self._cloud_data_key, {}) + + #: The unique identifier of the team. |br| **Type:** str + self.object_id = cloud_data.get('id') + + # Choose the main_resource passed in kwargs over parent main_resource + main_resource = kwargs.pop('main_resource', None) or ( + getattr(parent, 'main_resource', None) if parent else None) + + resource_prefix = '/teams/{team_id}'.format(team_id=self.object_id) + main_resource = '{}{}'.format(main_resource, resource_prefix) + + super().__init__( + protocol=parent.protocol if parent else kwargs.get('protocol'), + main_resource=main_resource) + + #: The name of the team. |br| **Type:** str + self.display_name = cloud_data.get(self._cc('displayName'), '') + #: An optional description for the team. |br| **Type:** str + self.description = cloud_data.get(self._cc('description'), '') + #: Whether this team is in read-only mode. |br| **Type:** bool + self.is_archived = cloud_data.get(self._cc('isArchived'), '') + #: A hyperlink that goes to the team in the Microsoft Teams client. + #: |br| **Type:** str + self.web_url = cloud_data.get(self._cc('webUrl'), '') + + def __str__(self): + return self.__repr__() + + def __repr__(self): + return 'Team: {}'.format(self.display_name) + + def __eq__(self, other): + return self.object_id == other.object_id + + def get_channels(self): + """ Returns a list of channels the team + + :rtype: list[Channel] + """ + url = self.build_url(self._endpoints.get('get_channels')) + response = self.con.get(url) + + if not response: + return [] + + data = response.json() + + return [self.channel_constructor(parent=self, + **{self._cloud_data_key: channel}) + for channel in data.get('value', [])] + + def get_channel(self, channel_id): + """ Returns a channel of the team + + :param channel_id: the team_id of the channel to be retrieved. + + :rtype: Channel + """ + url = self.build_url(self._endpoints.get('get_channel').format(channel_id=channel_id)) + response = self.con.get(url) + + if not response: + return None + + data = response.json() + + return self.channel_constructor(parent=self, **{self._cloud_data_key: data}) + + + + +class App(ApiComponent): + """ A Microsoft Teams app """ + + _endpoints = {} + + def __init__(self, *, parent=None, con=None, **kwargs): + """ A Microsoft Teams app + + :param parent: parent object + :type parent: Teams + :param Connection con: connection to use if no parent specified + :param Protocol protocol: protocol to use if no parent specified + (kwargs) + :param str main_resource: use this resource instead of parent resource + (kwargs) + """ + if parent and con: + raise ValueError('Need a parent or a connection but not both') + self.con = parent.con if parent else con + + cloud_data = kwargs.get(self._cloud_data_key, {}) + + #: The app ID generated for the catalog is different from the developer-provided + #: ID found within the Microsoft Teams zip app package. The externalId value is + #: empty for apps with a distributionMethod type of store. When apps are + #: published to the global store, the id of the app matches the id in the app manifest. + #: |br| **Type:** str + self.object_id = cloud_data.get('id') + + # Choose the main_resource passed in kwargs over parent main_resource + main_resource = kwargs.pop('main_resource', None) or ( + getattr(parent, 'main_resource', None) if parent else None) + + main_resource = '{}{}'.format(main_resource, '') + + super().__init__( + protocol=parent.protocol if parent else kwargs.get('protocol'), + main_resource=main_resource) + + #: The details for each version of the app. |br| **Type:** list[teamsAppDefinition] + self.app_definition = cloud_data.get(self._cc('teamsAppDefinition'), + {}) + + def __str__(self): + return self.__repr__() + + def __repr__(self): + return 'App: {}'.format(self.app_definition.get('displayName')) + + def __eq__(self, other): + return self.object_id == other.object_id + + +class Teams(ApiComponent): + """ A Microsoft Teams class""" + + _endpoints = { + "get_my_presence": "/me/presence", + "get_user_presence": "/users/{user_id}/presence", + "set_my_presence": "/me/presence/setPresence", + "set_my_user_preferred_presence": "/me/presence/setUserPreferredPresence", + "get_my_teams": "/me/joinedTeams", + "get_channels": "/teams/{team_id}/channels", + "create_channel": "/teams/{team_id}/channels", + "get_channel": "/teams/{team_id}/channels/{channel_id}", + "get_apps_in_team": "/teams/{team_id}/installedApps?$expand=teamsAppDefinition", + "get_my_chats": "/me/chats" + } + presence_constructor = Presence #: :meta private: + team_constructor = Team #: :meta private: + channel_constructor = Channel #: :meta private: + app_constructor = App #: :meta private: + chat_constructor = Chat #: :meta private: + + def __init__(self, *, parent=None, con=None, **kwargs): + """ A Teams object + + :param parent: parent object + :type parent: Account + :param Connection con: connection to use if no parent specified + :param Protocol protocol: protocol to use if no parent specified + (kwargs) + :param str main_resource: use this resource instead of parent resource + (kwargs) + """ + if parent and con: + raise ValueError('Need a parent or a connection but not both') + self.con = parent.con if parent else con + + # Choose the main_resource passed in kwargs over the host_name + main_resource = kwargs.pop('main_resource', + '') # defaults to blank resource + super().__init__( + protocol=parent.protocol if parent else kwargs.get('protocol'), + main_resource=main_resource) + + def __str__(self): + return self.__repr__() + + def __repr__(self): + return 'Microsoft Teams' + + def get_my_presence(self): + """ Returns my availability and activity + + :rtype: Presence + """ + + url = self.build_url(self._endpoints.get('get_my_presence')) + + response = self.con.get(url) + + if not response: + return None + + data = response.json() + + return self.presence_constructor(parent=self, + **{self._cloud_data_key: data}) + + def set_my_presence( + self, + session_id, + availability: Availability, + activity: Activity, + expiration_duration, + ): + """Sets my presence status + + :param session_id: the session/capplication id. + :param availability: the availability. + :param activity: the activity. + :param activity: the expiration_duration when status will be unset. + :rtype: Presence + """ + + url = self.build_url(self._endpoints.get("set_my_presence")) + + data = { + "sessionId": session_id, + "availability": availability.value, + "activity": activity.value, + "expirationDutaion": expiration_duration, + } + + response = self.con.post(url, data=data) + + return self.get_my_presence() if response else None + + def set_my_user_preferred_presence( + self, + availability: PreferredAvailability, + activity: PreferredActivity, + expiration_duration, + ): + """Sets my user preferred presence status + + :param availability: the availability. + :param activity: the activity. + :param activity: the expiration_duration when status will be unset. + :rtype: Presence + """ + + url = self.build_url(self._endpoints.get("set_my_user_preferred_presence")) + + data = { + "availability": availability.value, + "activity": activity.value, + "expirationDutaion": expiration_duration, + } + + response = self.con.post(url, data=data) + + return self.get_my_presence() if response else None + + def get_user_presence(self, user_id=None, email=None): + """Returns specific user availability and activity + + :rtype: Presence + """ + + url = self.build_url( + self._endpoints.get("get_user_presence").format(user_id=user_id) + ) + + response = self.con.get(url) + + if not response: + return None + + data = response.json() + + return self.presence_constructor(parent=self, **{self._cloud_data_key: data}) + + def get_my_teams(self): + """ Returns a list of teams that I am in + + :rtype: list[Team] + """ + + url = self.build_url(self._endpoints.get('get_my_teams')) + response = self.con.get(url) + + if not response: + return [] + + data = response.json() + + return [ + self.team_constructor(parent=self, **{self._cloud_data_key: site}) + for site in data.get('value', [])] + + def get_my_chats(self, limit=None, batch=None): + """ Returns a list of chats that I am in + :param int limit: number of chats to retrieve + :param int batch: number of chats to be in each data set + :rtype: list[ChatMessage] or Pagination of Chat + """ + url = self.build_url(self._endpoints.get('get_my_chats')) + + if not batch and (limit is None or limit > MAX_BATCH_CHATS): + batch = MAX_BATCH_CHATS + + params = {'$top': batch if batch else limit} + response = self.con.get(url, params=params) + if not response: + return [] + + data = response.json() + next_link = data.get(NEXT_LINK_KEYWORD, None) + + chats = [self.chat_constructor(parent=self, + **{self._cloud_data_key: message}) + for message in data.get('value', [])] + + if batch and next_link: + return Pagination(parent=self, data=chats, + constructor=self.chat_constructor, + next_link=next_link, limit=limit) + else: + return chats + + def get_channels(self, team_id): + """ Returns a list of channels of a specified team + + :param team_id: the team_id of the channel to be retrieved. + + :rtype: list[Channel] + """ + + url = self.build_url( + self._endpoints.get('get_channels').format(team_id=team_id)) + + response = self.con.get(url) + + if not response: + return [] + + data = response.json() + + return [ + self.channel_constructor(parent=self, + **{self._cloud_data_key: channel}) + for channel in data.get('value', [])] + + def create_channel(self, team_id, display_name, description=None): + """ Creates a channel within a specified team + + :param team_id: the team_id where the channel is created. + :param display_name: the channel display name. + :param description: the channel description. + :rtype: Channel + """ + + url = self.build_url( + self._endpoints.get('get_channels').format(team_id=team_id)) + + if description: + data = { + 'displayName': display_name, + 'description': description, + } + else: + data = { + 'displayName': display_name, + } + + response = self.con.post(url, data=data) + + if not response: + return None + + data = response.json() + + return self.channel_constructor(parent=self, + **{self._cloud_data_key: data}) + + def get_channel(self, team_id, channel_id): + """ Returns the channel info for a given channel + + :param team_id: the team_id of the channel. + :param channel_id: the channel_id of the channel. + + :rtype: list[Channel] + """ + + url = self.build_url( + self._endpoints.get('get_channel').format(team_id=team_id, + channel_id=channel_id)) + + response = self.con.get(url) + + if not response: + return None + + data = response.json() + + return self.channel_constructor(parent=self, + **{self._cloud_data_key: data}) + + def get_apps_in_team(self, team_id): + """ Returns a list of apps of a specified team + + :param team_id: the team_id of the team to get the apps of. + + :rtype: list[App] + """ + + url = self.build_url( + self._endpoints.get('get_apps_in_team').format(team_id=team_id)) + response = self.con.get(url) + + if not response: + return [] + + data = response.json() + + return [ + self.app_constructor(parent=self, **{self._cloud_data_key: site}) + for site in data.get('value', [])] diff --git a/O365/utils/__init__.py b/O365/utils/__init__.py new file mode 100644 index 00000000..c38ca4a0 --- /dev/null +++ b/O365/utils/__init__.py @@ -0,0 +1,12 @@ +from .attachment import BaseAttachments, BaseAttachment, AttachableMixin +from .utils import ApiComponent, OutlookWellKnowFolderNames +from .utils import CaseEnum, ImportanceLevel, TrackerSet +from .utils import Recipient, Recipients, HandleRecipientsMixin +from .utils import NEXT_LINK_KEYWORD, ME_RESOURCE, USERS_RESOURCE +from .utils import OneDriveWellKnowFolderNames, Pagination +from .token import BaseTokenBackend, FileSystemTokenBackend, FirestoreBackend, AWSS3Backend, AWSSecretsBackend, EnvTokenBackend, BitwardenSecretsManagerBackend, DjangoTokenBackend +from .range import col_index_to_label +from .windows_tz import get_iana_tz, get_windows_tz +from .consent import consent_input_token +from .casing import to_snake_case, to_pascal_case, to_camel_case +from .query import QueryBuilder, CompositeFilter diff --git a/O365/utils/attachment.py b/O365/utils/attachment.py new file mode 100644 index 00000000..11d5d025 --- /dev/null +++ b/O365/utils/attachment.py @@ -0,0 +1,594 @@ +import base64 +import logging +from io import BytesIO +from pathlib import Path + +from .utils import ApiComponent + +log = logging.getLogger(__name__) + +UPLOAD_SIZE_LIMIT_SIMPLE = 1024 * 1024 * 3 # 3 MB +DEFAULT_UPLOAD_CHUNK_SIZE = 1024 * 1024 * 3 + + +class AttachableMixin: + def __init__(self, attachment_name_property=None, attachment_type=None): + """ Defines the functionality for an object to be attachable. + Any object that inherits from this class will be attachable + (if the underlying api allows that) + + """ + self.__attachment_name = None + self.__attachment_name_property = attachment_name_property + self.__attachment_type = self._gk(attachment_type) + + @property + def attachment_name(self): + """ Name of the attachment + + :getter: get attachment name + :setter: set new name for the attachment + :type: str + """ + if self.__attachment_name is not None: + return self.__attachment_name + if self.__attachment_name_property: + return getattr(self, self.__attachment_name_property, '') + else: + # property order resolution: + # 1) try property 'subject' + # 2) try property 'name' + try: + attachment_name = getattr(self, 'subject') + except AttributeError: + attachment_name = getattr(self, 'name', '') + return attachment_name + + @attachment_name.setter + def attachment_name(self, value): + self.__attachment_name = value + + @property + def attachment_type(self): + """ Type of attachment + + :rtype: str + """ + return self.__attachment_type + + def to_api_data(self): + """ Returns a dict to communicate with the server + + :rtype: dict + """ + raise NotImplementedError() + + +class UploadSessionRequest(ApiComponent): + + def __init__(self, parent, attachment): + super().__init__(protocol=parent.protocol, + main_resource=parent.main_resource) + self._attachment = attachment + + def to_api_data(self): + attachment_item = { + self._cc('attachmentType'): self._attachment.attachment_type, + self._cc('name'): self._attachment.name, + self._cc('size'): self._attachment.size + } + if self._attachment.is_inline: + attachment_item[self._cc('isInline')] = self._attachment.is_inline + data = {self._cc('AttachmentItem'): attachment_item} + return data + + +class BaseAttachment(ApiComponent): + """ BaseAttachment class is the base object for dealing with attachments """ + + _endpoints = {'attach': '/messages/{id}/attachments'} + + def __init__(self, attachment=None, *, parent=None, **kwargs): + """ Creates a new attachment, optionally from existing cloud data + + :param attachment: attachment data (dict = cloud data, + other = user data) + :type attachment: dict or str or Path or list[str] or AttachableMixin + :param BaseAttachments parent: the parent Attachments + :param Protocol protocol: protocol to use if no parent specified + (kwargs) + :param str main_resource: use this resource instead of parent resource + (kwargs) + """ + kwargs.setdefault('protocol', getattr(parent, 'protocol', None)) + kwargs.setdefault('main_resource', + getattr(parent, 'main_resource', None)) + + super().__init__(**kwargs) + #: The attachment's file name. |br| **Type:** str + self.name = None + #: The attachment's type. Default 'file' |br| **Type:** str + self.attachment_type = 'file' + #: The attachment's id. Default 'file' |br| **Type:** str + self.attachment_id = None + #: The attachment's content id Default 'file'. |br| **Type:** str + self.content_id = None + #: true if the attachment is an inline attachment; otherwise, false. |br| **Type:** bool + self.is_inline = False + #: Path to the attachment if on disk |br| **Type:** Path + self.attachment = None + #: Content of the attachment |br| **Type:** any + self.content = None + #: Indicates if the attachment is stored on disk. |br| **Type:** bool + self.on_disk = False + #: Indicates if the attachment is stored on cloud. |br| **Type:** bool + self.on_cloud = kwargs.get('on_cloud', False) + self.size = None + + if attachment: + if isinstance(attachment, dict): + if self._cloud_data_key in attachment: + # data from the cloud + attachment = attachment.get(self._cloud_data_key) + self.attachment_id = attachment.get(self._cc('id'), None) + self.content_id = attachment.get(self._cc('contentId'), None) + self.is_inline = attachment.get(self._cc('IsInline'), False) + self.name = attachment.get(self._cc('name'), None) + self.content = attachment.get(self._cc('contentBytes'), + None) + self.attachment_type = 'item' if 'item' in attachment.get( + '@odata.type', '').lower() else 'file' + self.on_disk = False + self.size = attachment.get(self._cc('size'), None) + else: + file_path = attachment.get('path', attachment.get('name')) + if file_path is None: + raise ValueError('Must provide a valid "path" or ' + '"name" for the attachment') + self.content = attachment.get('content') + self.on_disk = attachment.get('on_disk') + self.attachment_id = attachment.get('attachment_id') + self.attachment = Path(file_path) if self.on_disk else None + self.name = (self.attachment.name if self.on_disk + else attachment.get('name')) + self.size = self.attachment.stat().st_size if self.attachment else None + + elif isinstance(attachment, str): + self.attachment = Path(attachment) + self.name = self.attachment.name + elif isinstance(attachment, Path): + self.attachment = attachment + self.name = self.attachment.name + elif isinstance(attachment, (tuple, list)): + # files with custom names or Inmemory objects + file_obj, custom_name = attachment + if isinstance(file_obj, BytesIO): + # in memory objects + self.size = file_obj.getbuffer().nbytes + self.content = base64.b64encode(file_obj.getvalue()).decode('utf-8') + else: + self.attachment = Path(file_obj) + self.name = custom_name + + elif isinstance(attachment, AttachableMixin): + # Object that can be attached (Message for example) + self.attachment_type = 'item' + self.attachment = attachment + self.name = attachment.attachment_name + self.content = attachment.to_api_data() + self.content['@odata.type'] = attachment.attachment_type + + if self.content is None and self.attachment and self.attachment.exists(): + with self.attachment.open('rb') as file: + self.content = base64.b64encode(file.read()).decode('utf-8') + self.on_disk = True + self.size = self.attachment.stat().st_size + + def __len__(self): + """ Returns the size of this attachment """ + return self.size + + def __eq__(self, other): + return self.attachment_id == other.attachment_id + + def to_api_data(self): + """ Returns a dict to communicate with the server + + :rtype: dict + """ + data = {'@odata.type': self._gk( + '{}_attachment_type'.format(self.attachment_type)), + self._cc('name'): self.name} + + if self.is_inline: + data[self._cc('isInline')] = self.is_inline + if self.attachment_type == 'file': + data[self._cc('contentBytes')] = self.content + if self.content_id is not None: + data[self._cc('contentId')] = self.content_id + else: + data[self._cc('item')] = self.content + + return data + + def save(self, location=None, custom_name=None): + """ Save the attachment locally to disk + + :param str location: path string to where the file is to be saved. + :param str custom_name: a custom name to be saved as + :return: Success / Failure + :rtype: bool + """ + if not self.content: + return False + + location = Path(location or '') + if not location.exists(): + log.debug('the location provided does not exist') + return False + + name = custom_name or self.name + name = name.replace('/', '-').replace('\\', '') + try: + path = location / name + with path.open('wb') as file: + file.write(base64.b64decode(self.content)) + self.attachment = path + self.on_disk = True + self.size = self.attachment.stat().st_size + + log.debug('file saved locally.') + except Exception as e: + log.error('file failed to be saved: %s', str(e)) + return False + return True + + def attach(self, api_object, on_cloud=False): + """ Attach this attachment to an existing api_object. This + BaseAttachment object must be an orphan BaseAttachment created for the + sole purpose of attach it to something and therefore run this method. + + :param api_object: object to attach to + :param on_cloud: if the attachment is on cloud or not + :return: Success / Failure + :rtype: bool + """ + + if self.on_cloud: + # item is already saved on the cloud. + return True + + # api_object must exist and if implements attachments + # then we can attach to it. + if api_object and getattr(api_object, 'attachments', None): + if on_cloud: + if not api_object.object_id: + raise RuntimeError( + 'A valid object id is needed in order to attach a file') + # api_object builds its own url using its + # resource and main configuration + url = api_object.build_url(self._endpoints.get('attach').format( + id=api_object.object_id)) + + response = api_object.con.post(url, data=self.to_api_data()) + + return bool(response) + else: + if self.attachment_type == 'file': + api_object.attachments.add([{ + 'attachment_id': self.attachment_id, + # TODO: copy attachment id? or set to None? + 'path': str( + self.attachment) if self.attachment else None, + 'name': self.name, + 'content': self.content, + 'on_disk': self.on_disk + }]) + else: + raise RuntimeError('Only file attachments can be attached') + + def __str__(self): + return self.__repr__() + + def __repr__(self): + return 'Attachment: {}'.format(self.name) + + +class BaseAttachments(ApiComponent): + """ A Collection of BaseAttachments """ + + _endpoints = { + 'attachments': '/messages/{id}/attachments', + 'attachment': '/messages/{id}/attachments/{ida}' + } + _attachment_constructor = BaseAttachment #: :meta private: + + def __init__(self, parent, attachments=None): + """ Attachments must be a list of path strings or dictionary elements + + :param Account parent: parent object + :param attachments: list of attachments + :type attachments: list[str] or list[Path] or str or Path or dict + """ + super().__init__(protocol=parent.protocol, + main_resource=parent.main_resource) + self._parent = parent + self.__attachments = [] + # holds on_cloud attachments removed from the parent object + self.__removed_attachments = [] + self.untrack = True + if attachments: + self.add(attachments) + self.untrack = False + + def __iter__(self): + return iter(self.__attachments) + + def __getitem__(self, key): + return self.__attachments[key] + + def __contains__(self, item): + return item in {attachment.name for attachment in self.__attachments} + + def __len__(self): + return len(self.__attachments) + + def __str__(self): + attachments = len(self.__attachments) + parent_has_attachments = getattr(self._parent, 'has_attachments', False) + if parent_has_attachments and attachments == 0: + return 'Number of Attachments: unknown' + else: + return 'Number of Attachments: {}'.format(attachments) + + def __repr__(self): + return self.__str__() + + def __bool__(self): + return bool(len(self.__attachments)) + + def to_api_data(self): + """ Returns a dict to communicate with the server + + :rtype: dict + """ + return [attachment.to_api_data() for attachment in self.__attachments if + attachment.on_cloud is False] + + def clear(self): + """ Clear the attachments """ + for attachment in self.__attachments: + if attachment.on_cloud: + self.__removed_attachments.append(attachment) + self.__attachments = [] + self._update_parent_attachments() + self._track_changes() + + def _track_changes(self): + """ Update the track_changes on the parent to reflect + a needed update on this field """ + if getattr(self._parent, '_track_changes', + None) is not None and self.untrack is False: + # noinspection PyProtectedMember + self._parent._track_changes.add('attachments') + + def _update_parent_attachments(self): + """ Tries to update the parent property 'has_attachments' """ + try: + self._parent.has_attachments = bool(len(self.__attachments)) + except AttributeError: + pass + + def add(self, attachments): + """ Add more attachments + + :param attachments: list of attachments + :type attachments: list[str] or list[Path] or str or Path or dict + """ + if attachments: + if isinstance(attachments, (str, Path)): + attachments = [attachments] + if isinstance(attachments, (list, tuple, set)): + # User provided attachments + attachments_temp = [ + self._attachment_constructor(attachment, parent=self) + for attachment in attachments] + elif isinstance(attachments, + dict) and self._cloud_data_key in attachments: + # Cloud downloaded attachments. We pass on_cloud=True + # to track if this attachment is saved on the server + attachments_temp = [self._attachment_constructor( + {self._cloud_data_key: attachment}, parent=self, + on_cloud=True) + for attachment in + attachments.get(self._cloud_data_key, [])] + else: + raise ValueError('Attachments must be a str or Path or a ' + 'list, tuple or set of the former') + + self.__attachments.extend(attachments_temp) + self._update_parent_attachments() + self._track_changes() + + def remove(self, attachments): + """ Remove the specified attachments + + :param attachments: list of attachments + :type attachments: list[str] or list[Path] or str or Path or dict + """ + if isinstance(attachments, (list, tuple)): + attachments = ({attachment.name + if isinstance(attachment, BaseAttachment) + else attachment for attachment in attachments}) + elif isinstance(attachments, str): + attachments = {attachments} + elif isinstance(attachments, BaseAttachment): + attachments = {attachments.name} + else: + raise ValueError('Incorrect parameter type for attachments') + + new_attachments = [] + for attachment in self.__attachments: + if attachment.name not in attachments: + new_attachments.append(attachment) + else: + if attachment.on_cloud: + # add to removed_attachments so later we can delete them + self.__removed_attachments.append( + attachment) + self.__attachments = new_attachments + self._update_parent_attachments() + self._track_changes() + + def download_attachments(self): + """ Downloads this message attachments into memory. + Need a call to 'attachment.save' to save them on disk. + + :return: Success / Failure + :rtype: bool + """ + if not self._parent.has_attachments: + log.debug( + 'Parent {} has no attachments, skipping out early.'.format( + self._parent.__class__.__name__)) + return False + + if not self._parent.object_id: + raise RuntimeError( + 'Attempted to download attachments of an unsaved {}'.format( + self._parent.__class__.__name__)) + + url = self.build_url(self._endpoints.get('attachments').format( + id=self._parent.object_id)) + + response = self._parent.con.get(url) + if not response: + return False + + attachments = response.json().get('value', []) + + # Everything received from cloud must be passed as self._cloud_data_key + self.untrack = True + self.add({self._cloud_data_key: attachments}) + self.untrack = False + + # TODO: when it's a item attachment the attachment itself + # is not downloaded. We must download it... + # TODO: idea: retrieve the attachments ids' only with + # select and then download one by one. + return True + + def _update_attachments_to_cloud(self, chunk_size=None): + """ Push new, unsaved attachments to the cloud and remove removed + attachments. This method should not be called for non draft messages. + """ + # ! potentially several api requests can be made by this method. + chunk_size = chunk_size if chunk_size is not None else DEFAULT_UPLOAD_CHUNK_SIZE + + for attachment in self.__attachments: + if attachment.on_cloud is False: + file_size = attachment.size + if file_size <= UPLOAD_SIZE_LIMIT_SIMPLE: + url = self.build_url(self._endpoints.get('attachments').format( + id=self._parent.object_id)) + # upload attachment: + response = self._parent.con.post(url, data=attachment.to_api_data()) + if not response: + return False + + data = response.json() + + # update attachment data + attachment.attachment_id = data.get('id') + attachment.content = data.get(self._cc('contentBytes'), None) + else: + # Upload with session + url = self.build_url( + self._endpoints.get('create_upload_session').format( + id=self._parent.object_id)) + + request = UploadSessionRequest(parent=self, attachment=attachment) + file_data = request.to_api_data() + response = self._parent.con.post(url, data=file_data) + if not response: + return False + + data = response.json() + + upload_url = data.get(self._cc('uploadUrl'), None) + log.info('Resumable upload on url: {}'.format(upload_url)) + expiration_date = data.get(self._cc('expirationDateTime'), None) + if expiration_date: + log.info('Expiration Date for this upload url is: {}'.format( + expiration_date)) + if upload_url is None: + log.error('Create upload session response without ' + 'upload_url for file {}'.format(attachment.name)) + return False + + def write_stream(read_byte_chunk): + current_bytes = 0 + while True: + data = read_byte_chunk() + if not data: + break + transfer_bytes = len(data) + headers = { + 'Content-type': 'application/octet-stream', + 'Content-Length': str(len(data)), + 'Content-Range': 'bytes {}-{}/{}' + ''.format(current_bytes, + current_bytes + + transfer_bytes - 1, + file_size) + } + current_bytes += transfer_bytes + + # this request mut NOT send the authorization header. + # so we use a naive simple request. + response = self._parent.con.naive_request(upload_url, 'PUT', + data=data, + headers=headers) + if not response: + return False + + if response.status_code == 201: + # file is completed + break + else: # Usually 200 + data = response.json() + log.debug('Successfully put {} bytes'.format( + data.get("nextExpectedRanges"))) + return True + + if attachment.attachment: + with attachment.attachment.open(mode='rb') as file: + read_from_file = lambda : file.read(chunk_size) + upload_completed = write_stream(read_byte_chunk=read_from_file) + else: + buffer = BytesIO(base64.b64decode(attachment.content)) + read_byte_chunk = lambda : buffer.read(chunk_size) + upload_completed = write_stream(read_byte_chunk=read_byte_chunk) + + if not upload_completed: + return False + + attachment.on_cloud = True + + for attachment in self.__removed_attachments: + if attachment.on_cloud and attachment.attachment_id is not None: + # delete attachment + url = self.build_url(self._endpoints.get('attachment').format( + id=self._parent.object_id, ida=attachment.attachment_id)) + + response = self._parent.con.delete(url) + if not response: + return False + + self.__removed_attachments = [] # reset the removed attachments + + log.debug('Successfully updated attachments on {}'.format( + self._parent.object_id)) + + return True + diff --git a/O365/utils/casing.py b/O365/utils/casing.py new file mode 100644 index 00000000..cdbb5011 --- /dev/null +++ b/O365/utils/casing.py @@ -0,0 +1,46 @@ +import re + + +def to_snake_case(value: str) -> str: + """Convert string into snake case""" + pass + value = re.sub(r"[\-.\s]", '_', str(value)) + if not value: + return value + return str(value[0]).lower() + re.sub( + r"[A-Z]", + lambda matched: '_' + str(matched.group(0)).lower(), + value[1:] + ) + + +def to_upper_lower_case(value: str, upper: bool = True) -> str: + """Convert string into upper or lower case""" + + value = re.sub(r"\w[\s\W]+\w", '', str(value)) + if not value: + return value + + first_letter = str(value[0]) + if upper: + first_letter = first_letter.upper() + else: + first_letter = first_letter.lower() + + return first_letter + re.sub( + r"[\-_.\s]([a-z])", + lambda matched: str(matched.group(1)).upper(), + value[1:] + ) + + +def to_camel_case(value: str) -> str: + """Convert string into camel case""" + + return to_upper_lower_case(value, upper=False) + + +def to_pascal_case(value: str) -> str: + """Convert string into pascal case""" + + return to_upper_lower_case(value, upper=True) diff --git a/O365/utils/consent.py b/O365/utils/consent.py new file mode 100644 index 00000000..b1672c99 --- /dev/null +++ b/O365/utils/consent.py @@ -0,0 +1,5 @@ +def consent_input_token(consent_url): + print('Visit the following url to give consent:') + print(consent_url) + + return input('Paste the authenticated url here:\n') diff --git a/O365/utils/decorators.py b/O365/utils/decorators.py new file mode 100644 index 00000000..ab1419b3 --- /dev/null +++ b/O365/utils/decorators.py @@ -0,0 +1,118 @@ +import logging +from functools import wraps + +log = logging.getLogger(__name__) + + +def deprecated(version, *replacement): + """ Decorator to mark a specified function as deprecated + + :param version: version in which it is deprecated + :param replacement: replacement functions to use + """ + + def deprecated_wrapper(func): + replacement_message = 'Use {} instead'.format(', '.join( + ["'{}'".format(_get_func_fq_name(x)) + for x in replacement])) + log_message = ("'{}' is deprecated, {}" + "".format(_get_func_fq_name(func), replacement_message)) + # func.__doc__ = replacement[0].__doc__ + + func_path = _get_func_path(func) + doc_replacement = [] + for x in replacement: + if func_path == _get_func_path(x): + doc_replacement.append(':func:`{}`'.format(_func_name(x))) + else: + doc_replacement.append( + ':func:`{}`'.format(_get_func_fq_name(x))) + + func.__doc__ = """ + .. deprecated:: {} + Use {} instead + + {} + """.format(version, + ', '.join(doc_replacement), + func.__doc__ if func.__doc__ else '') + + @wraps(func) + def wrapper(*args, **kwargs): + log.warning(log_message) + return func(*args, **kwargs) + + return wrapper + + return deprecated_wrapper + + +def _func_name(func): + if isinstance(func, property): + func = func.fget + return func.__name__ + + +def _get_func_path(func): + if isinstance(func, property): + func = func.fget + full_path = "{}.".format(func.__module__) + if callable(func): + try: + temp = func.__qualname__.split('.', 1)[0].rsplit('.', 1)[0] + full_path += "{}.".format(temp) + except AttributeError as _: + try: + # noinspection PyUnresolvedReferences + temp = func.im_class + full_path += "{}.".format(temp) + except AttributeError as _: + pass + + return full_path + + +def _get_func_fq_name(func): + if isinstance(func, property): + func = func.fget + full_path = _get_func_path(func) + full_path += func.__name__ + return full_path + + +def fluent(func): + func.__doc__ = """{} + .. note:: This method is part of fluent api and can be chained + """.format(func.__doc__ if func.__doc__ else '') + + @wraps(func) + def inner(self, *args, **kwargs): + return func(self, *args, **kwargs) + + return inner + + +def action(func): + func.__doc__ = """{} + .. note:: The success/failure of this action can be obtained + from **success** and **error_message** attributes after + executing this function + + Example: + .. code-block:: python + + my_obj.one().two().finish() + if not my_obj.is_success: + print(my_obj.error_message) + + this will return success/failure of **finish** action + """.format(func.__doc__ if func.__doc__ else '') + + @wraps(func) + def inner(self, *args, **kwargs): + obj = self.__class__.__new__(self.__class__) + obj.__dict__ = self.__dict__.copy() + func(obj, *args, **kwargs) + return obj + + return inner diff --git a/O365/utils/query.py b/O365/utils/query.py new file mode 100644 index 00000000..c2f203a3 --- /dev/null +++ b/O365/utils/query.py @@ -0,0 +1,823 @@ +from __future__ import annotations + +import datetime as dt +from abc import ABC, abstractmethod +from typing import Union, Optional, TYPE_CHECKING, Type, Iterator, TypeAlias + +if TYPE_CHECKING: + from O365.connection import Protocol + +FilterWord: TypeAlias = Union[str, bool, None, dt.date, int, float] + + +class QueryBase(ABC): + __slots__ = () + + @abstractmethod + def as_params(self) -> dict: + pass + + @abstractmethod + def render(self) -> str: + pass + + def __str__(self): + return self.__repr__() + + def __repr__(self): + return self.render() + + @abstractmethod + def __and__(self, other): + pass + + @abstractmethod + def __or__(self, other): + pass + + def get_filter_by_attribute(self, attribute: str) -> Optional[str]: + """ + Returns a filter value by attribute name. It will match the attribute to the start of each filter attribute + and return the first found. + + :param attribute: the attribute you want to search + :return: The value applied to that attribute or None + """ + search_object: Optional[QueryFilter] = getattr(self, "_filter_instance", None) or getattr(self, "filters", None) + if search_object is not None: + # CompositeFilter, IterableFilter, ModifierQueryFilter (negate, group) + return search_object.get_filter_by_attribute(attribute) + + search_object: Optional[list[QueryFilter]] = getattr(self, "_filter_instances", None) + if search_object is not None: + # ChainFilter + for filter_obj in search_object: + result = filter_obj.get_filter_by_attribute(attribute) + if result is not None: + return result + return None + + search_object: Optional[str] = getattr(self, "_attribute", None) + if search_object is not None: + # LogicalFilter or FunctionFilter + if search_object.lower().startswith(attribute.lower()): + return getattr(self, "_word") + return None + + +class QueryFilter(QueryBase, ABC): + __slots__ = () + + @abstractmethod + def render(self, item_name: Optional[str] = None) -> str: + pass + + def as_params(self) -> dict: + return {"$filter": self.render()} + + def __and__(self, other: Optional[QueryBase]) -> QueryBase: + if other is None: + return self + if isinstance(other, QueryFilter): + return ChainFilter("and", [self, other]) + elif isinstance(other, OrderByFilter): + return CompositeFilter(filters=self, order_by=other) + elif isinstance(other, SearchFilter): + raise ValueError("Can't mix search with filters or order by clauses.") + elif isinstance(other, SelectFilter): + return CompositeFilter(filters=self, select=other) + elif isinstance(other, ExpandFilter): + return CompositeFilter(filters=self, expand=other) + else: + raise ValueError(f"Can't mix {type(other)} with {type(self)}") + + + def __or__(self, other: QueryFilter) -> ChainFilter: + if not isinstance(other, QueryFilter): + raise ValueError("Can't chain a non-query filter with and 'or' operator. Use 'and' instead.") + return ChainFilter("or", [self, other]) + + +class OperationQueryFilter(QueryFilter, ABC): + __slots__ = ("_operation",) + + def __init__(self, operation: str): + self._operation: str = operation + + +class LogicalFilter(OperationQueryFilter): + __slots__ = ("_operation", "_attribute", "_word") + + def __init__(self, operation: str, attribute: str, word: str): + super().__init__(operation) + self._attribute: str = attribute + self._word: str = word + + def _prepare_attribute(self, item_name: str = None) -> str: + if item_name: + if self._attribute is None: + # iteration will occur in the item itself + return f"{item_name}" + else: + return f"{item_name}/{self._attribute}" + else: + return self._attribute + + def render(self, item_name: Optional[str] = None) -> str: + return f"{self._prepare_attribute(item_name)} {self._operation} {self._word}" + + +class FunctionFilter(LogicalFilter): + __slots__ = ("_operation", "_attribute", "_word") + + def render(self, item_name: Optional[str] = None) -> str: + return f"{self._operation}({self._prepare_attribute(item_name)}, {self._word})" + + +class IterableFilter(OperationQueryFilter): + __slots__ = ("_operation", "_collection", "_item_name", "_filter_instance") + + def __init__(self, operation: str, collection: str, filter_instance: QueryFilter, *, item_name: str = "a"): + super().__init__(operation) + self._collection: str = collection + self._item_name: str = item_name + self._filter_instance: QueryFilter = filter_instance + + def render(self, item_name: Optional[str] = None) -> str: + # an iterable filter will always ignore external item names + filter_instance_render = self._filter_instance.render(item_name=self._item_name) + return f"{self._collection}/{self._operation}({self._item_name}: {filter_instance_render})" + + +class ChainFilter(OperationQueryFilter): + __slots__ = ("_operation", "_filter_instances") + + def __init__(self, operation: str, filter_instances: list[QueryFilter]): + assert operation in ("and", "or") + super().__init__(operation) + self._filter_instances: list[QueryFilter] = filter_instances + + def render(self, item_name: Optional[str] = None) -> str: + return f" {self._operation} ".join([fi.render(item_name) for fi in self._filter_instances]) + + +class ModifierQueryFilter(QueryFilter, ABC): + __slots__ = ("_filter_instance",) + + def __init__(self, filter_instance: QueryFilter): + self._filter_instance: QueryFilter = filter_instance + + +class NegateFilter(ModifierQueryFilter): + __slots__ = ("_filter_instance",) + + def render(self, item_name: Optional[str] = None) -> str: + return f"not {self._filter_instance.render(item_name=item_name)}" + + +class GroupFilter(ModifierQueryFilter): + __slots__ = ("_filter_instance",) + + def render(self, item_name: Optional[str] = None) -> str: + return f"({self._filter_instance.render(item_name=item_name)})" + + +class SearchFilter(QueryBase): + __slots__ = ("_search",) + + def __init__(self, word: Optional[Union[str, int, bool]] = None, attribute: Optional[str] = None): + if word: + if attribute: + self._search: str = f"{attribute}:{word}" + else: + self._search: str = word + else: + self._search: str = "" + + def _combine(self, search_one: str, search_two: str, operator: str = "and"): + self._search = f"{search_one} {operator} {search_two}" + + def render(self) -> str: + return f'"{self._search}"' + + def as_params(self) -> dict: + return {"$search": self.render()} + + def __and__(self, other: Optional[QueryBase]) -> QueryBase: + if other is None: + return self + if isinstance(other, SearchFilter): + new_search = self.__class__() + new_search._combine(self._search, other._search, operator="and") + return new_search + elif isinstance(other, QueryFilter): + raise ValueError("Can't mix search with filters clauses.") + elif isinstance(other, OrderByFilter): + raise ValueError("Can't mix search with order by clauses.") + elif isinstance(other, SelectFilter): + return CompositeFilter(search=self, select=other) + elif isinstance(other, ExpandFilter): + return CompositeFilter(search=self, expand=other) + else: + raise ValueError(f"Can't mix {type(other)} with {type(self)}") + + def __or__(self, other: QueryBase) -> SearchFilter: + if not isinstance(other, SearchFilter): + raise ValueError("Can't chain a non-search filter with and 'or' operator. Use 'and' instead.") + new_search = self.__class__() + new_search._combine(self._search, other._search, operator="or") + return new_search + + +class OrderByFilter(QueryBase): + __slots__ = ("_orderby",) + + def __init__(self): + self._orderby: list[tuple[str, bool]] = [] + + def _sorted_attributes(self) -> list[str]: + return [att for att, asc in self._orderby] + + def add(self, attribute: str, ascending: bool = True) -> None: + if not attribute: + raise ValueError("Attribute can't be empty") + if attribute not in self._sorted_attributes(): + self._orderby.append((attribute, ascending)) + + def render(self) -> str: + return ",".join(f"{att} {'' if asc else 'desc'}".strip() for att, asc in self._orderby) + + def as_params(self) -> dict: + return {"$orderby": self.render()} + + def __and__(self, other: Optional[QueryBase]) -> QueryBase: + if other is None: + return self + if isinstance(other, OrderByFilter): + new_order_by = self.__class__() + for att, asc in self._orderby: + new_order_by.add(att, asc) + for att, asc in other._orderby: + new_order_by.add(att, asc) + return new_order_by + elif isinstance(other, SearchFilter): + raise ValueError("Can't mix order by with search clauses.") + elif isinstance(other, QueryFilter): + return CompositeFilter(order_by=self, filters=other) + elif isinstance(other, SelectFilter): + return CompositeFilter(order_by=self, select=other) + elif isinstance(other, ExpandFilter): + return CompositeFilter(order_by=self, expand=other) + else: + raise ValueError(f"Can't mix {type(other)} with {type(self)}") + + def __or__(self, other: QueryBase): + raise RuntimeError("Orderby clauses are mutually exclusive") + + +class ContainerQueryFilter(QueryBase): + __slots__ = ("_container", "_keyword") + + def __init__(self, *args: Union[str, tuple[str, SelectFilter]]): + self._container: list[Union[str, tuple[str, SelectFilter]]] = list(args) + self._keyword: str = '' + + def append(self, item: Union[str, tuple[str, SelectFilter]]) -> None: + self._container.append(item) + + def __iter__(self) -> Iterator[Union[str, tuple[str, SelectFilter]]]: + return iter(self._container) + + def __contains__(self, attribute: str) -> bool: + return attribute in [item[0] if isinstance(item, tuple) else item for item in self._container] + + def __and__(self, other: Optional[QueryBase]) -> QueryBase: + if other is None: + return self + if (isinstance(other, SelectFilter) and isinstance(self, SelectFilter) + ) or (isinstance(other, ExpandFilter) and isinstance(self, ExpandFilter)): + new_container = self.__class__(*self) + for item in other: + if isinstance(item, tuple): + attribute = item[0] + else: + attribute = item + if attribute not in new_container: + new_container.append(item) + return new_container + elif isinstance(other, QueryFilter): + return CompositeFilter(**{self._keyword: self, "filters": other}) + elif isinstance(other, SearchFilter): + return CompositeFilter(**{self._keyword: self, "search": other}) + elif isinstance(other, OrderByFilter): + return CompositeFilter(**{self._keyword: self, "order_by": other}) + elif isinstance(other, SelectFilter): + return CompositeFilter(**{self._keyword: self, "select": other}) + elif isinstance(other, ExpandFilter): + return CompositeFilter(**{self._keyword: self, "expand": other}) + else: + raise ValueError(f"Can't mix {type(other)} with {type(self)}") + + def __or__(self, other: Optional[QueryBase]): + raise RuntimeError("Can't combine multiple composite filters with an 'or' statement. Use 'and' instead.") + + def render(self) -> str: + return ",".join(self._container) + + def as_params(self) -> dict: + return {f"${self._keyword}": self.render()} + + +class SelectFilter(ContainerQueryFilter): + __slots__ = ("_container", "_keyword") + + def __init__(self, *args: str): + super().__init__(*args) + self._keyword: str = "select" + + +class ExpandFilter(ContainerQueryFilter): + __slots__ = ("_container", "_keyword") + + def __init__(self, *args: Union[str, tuple[str, SelectFilter]]): + super().__init__(*args) + self._keyword: str = "expand" + + def render(self) -> str: + renders = [] + for item in self._container: + if isinstance(item, tuple): + renders.append(f"{item[0]}($select={item[1].render()})") + else: + renders.append(item) + return ",".join(renders) + + +class CompositeFilter(QueryBase): + """ A Query object that holds all query parameters. """ + + __slots__ = ("filters", "search", "order_by", "select", "expand") + + def __init__(self, *, filters: Optional[QueryFilter] = None, search: Optional[SearchFilter] = None, + order_by: Optional[OrderByFilter] = None, select: Optional[SelectFilter] = None, + expand: Optional[ExpandFilter] = None): + self.filters: Optional[QueryFilter] = filters + self.search: Optional[SearchFilter] = search + self.order_by: Optional[OrderByFilter] = order_by + self.select: Optional[SelectFilter] = select + self.expand: Optional[ExpandFilter] = expand + + def render(self) -> str: + return ( + f"Filters: {self.filters.render() if self.filters else ''}\n" + f"Search: {self.search.render() if self.search else ''}\n" + f"OrderBy: {self.order_by.render() if self.order_by else ''}\n" + f"Select: {self.select.render() if self.select else ''}\n" + f"Expand: {self.expand.render() if self.expand else ''}" + ) + + @property + def has_filters(self) -> bool: + """ Returns if this CompositeFilter has filters""" + return self.filters is not None + + @property + def has_selects(self) -> bool: + """ Returns if this CompositeFilter has selects""" + return self.select is not None + + @property + def has_expands(self) -> bool: + """ Returns if this CompositeFilter has expands""" + return self.expand is not None + + @property + def has_search(self) -> bool: + """ Returns if this CompositeFilter has search""" + return self.search is not None + + @property + def has_order_by(self) -> bool: + """ Returns if this CompositeFilter has order_by""" + return self.order_by is not None + + def clear_filters(self) -> None: + """ Removes all filters from the query """ + self.filters = None + + @property + def has_only_filters(self) -> bool: + """ Returns true if it only has filters""" + return (self.filters is not None and self.search is None and + self.order_by is None and self.select is None and self.expand is None) + + def as_params(self) -> dict: + params = {} + if self.filters: + params.update(self.filters.as_params()) + if self.search: + params.update(self.search.as_params()) + if self.order_by: + params.update(self.order_by.as_params()) + if self.expand: + params.update(self.expand.as_params()) + if self.select: + params.update(self.select.as_params()) + return params + + def __and__(self, other: Optional[QueryBase]) -> CompositeFilter: + """ Combine this CompositeFilter with another QueryBase object """ + if other is None: + return self + nc = CompositeFilter(filters=self.filters, search=self.search, order_by=self.order_by, + select=self.select, expand=self.expand) + if isinstance(other, QueryFilter): + if self.search is not None: + raise ValueError("Can't mix search with filters or order by clauses.") + nc.filters = nc.filters & other if nc.filters else other + elif isinstance(other, OrderByFilter): + if self.search is not None: + raise ValueError("Can't mix search with filters or order by clauses.") + nc.order_by = nc.order_by & other if nc.order_by else other + elif isinstance(other, SearchFilter): + if self.filters is not None or self.order_by is not None: + raise ValueError("Can't mix search with filters or order by clauses.") + nc.search = nc.search & other if nc.search else other + elif isinstance(other, SelectFilter): + nc.select = nc.select & other if nc.select else other + elif isinstance(other, ExpandFilter): + nc.expand = nc.expand & other if nc.expand else other + elif isinstance(other, CompositeFilter): + if (self.search and (other.filters or other.order_by) + ) or (other.search and (self.filters or self.order_by)): + raise ValueError("Can't mix search with filters or order by clauses.") + nc.filters = nc.filters & other.filters if nc.filters else other.filters + nc.search = nc.search & other.search if nc.search else other.search + nc.order_by = nc.order_by & other.order_by if nc.order_by else other.order_by + nc.select = nc.select & other.select if nc.select else other.select + nc.expand = nc.expand & other.expand if nc.expand else other.expand + return nc + + def __or__(self, other: Optional[QueryBase]) -> CompositeFilter: + if isinstance(other, CompositeFilter): + if self.has_only_filters and other.has_only_filters: + return CompositeFilter(filters=self.filters | other.filters) + raise RuntimeError("Can't combine multiple composite filters with an 'or' statement. Use 'and' instead.") + + +class QueryBuilder: + + _attribute_mapping = { + "from": "from/emailAddress/address", + "to": "toRecipients/emailAddress/address", + "start": "start/DateTime", + "end": "end/DateTime", + "due": "duedatetime/DateTime", + "reminder": "reminderdatetime/DateTime", + "flag": "flag/flagStatus", + "body": "body/content" + } + + def __init__(self, protocol: Union[Protocol, Type[Protocol]]): + """ Build a query to apply OData filters + https://docs.microsoft.com/en-us/graph/query-parameters + + :param Protocol protocol: protocol to retrieve the timezone from + """ + self.protocol = protocol() if isinstance(protocol, type) else protocol + + def _parse_filter_word(self, word: FilterWord) -> str: + """ Converts the word parameter into a string """ + if isinstance(word, str): + # string must be enclosed in quotes + parsed_word = f"'{word}'" + elif isinstance(word, bool): + # bools are treated as lower case bools + parsed_word = str(word).lower() + elif word is None: + parsed_word = "null" + elif isinstance(word, dt.date): + if isinstance(word, dt.datetime): + if word.tzinfo is None: + # if it's a naive datetime, localize the datetime. + word = word.replace(tzinfo=self.protocol.timezone) # localize datetime into local tz + # convert datetime to iso format + parsed_word = f"{word.isoformat()}" + else: + # other cases like int or float, return as a string. + parsed_word = str(word) + return parsed_word + + def _get_attribute_from_mapping(self, attribute: str) -> str: + """ + Look up the provided attribute into the query builder mapping + Applies a conversion to the appropriate casing defined by the protocol. + + :param attribute: attribute to look up + :return: the attribute itself of if found the corresponding complete attribute in the mapping + """ + mapping = self._attribute_mapping.get(attribute) + if mapping: + attribute = "/".join( + [self.protocol.convert_case(step) for step in + mapping.split("/")]) + else: + attribute = self.protocol.convert_case(attribute) + return attribute + + def logical_operation(self, operation: str, attribute: str, word: FilterWord) -> CompositeFilter: + """ Apply a logical operation like equals, less than, etc. + + :param operation: how to combine with a new one + :param attribute: attribute to compare word with + :param word: value to compare the attribute with + :return: a CompositeFilter instance that can render the OData logical operation + """ + logical_filter = LogicalFilter(operation, + self._get_attribute_from_mapping(attribute), + self._parse_filter_word(word)) + return CompositeFilter(filters=logical_filter) + + def equals(self, attribute: str, word: FilterWord) -> CompositeFilter: + """ Return an equals check + + :param attribute: attribute to compare word with + :param word: word to compare with + :return: a CompositeFilter instance that can render the OData this logical operation + """ + return self.logical_operation("eq", attribute, word) + + def unequal(self, attribute: str, word: FilterWord) -> CompositeFilter: + """ Return an unequal check + + :param attribute: attribute to compare word with + :param word: word to compare with + :return: a CompositeFilter instance that can render the OData this logical operation + """ + return self.logical_operation("ne", attribute, word) + + def greater(self, attribute: str, word: FilterWord) -> CompositeFilter: + """ Return a 'greater than' check + + :param attribute: attribute to compare word with + :param word: word to compare with + :return: a CompositeFilter instance that can render the OData this logical operation + """ + return self.logical_operation("gt", attribute, word) + + def greater_equal(self, attribute: str, word: FilterWord) -> CompositeFilter: + """ Return a 'greater than or equal to' check + + :param attribute: attribute to compare word with + :param word: word to compare with + :return: a CompositeFilter instance that can render the OData this logical operation + """ + return self.logical_operation("ge", attribute, word) + + def less(self, attribute: str, word: FilterWord) -> CompositeFilter: + """ Return a 'less than' check + + :param attribute: attribute to compare word with + :param word: word to compare with + :return: a CompositeFilter instance that can render the OData this logical operation + """ + return self.logical_operation("lt", attribute, word) + + def less_equal(self, attribute: str, word: FilterWord) -> CompositeFilter: + """ Return a 'less than or equal to' check + + :param attribute: attribute to compare word with + :param word: word to compare with + :return: a CompositeFilter instance that can render the OData this logical operation + """ + return self.logical_operation("le", attribute, word) + + def function_operation(self, operation: str, attribute: str, word: FilterWord) -> CompositeFilter: + """ Apply a function operation + + :param operation: function name to operate on attribute + :param attribute: the name of the attribute on which to apply the function + :param word: value to feed the function + :return: a CompositeFilter instance that can render the OData function operation + """ + function_filter = FunctionFilter(operation, + self._get_attribute_from_mapping(attribute), + self._parse_filter_word(word)) + return CompositeFilter(filters=function_filter) + + def contains(self, attribute: str, word: FilterWord) -> CompositeFilter: + """ Adds a contains word check + + :param attribute: the name of the attribute on which to apply the function + :param word: value to feed the function + :return: a CompositeFilter instance that can render the OData function operation + """ + return self.function_operation("contains", attribute, word) + + def startswith(self, attribute: str, word: FilterWord) -> CompositeFilter: + """ Adds a startswith word check + + :param attribute: the name of the attribute on which to apply the function + :param word: value to feed the function + :return: a CompositeFilter instance that can render the OData function operation + """ + return self.function_operation("startswith", attribute, word) + + def endswith(self, attribute: str, word: FilterWord) -> CompositeFilter: + """ Adds a endswith word check + + :param attribute: the name of the attribute on which to apply the function + :param word: value to feed the function + :return: a CompositeFilter instance that can render the OData function operation + """ + return self.function_operation("endswith", attribute, word) + + def iterable_operation(self, operation: str, collection: str, filter_instance: CompositeFilter, + *, item_name: str = "a") -> CompositeFilter: + """ Performs the provided filter operation on a collection by iterating over it. + + For example: + + .. code-block:: python + + q.iterable( + operation='any', + collection='email_addresses', + filter_instance=q.equals('address', 'george@best.com') + ) + + will transform to a filter such as: + emailAddresses/any(a:a/address eq 'george@best.com') + + :param operation: the iterable operation name + :param collection: the collection to apply the iterable operation on + :param filter_instance: a CompositeFilter instance on which you will apply the iterable operation + :param item_name: the name of the collection item to be used on the filter_instance + :return: a CompositeFilter instance that can render the OData iterable operation + """ + iterable_filter = IterableFilter(operation, + self._get_attribute_from_mapping(collection), + filter_instance.filters, + item_name=item_name) + return CompositeFilter(filters=iterable_filter) + + + def any(self, collection: str, filter_instance: CompositeFilter, *, item_name: str = "a") -> CompositeFilter: + """ Performs a filter with the OData 'any' keyword on the collection + + For example: + q.any(collection='email_addresses', filter_instance=q.equals('address', 'george@best.com')) + + will transform to a filter such as: + + emailAddresses/any(a:a/address eq 'george@best.com') + + :param collection: the collection to apply the iterable operation on + :param filter_instance: a CompositeFilter Instance on which you will apply the iterable operation + :param item_name: the name of the collection item to be used on the filter_instance + :return: a CompositeFilter instance that can render the OData iterable operation + """ + + return self.iterable_operation("any", collection=collection, + filter_instance=filter_instance, item_name=item_name) + + + def all(self, collection: str, filter_instance: CompositeFilter, *, item_name: str = "a") -> CompositeFilter: + """ Performs a filter with the OData 'all' keyword on the collection + + For example: + q.all(collection='email_addresses', filter_instance=q.equals('address', 'george@best.com')) + + will transform to a filter such as: + + emailAddresses/all(a:a/address eq 'george@best.com') + + :param collection: the collection to apply the iterable operation on + :param filter_instance: a CompositeFilter Instance on which you will apply the iterable operation + :param item_name: the name of the collection item to be used on the filter_instance + :return: a CompositeFilter instance that can render the OData iterable operation + """ + + return self.iterable_operation("all", collection=collection, + filter_instance=filter_instance, item_name=item_name) + + @staticmethod + def negate(filter_instance: CompositeFilter) -> CompositeFilter: + """ Apply a not operator to the provided QueryFilter + :param filter_instance: a CompositeFilter instance + :return: a CompositeFilter with its filter negated + """ + negate_filter = NegateFilter(filter_instance=filter_instance.filters) + return CompositeFilter(filters=negate_filter) + + def _chain(self, operator: str, *filter_instances: CompositeFilter, group: bool = False) -> CompositeFilter: + chain = ChainFilter(operation=operator, filter_instances=[fl.filters for fl in filter_instances]) + chain = CompositeFilter(filters=chain) + if group: + return self.group(chain) + else: + return chain + + def chain_and(self, *filter_instances: CompositeFilter, group: bool = False) -> CompositeFilter: + """ Start a chain 'and' operation + + :param filter_instances: a list of other CompositeFilter you want to combine with the 'and' operation + :param group: will group this chain operation if True + :return: a CompositeFilter with the filter instances combined with an 'and' operation + """ + return self._chain("and", *filter_instances, group=group) + + def chain_or(self, *filter_instances: CompositeFilter, group: bool = False) -> CompositeFilter: + """ Start a chain 'or' operation. Will automatically apply a grouping. + + :param filter_instances: a list of other CompositeFilter you want to combine with the 'or' operation + :param group: will group this chain operation if True + :return: a CompositeFilter with the filter instances combined with an 'or' operation + """ + return self._chain("or", *filter_instances, group=group) + + @staticmethod + def group(filter_instance: CompositeFilter) -> CompositeFilter: + """ Applies a grouping to the provided filter_instance """ + group_filter = GroupFilter(filter_instance.filters) + return CompositeFilter(filters=group_filter) + + def search(self, word: Union[str, int, bool], attribute: Optional[str] = None) -> CompositeFilter: + """ + Perform a search. + Note from graph docs: + + You can currently search only message and person collections. + A $search request returns up to 250 results. + You cannot use $filter or $orderby in a search request. + + :param word: the text to search + :param attribute: the attribute to search the word on + :return: a CompositeFilter instance that can render the OData search operation + """ + word = self._parse_filter_word(word) + if attribute: + attribute = self._get_attribute_from_mapping(attribute) + search = SearchFilter(word=word, attribute=attribute) + return CompositeFilter(search=search) + + @staticmethod + def orderby(*attributes: tuple[Union[str, tuple[str, bool]]]) -> CompositeFilter: + """ + Returns an 'order by' query param + This is useful to order the result set of query from a resource. + Note that not all attributes can be sorted and that all resources have different sort capabilities + + :param attributes: the attributes to orderby + :return: a CompositeFilter instance that can render the OData order by operation + """ + new_order_by = OrderByFilter() + for order_by_clause in attributes: + if isinstance(order_by_clause, str): + new_order_by.add(order_by_clause) + elif isinstance(order_by_clause, tuple): + new_order_by.add(order_by_clause[0], order_by_clause[1]) + else: + raise ValueError("Arguments must be attribute strings or tuples" + " of attribute strings and ascending booleans") + return CompositeFilter(order_by=new_order_by) + + def select(self, *attributes: str) -> CompositeFilter: + """ + Returns a 'select' query param + This is useful to return a limited set of attributes from a resource or return attributes that are not + returned by default by the resource. + + :param attributes: a tuple of attribute names to select + :return: a CompositeFilter instance that can render the OData select operation + """ + select = SelectFilter() + for attribute in attributes: + attribute = self.protocol.convert_case(attribute) + if attribute.lower() in ["meetingmessagetype"]: + attribute = f"{self.protocol.keyword_data_store['event_message_type']}/{attribute}" + select.append(attribute) + return CompositeFilter(select=select) + + def expand(self, relationship: str, select: Optional[CompositeFilter] = None) -> CompositeFilter: + """ + Returns an 'expand' query param + Important: If the 'expand' is a relationship (e.g. "event" or "attachments"), then the ApiComponent using + this query should know how to handle the relationship (e.g. Message knows how to handle attachments, + and event (if it's an EventMessage). + Important: When using expand on multi-value relationships a max of 20 items will be returned. + + :param relationship: a relationship that will be expanded + :param select: a CompositeFilter instance to select attributes on the expanded relationship + :return: a CompositeFilter instance that can render the OData expand operation + """ + expand = ExpandFilter() + # this will prepend the event message type tag based on the protocol + if relationship == "event": + relationship = f"{self.protocol.get_service_keyword('event_message_type')}/event" + + if select is not None: + expand.append((relationship, select.select)) + else: + expand.append(relationship) + return CompositeFilter(expand=expand) diff --git a/O365/utils/range.py b/O365/utils/range.py new file mode 100644 index 00000000..0e472fb3 --- /dev/null +++ b/O365/utils/range.py @@ -0,0 +1,27 @@ +CAPITALIZED_ASCII_CODE = ord('A') +CAPITALIZED_WINDOW = 26 + + +def col_index_to_label(col_index): + """ + Given a column index, returns the label corresponding to the column name. For example, index 0 would be + A ... until 25 which would be Z. + This function will recurse until a full label is generated using chunks of CAPITALIZED_WINDOW. Meaning, + an index of 51 should yield a label of ZZ corresponding to the ZZ column. + + :param int col_index: number associated with the index position of the requested column. For example, column index 0 + would correspond to column label A. + """ + label = '' + extra_letter_index = (col_index // CAPITALIZED_WINDOW) - 1 # Minor adjustment for the no repeat (0) case. + + # If we do need to prepend a new letter to the column label do so recursively such that we could simulate + # labels like AA or AAA or AAAA ... etc. + if extra_letter_index >= 0: + label += col_index_to_label(extra_letter_index) + + # Otherwise, passthrough and add the letter the input index corresponds to. + return label + index_to_col_char(col_index) + +def index_to_col_char(index): + return chr(CAPITALIZED_ASCII_CODE + index % CAPITALIZED_WINDOW) diff --git a/O365/utils/token.py b/O365/utils/token.py new file mode 100644 index 00000000..4357dd7a --- /dev/null +++ b/O365/utils/token.py @@ -0,0 +1,967 @@ +from __future__ import annotations + +import datetime as dt +import json +import logging +import os +from pathlib import Path +from typing import Optional, Protocol, Union, TYPE_CHECKING + +from msal.token_cache import TokenCache + +if TYPE_CHECKING: + from O365.connection import Connection + +log = logging.getLogger(__name__) + + +RESERVED_SCOPES = {"profile", "openid", "offline_access"} + + +class CryptographyManagerType(Protocol): + """Abstract cryptography manager""" + + def encrypt(self, data: Union[bytes, str]) -> Union[bytes, str]: ... + + def decrypt(self, data: Union[bytes, str]) -> Union[bytes, str]: ... + + +class BaseTokenBackend(TokenCache): + """A base token storage class""" + + serializer = json # The default serializer is json + + def __init__(self): + super().__init__() + self._has_state_changed: bool = False + #: Optional cryptography manager. |br| **Type:** CryptographyManagerType + self.cryptography_manager: Optional[CryptographyManagerType] = None + + @property + def has_data(self) -> bool: + """Does the token backend contain data.""" + return bool(self._cache) + + def token_expiration_datetime( + self, *, username: Optional[str] = None + ) -> Optional[dt.datetime]: + """ + Returns the current access token expiration datetime + If the refresh token is present, then the expiration datetime is extended by 3 months + :param str username: The username from which check the tokens + :return dt.datetime or None: The expiration datetime + """ + access_token = self.get_access_token(username=username) + if access_token is None: + return None + + expires_on = access_token.get("expires_on") + if expires_on is None: + # consider the token has expired + return None + else: + expires_on = int(expires_on) + return dt.datetime.fromtimestamp(expires_on) + + def token_is_expired(self, *, username: Optional[str] = None) -> bool: + """ + Checks whether the current access token is expired + :param str username: The username from which check the tokens + :return bool: True if the token is expired, False otherwise + """ + token_expiration_datetime = self.token_expiration_datetime(username=username) + if token_expiration_datetime is None: + return True + else: + return dt.datetime.now() > token_expiration_datetime + + def token_is_long_lived(self, *, username: Optional[str] = None) -> bool: + """Returns if the token backend has a refresh token""" + return self.get_refresh_token(username=username) is not None + + def _get_home_account_id(self, username: str) -> Optional[str]: + """Gets the home_account_id string from the ACCOUNT cache for the specified username""" + + result = list( + self.search(TokenCache.CredentialType.ACCOUNT, query={"username": username}) + ) + if result: + return result[0].get("home_account_id") + else: + log.debug(f"No account found for username: {username}") + return None + + def get_all_accounts(self) -> list[dict]: + """Returns a list of all accounts present in the token cache""" + return list(self.search(TokenCache.CredentialType.ACCOUNT)) + + def get_account( + self, *, username: Optional[str] = None, home_account_id: Optional[str] = None + ) -> Optional[dict]: + """Gets the account object for the specified username or home_account_id""" + if username and home_account_id: + raise ValueError( + 'Provide nothing or either username or home_account_id to "get_account", but not both' + ) + + query = None + if username is not None: + query = {"username": username} + if home_account_id is not None: + query = {"home_account_id": home_account_id} + + result = list(self.search(TokenCache.CredentialType.ACCOUNT, query=query)) + + if result: + return result[0] + else: + return None + + def get_access_token(self, *, username: Optional[str] = None) -> Optional[dict]: + """ + Retrieve the stored access token + If username is None, then the first access token will be retrieved + :param str username: The username from which retrieve the access token + """ + query = None + if username is not None: + home_account_id = self._get_home_account_id(username) + if home_account_id: + query = {"home_account_id": home_account_id} + else: + return None + + results = list(self.search(TokenCache.CredentialType.ACCESS_TOKEN, query=query)) + return results[0] if results else None + + def get_refresh_token(self, *, username: Optional[str] = None) -> Optional[dict]: + """Retrieve the stored refresh token + If username is None, then the first access token will be retrieved + :param str username: The username from which retrieve the refresh token + """ + query = None + if username is not None: + home_account_id = self._get_home_account_id(username) + if home_account_id: + query = {"home_account_id": home_account_id} + else: + return None + + results = list( + self.search(TokenCache.CredentialType.REFRESH_TOKEN, query=query) + ) + return results[0] if results else None + + def get_id_token(self, *, username: Optional[str] = None) -> Optional[dict]: + """Retrieve the stored id token + If username is None, then the first id token will be retrieved + :param str username: The username from which retrieve the id token + """ + query = None + if username is not None: + home_account_id = self._get_home_account_id(username) + if home_account_id: + query = {"home_account_id": home_account_id} + else: + return None + + results = list(self.search(TokenCache.CredentialType.ID_TOKEN, query=query)) + return results[0] if results else None + + def get_token_scopes( + self, *, username: Optional[str] = None, remove_reserved: bool = False + ) -> Optional[list]: + """ + Retrieve the scopes the token (refresh first then access) has permissions on + :param str username: The username from which retrieve the refresh token + :param bool remove_reserved: if True RESERVED_SCOPES will be removed from the list + """ + token = self.get_refresh_token(username=username) or self.get_access_token( + username=username + ) + if token: + scopes_str = token.get("target") + if scopes_str: + scopes = scopes_str.split(" ") + if remove_reserved: + scopes = [scope for scope in scopes if scope not in RESERVED_SCOPES] + return scopes + return None + + def remove_data(self, *, username: str) -> bool: + """ + Removes all tokens and all related data from the token cache for the specified username. + Returns success or failure. + :param str username: The username from which remove the tokens and related data + """ + home_account_id = self._get_home_account_id(username) + if not home_account_id: + return False + + query = {"home_account_id": home_account_id} + + # remove id token + results = list(self.search(TokenCache.CredentialType.ID_TOKEN, query=query)) + for id_token in results: + self.remove_idt(id_token) + + # remove access token + results = list(self.search(TokenCache.CredentialType.ACCESS_TOKEN, query=query)) + for access_token in results: + self.remove_at(access_token) + + # remove refresh tokens + results = list( + self.search(TokenCache.CredentialType.REFRESH_TOKEN, query=query) + ) + for refresh_token in results: + self.remove_rt(refresh_token) + + # remove accounts + results = list(self.search(TokenCache.CredentialType.ACCOUNT, query=query)) + for account in results: + self.remove_account(account) + + self._has_state_changed = True + return True + + def add(self, event, **kwargs) -> None: + """Add to the current cache.""" + super().add(event, **kwargs) + self._has_state_changed = True + + def modify(self, credential_type, old_entry, new_key_value_pairs=None) -> None: + """Modify content in the cache.""" + super().modify(credential_type, old_entry, new_key_value_pairs) + self._has_state_changed = True + + def serialize(self) -> Union[bytes, str]: + """Serialize the current cache state into a string.""" + with self._lock: + self._has_state_changed = False + token_str = self.serializer.dumps(self._cache, indent=4) + if self.cryptography_manager is not None: + token_str = self.cryptography_manager.encrypt(token_str) + return token_str + + def deserialize(self, token_cache_state: Union[bytes, str]) -> dict: + """Deserialize the cache from a state previously obtained by serialize()""" + with self._lock: + self._has_state_changed = False + if self.cryptography_manager is not None: + token_cache_state = self.cryptography_manager.decrypt(token_cache_state) + return self.serializer.loads(token_cache_state) if token_cache_state else {} + + def load_token(self) -> bool: + """ + Abstract method that will retrieve the token data from the backend + This MUST be implemented in subclasses + """ + raise NotImplementedError + + def save_token(self, force=False) -> bool: + """ + Abstract method that will save the token data into the backend + This MUST be implemented in subclasses + """ + raise NotImplementedError + + def delete_token(self) -> bool: + """Optional Abstract method to delete the token from the backend""" + raise NotImplementedError + + def check_token(self) -> bool: + """Optional Abstract method to check for the token existence in the backend""" + raise NotImplementedError + + def should_refresh_token(self, con: Optional[Connection] = None, *, + username: Optional[str] = None) -> Optional[bool]: + """ + This method is intended to be implemented for environments + where multiple Connection instances are running on parallel. + + This method should check if it's time to refresh the token or not. + The chosen backend can store a flag somewhere to answer this question. + This can avoid race conditions between different instances trying to + refresh the token at once, when only one should make the refresh. + + This is an example of how to achieve this: + + 1. Along with the token store a Flag + 2. The first to see the Flag as True must transactional update it + to False. This method then returns True and therefore the + connection will refresh the token. + 3. The save_token method should be rewritten to also update the flag + back to True always. + 4. Meanwhile between steps 2 and 3, any other token backend checking + for this method should get the flag with a False value. + + | This method should then wait and check again the flag. + | This can be implemented as a call with an incremental backoff + factor to avoid too many calls to the database. + | At a given point in time, the flag will return True. + | Then this method should load the token and finally return False + signaling there is no need to refresh the token. + + | If this returns True, then the Connection will refresh the token. + | If this returns False, then the Connection will NOT refresh the token as it was refreshed by + another instance or thread. + | If this returns None, then this method has already executed the refresh and also updated the access + token into the connection session and therefore the Connection does not have to. + + By default, this always returns True + + There is an example of this in the example's folder. + + + + :param con: the Connection instance passed by the caller. This is passed because maybe + the locking mechanism needs to refresh the token within the lock applied in this method. + :param username: The username from which retrieve the refresh token + :return: | True if the Connection should refresh the token + | False if the Connection should not refresh the token as it was refreshed by another instance + | None if the token was refreshed by this method and therefore the Connection should do nothing. + """ + return True + + +class FileSystemTokenBackend(BaseTokenBackend): + """A token backend based on files on the filesystem""" + + def __init__(self, token_path=None, token_filename=None): + """ + Init Backend + :param str or Path token_path: the path where to store the token + :param str token_filename: the name of the token file + """ + super().__init__() + if not isinstance(token_path, Path): + token_path = Path(token_path) if token_path else Path() + + if token_path.is_file(): + #: Path to the token stored in the file system. |br| **Type:** str + self.token_path = token_path + else: + token_filename = token_filename or "o365_token.txt" + self.token_path = token_path / token_filename + + def __repr__(self): + return str(self.token_path) + + def load_token(self) -> bool: + """ + Retrieves the token from the File System and stores it in the cache + :return bool: Success / Failure + """ + if self.token_path.exists(): + with self.token_path.open("r") as token_file: + token_dict = self.deserialize(token_file.read()) + if "access_token" in token_dict: + raise ValueError( + "The token you are trying to load is not valid anymore. " + "Please delete the token and proceed to authenticate again." + ) + self._cache = token_dict + log.debug(f"Token loaded from {self.token_path}") + return True + return False + + def save_token(self, force=False) -> bool: + """ + Saves the token cache dict in the specified file + Will create the folder if it doesn't exist + :param bool force: Force save even when state has not changed + :return bool: Success / Failure + """ + if not self._cache: + return False + + if force is False and self._has_state_changed is False: + return True + + try: + if not self.token_path.parent.exists(): + self.token_path.parent.mkdir(parents=True) + except Exception as e: + log.error(f"Token could not be saved: {e}") + return False + + with self.token_path.open("w") as token_file: + token_file.write(self.serialize()) + return True + + def delete_token(self) -> bool: + """ + Deletes the token file + :return bool: Success / Failure + """ + if self.token_path.exists(): + self.token_path.unlink() + return True + return False + + def check_token(self) -> bool: + """ + Checks if the token exists in the filesystem + :return bool: True if exists, False otherwise + """ + return self.token_path.exists() + + +class MemoryTokenBackend(BaseTokenBackend): + """A token backend stored in memory.""" + + def __repr__(self): + return "MemoryTokenBackend" + + def load_token(self) -> bool: + return True + + def save_token(self, force=False) -> bool: + return True + + +class EnvTokenBackend(BaseTokenBackend): + """A token backend based on environmental variable.""" + + def __init__(self, token_env_name=None): + """ + Init Backend + :param str token_env_name: the name of the environmental variable that will hold the token + """ + super().__init__() + + #: Name of the environment token (Default - `O365TOKEN`). |br| **Type:** str + self.token_env_name = token_env_name if token_env_name else "O365TOKEN" + + def __repr__(self): + return str(self.token_env_name) + + def load_token(self) -> bool: + """ + Retrieves the token from the environmental variable + :return bool: Success / Failure + """ + if self.token_env_name in os.environ: + self._cache = self.deserialize(os.environ.get(self.token_env_name)) + return True + return False + + def save_token(self, force=False) -> bool: + """ + Saves the token dict in the specified environmental variable + :param bool force: Force save even when state has not changed + :return bool: Success / Failure + """ + if not self._cache: + return False + + if force is False and self._has_state_changed is False: + return True + + os.environ[self.token_env_name] = self.serialize() + + return True + + def delete_token(self) -> bool: + """ + Deletes the token environmental variable + :return bool: Success / Failure + """ + if self.token_env_name in os.environ: + del os.environ[self.token_env_name] + return True + return False + + def check_token(self) -> bool: + """ + Checks if the token exists in the environmental variables + :return bool: True if exists, False otherwise + """ + return self.token_env_name in os.environ + + +class FirestoreBackend(BaseTokenBackend): + """A Google Firestore database backend to store tokens""" + + def __init__(self, client, collection, doc_id, field_name="token"): + """ + Init Backend + :param firestore.Client client: the firestore Client instance + :param str collection: the firestore collection where to store tokens (can be a field_path) + :param str doc_id: # the key of the token document. Must be unique per-case. + :param str field_name: the name of the field that stores the token in the document + """ + super().__init__() + #: Fire store client. |br| **Type:** firestore.Client + self.client = client + #: Fire store collection. |br| **Type:** str + self.collection = collection + #: Fire store token document key. |br| **Type:** str + self.doc_id = doc_id + #: Fire store document reference. |br| **Type:** any + self.doc_ref = client.collection(collection).document(doc_id) + #: Fire store token field name (Default - `token`). |br| **Type:** str + self.field_name = field_name + + def __repr__(self): + return f"Collection: {self.collection}. Doc Id: {self.doc_id}" + + def load_token(self) -> bool: + """ + Retrieves the token from the store + :return bool: Success / Failure + """ + try: + doc = self.doc_ref.get() + except Exception as e: + log.error( + f"Token (collection: {self.collection}, doc_id: {self.doc_id}) " + f"could not be retrieved from the backend: {e}" + ) + doc = None + if doc and doc.exists: + token_str = doc.get(self.field_name) + if token_str: + self._cache = self.deserialize(token_str) + return True + return False + + def save_token(self, force=False) -> bool: + """ + Saves the token dict in the store + :param bool force: Force save even when state has not changed + :return bool: Success / Failure + """ + if not self._cache: + return False + + if force is False and self._has_state_changed is False: + return True + + try: + # set token will overwrite previous data + self.doc_ref.set({self.field_name: self.serialize()}) + except Exception as e: + log.error(f"Token could not be saved: {e}") + return False + + return True + + def delete_token(self) -> bool: + """ + Deletes the token from the store + :return bool: Success / Failure + """ + try: + self.doc_ref.delete() + except Exception as e: + log.error( + f"Could not delete the token (key: {self.doc_id}): {e}" + ) + return False + return True + + def check_token(self) -> bool: + """ + Checks if the token exists + :return bool: True if it exists on the store + """ + try: + doc = self.doc_ref.get() + except Exception as e: + log.error( + f"Token (collection: {self.collection}, doc_id:" + f" {self.doc_id}) could not be retrieved from the backend: {e}" + ) + doc = None + return doc and doc.exists + + +class AWSS3Backend(BaseTokenBackend): + """An AWS S3 backend to store tokens""" + + def __init__(self, bucket_name, filename): + """ + Init Backend + :param str bucket_name: Name of the S3 bucket + :param str filename: Name of the S3 file + """ + try: + import boto3 + except ModuleNotFoundError as e: + raise Exception( + "Please install the boto3 package to use this token backend." + ) from e + super().__init__() + #: S3 bucket name. |br| **Type:** str + self.bucket_name = bucket_name + #: S3 file name. |br| **Type:** str + self.filename = filename + self._client = boto3.client("s3") + + def __repr__(self): + return f"AWSS3Backend('{self.bucket_name}', '{self.filename}')" + + def load_token(self) -> bool: + """ + Retrieves the token from the store + :return bool: Success / Failure + """ + try: + token_object = self._client.get_object( + Bucket=self.bucket_name, Key=self.filename + ) + self._cache = self.deserialize(token_object["Body"].read()) + except Exception as e: + log.error( + f"Token ({self.filename}) could not be retrieved from the backend: {e}" + ) + return False + return True + + def save_token(self, force=False) -> bool: + """ + Saves the token dict in the store + :param bool force: Force save even when state has not changed + :return bool: Success / Failure + """ + if not self._cache: + return False + + if force is False and self._has_state_changed is False: + return True + + token_str = str.encode(self.serialize()) + if self.check_token(): # file already exists + try: + _ = self._client.put_object( + Bucket=self.bucket_name, Key=self.filename, Body=token_str + ) + except Exception as e: + log.error(f"Token file could not be saved: {e}") + return False + else: # create a new token file + try: + r = self._client.put_object( + ACL="private", + Bucket=self.bucket_name, + Key=self.filename, + Body=token_str, + ContentType="text/plain", + ) + except Exception as e: + log.error(f"Token file could not be created: {e}") + return False + + return True + + def delete_token(self) -> bool: + """ + Deletes the token from the store + :return bool: Success / Failure + """ + try: + r = self._client.delete_object(Bucket=self.bucket_name, Key=self.filename) + except Exception as e: + log.error(f"Token file could not be deleted: {e}") + return False + else: + log.warning( + f"Deleted token file {self.filename} in bucket {self.bucket_name}." + ) + return True + + def check_token(self) -> bool: + """ + Checks if the token exists + :return bool: True if it exists on the store + """ + try: + _ = self._client.head_object(Bucket=self.bucket_name, Key=self.filename) + except: + return False + else: + return True + + +class AWSSecretsBackend(BaseTokenBackend): + """An AWS Secrets Manager backend to store tokens""" + + def __init__(self, secret_name, region_name): + """ + Init Backend + :param str secret_name: Name of the secret stored in Secrets Manager + :param str region_name: AWS region hosting the secret (for example, 'us-east-2') + """ + try: + import boto3 + except ModuleNotFoundError as e: + raise Exception( + "Please install the boto3 package to use this token backend." + ) from e + super().__init__() + #: AWS Secret secret name. |br| **Type:** str + self.secret_name = secret_name + #: AWS Secret region name. |br| **Type:** str + self.region_name = region_name + self._client = boto3.client("secretsmanager", region_name=region_name) + + def __repr__(self): + return f"AWSSecretsBackend('{self.secret_name}', '{self.region_name}')" + + def load_token(self) -> bool: + """ + Retrieves the token from the store + :return bool: Success / Failure + """ + try: + get_secret_value_response = self._client.get_secret_value( + SecretId=self.secret_name + ) + token_str = get_secret_value_response["SecretString"] + self._cache = self.deserialize(token_str) + except Exception as e: + log.error( + f"Token (secret: {self.secret_name}) could not be retrieved from the backend: {e}" + ) + return False + + return True + + def save_token(self, force=False) -> bool: + """ + Saves the token dict in the store + :param bool force: Force save even when state has not changed + :return bool: Success / Failure + """ + if not self._cache: + return False + + if force is False and self._has_state_changed is False: + return True + + if self.check_token(): # secret already exists + try: + _ = self._client.update_secret( + SecretId=self.secret_name, SecretString=self.serialize() + ) + except Exception as e: + log.error(f"Token secret could not be saved: {e}") + return False + else: # create a new secret + try: + r = self._client.create_secret( + Name=self.secret_name, + Description="Token generated by the O365 python package (https://pypi.org/project/O365/).", + SecretString=self.serialize(), + ) + except Exception as e: + log.error(f"Token secret could not be created: {e}") + return False + else: + log.warning( + f"\nCreated secret {r['Name']} ({r['ARN']}). Note: using AWS Secrets Manager incurs charges, " + f"please see https://aws.amazon.com/secrets-manager/pricing/ for pricing details.\n" + ) + + return True + + def delete_token(self) -> bool: + """ + Deletes the token from the store + :return bool: Success / Failure + """ + try: + r = self._client.delete_secret( + SecretId=self.secret_name, ForceDeleteWithoutRecovery=True + ) + except Exception as e: + log.error(f"Token secret could not be deleted: {e}") + return False + else: + log.warning(f"Deleted token secret {r['Name']} ({r['ARN']}).") + return True + + def check_token(self) -> bool: + """ + Checks if the token exists + :return bool: True if it exists on the store + """ + try: + _ = self._client.describe_secret(SecretId=self.secret_name) + except: + return False + else: + return True + + +class BitwardenSecretsManagerBackend(BaseTokenBackend): + """A Bitwarden Secrets Manager backend to store tokens""" + + def __init__(self, access_token: str, secret_id: str): + """ + Init Backend + :param str access_token: Access Token used to access the Bitwarden Secrets Manager API + :param str secret_id: ID of Bitwarden Secret used to store the O365 token + """ + try: + from bitwarden_sdk import BitwardenClient + except ModuleNotFoundError as e: + raise Exception( + "Please install the bitwarden-sdk package to use this token backend." + ) from e + super().__init__() + #: Bitwarden client. |br| **Type:** BitWardenClient + self.client = BitwardenClient() + #: Bitwarden login access token. |br| **Type:** str + self.client.auth().login_access_token(access_token) + #: Bitwarden secret is. |br| **Type:** str + self.secret_id = secret_id + #: Bitwarden secret. |br| **Type:** str + self.secret = None + + def __repr__(self): + return f"BitwardenSecretsManagerBackend('{self.secret_id}')" + + def load_token(self) -> bool: + """ + Retrieves the token from Bitwarden Secrets Manager + :return bool: Success / Failure + """ + resp = self.client.secrets().get(self.secret_id) + if not resp.success: + return False + + self.secret = resp.data + + try: + self._cache = self.deserialize(self.secret.value) + return True + except: + logging.warning("Existing token could not be decoded") + return False + + def save_token(self, force=False) -> bool: + """ + Saves the token dict in Bitwarden Secrets Manager + :param bool force: Force save even when state has not changed + :return bool: Success / Failure + """ + if self.secret is None: + raise ValueError('You have to set "self.secret" data first.') + + if not self._cache: + return False + + if force is False and self._has_state_changed is False: + return True + + self.client.secrets().update( + self.secret.id, + self.secret.key, + self.secret.note, + self.secret.organization_id, + self.serialize(), + [self.secret.project_id], + ) + return True + + +class DjangoTokenBackend(BaseTokenBackend): + """ + A Django database token backend to store tokens. To use this backend add the `TokenModel` + model below into your Django application. + + .. code-block:: python + + class TokenModel(models.Model): + token = models.JSONField() + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + def __str__(self): + return f"Token for {self.token.get('client_id', 'unknown')}" + + Example usage: + + .. code-block:: python + + from O365.utils import DjangoTokenBackend + from models import TokenModel + + token_backend = DjangoTokenBackend(token_model=TokenModel) + account = Account(credentials, token_backend=token_backend) + """ + + def __init__(self, token_model=None): + """ + Initializes the DjangoTokenBackend. + + :param token_model: The Django model class to use for storing and retrieving tokens (defaults to TokenModel). + """ + super().__init__() + # Use the provided token_model class + #: Django token model |br| **Type:** TokenModel + self.token_model = token_model + + def __repr__(self): + return "DjangoTokenBackend" + + def load_token(self) -> bool: + """ + Retrieves the latest token from the Django database + :return bool: Success / Failure + """ + + try: + # Retrieve the latest token based on the most recently created record + token_record = self.token_model.objects.latest("created_at") + self._cache = self.deserialize(token_record.token) + except Exception as e: + log.warning(f"No token found in the database, creating a new one: {e}") + return False + + return True + + def save_token(self, force=False) -> bool: + """ + Saves the token dict in the Django database + :param bool force: Force save even when state has not changed + :return bool: Success / Failure + """ + if not self._cache: + return False + + if force is False and self._has_state_changed is False: + return True + + try: + # Create a new token record in the database + self.token_model.objects.create(token=self.serialize()) + except Exception as e: + log.error(f"Token could not be saved: {e}") + return False + + return True + + def delete_token(self) -> bool: + """ + Deletes the latest token from the Django database + :return bool: Success / Failure + """ + try: + # Delete the latest token + token_record = self.token_model.objects.latest("created_at") + token_record.delete() + except Exception as e: + log.error(f"Could not delete token: {e}") + return False + return True + + def check_token(self) -> bool: + """ + Checks if any token exists in the Django database + :return bool: True if it exists, False otherwise + """ + return self.token_model.objects.exists() diff --git a/O365/utils/utils.py b/O365/utils/utils.py new file mode 100644 index 00000000..1289ea9d --- /dev/null +++ b/O365/utils/utils.py @@ -0,0 +1,1333 @@ +import datetime as dt +import logging +from collections import OrderedDict +from enum import Enum +from typing import Dict, Union + +from dateutil.parser import parse +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError + +from .query import QueryBuilder +from .casing import to_snake_case +from .decorators import fluent +from .windows_tz import get_iana_tz, get_windows_tz + +ME_RESOURCE = 'me' +USERS_RESOURCE = 'users' +GROUPS_RESOURCE = 'groups' +SITES_RESOURCE = 'sites' + + +NEXT_LINK_KEYWORD = '@odata.nextLink' + +log = logging.getLogger(__name__) + +MAX_RECIPIENTS_PER_MESSAGE = 500 # Actual limit on Microsoft 365 + + +class CaseEnum(Enum): + """ A Enum that converts the value to a snake_case casing """ + + def __new__(cls, value): + obj = object.__new__(cls) + obj._value_ = to_snake_case(value) # value will be transformed to snake_case + return obj + + @classmethod + def from_value(cls, value): + """ Gets a member by a snaked-case provided value""" + try: + return cls(to_snake_case(value)) + except ValueError: + return None + + +class ImportanceLevel(CaseEnum): + Normal = 'normal' + Low = 'low' + High = 'high' + + +class OutlookWellKnowFolderNames(Enum): + INBOX = 'Inbox' + JUNK = 'JunkEmail' + DELETED = 'DeletedItems' + DRAFTS = 'Drafts' + SENT = 'SentItems' + OUTBOX = 'Outbox' + ARCHIVE = 'Archive' + CLUTTER = 'clutter' + CONFLICTS = 'conflicts' + CONVERSATIONHISTORY = 'conversationhistory' + LOCALFAILURES = 'localfailures' + RECOVERABLEITEMSDELETIONS = 'recoverableitemsdeletions' + SCHEDULED = 'scheduled' + SEARCHFOLDERS = 'searchfolders' + SERVERFAILURES = 'serverfailures' + SYNCISSUES = 'syncissues' + + +class OneDriveWellKnowFolderNames(Enum): + DOCUMENTS = 'documents' + PHOTOS = 'photos' + CAMERA_ROLL = 'cameraroll' + APP_ROOT = 'approot' + MUSIC = 'music' + ATTACHMENTS = 'attachments' + + +class ChainOperator(Enum): + AND = 'and' + OR = 'or' + + +class TrackerSet(set): + def __init__(self, *args, casing=None, **kwargs): + """ A Custom Set that changes the casing of it's keys + + :param func casing: a function to convert into specified case + """ + self.cc = casing + super().__init__(*args, **kwargs) + + def add(self, value): + value = self.cc(value) + super().add(value) + + def remove(self, value): + value = self.cc(value) + super().remove(value) + + +class Recipient: + """ A single Recipient """ + + def __init__(self, address=None, name=None, parent=None, field=None): + """ Create a recipient with provided information + + :param str address: email address of the recipient + :param str name: name of the recipient + :param HandleRecipientsMixin parent: parent recipients handler + :param str field: name of the field to update back + """ + self._address = address or '' + self._name = name or '' + self._parent = parent + self._field = field + + def __bool__(self): + return bool(self.address) + + def __str__(self): + return self.__repr__() + + def __repr__(self): + if self.name: + return '{} <{}>'.format(self.name, self.address) + else: + return self.address + + # noinspection PyProtectedMember + def _track_changes(self): + """ Update the track_changes on the parent to reflect a + needed update on this field """ + if self._field and getattr(self._parent, '_track_changes', + None) is not None: + self._parent._track_changes.add(self._field) + + @property + def address(self): + """ Email address of the recipient + + :getter: Get the email address + :setter: Set and update the email address + :type: str + """ + return self._address + + @address.setter + def address(self, value): + self._address = value + self._track_changes() + + @property + def name(self): + """ Name of the recipient + + :getter: Get the name + :setter: Set and update the name + :type: str + """ + return self._name + + @name.setter + def name(self, value): + self._name = value + self._track_changes() + + +class Recipients: + """ A Sequence of Recipients """ + + def __init__(self, recipients=None, parent=None, field=None): + """ Recipients must be a list of either address strings or + tuples (name, address) or dictionary elements + + :param recipients: list of either address strings or + tuples (name, address) or dictionary elements + :type recipients: list[str] or list[tuple] or list[dict] + or list[Recipient] + :param HandleRecipientsMixin parent: parent recipients handler + :param str field: name of the field to update back + """ + self._parent = parent + self._field = field + self._recipients = [] + self.untrack = True + if recipients: + self.add(recipients) + self.untrack = False + + def __iter__(self): + return iter(self._recipients) + + def __getitem__(self, key): + return self._recipients[key] + + def __contains__(self, item): + return item in {recipient.address for recipient in self._recipients} + + def __bool__(self): + return bool(len(self._recipients)) + + def __len__(self): + return len(self._recipients) + + def __str__(self): + return self.__repr__() + + def __repr__(self): + return 'Recipients count: {}'.format(len(self._recipients)) + + # noinspection PyProtectedMember + def _track_changes(self): + """ Update the track_changes on the parent to reflect a + needed update on this field """ + if self._field and getattr(self._parent, '_track_changes', + None) is not None and self.untrack is False: + self._parent._track_changes.add(self._field) + + def clear(self): + """ Clear the list of recipients """ + self._recipients = [] + self._track_changes() + + def add(self, recipients): + """ Add the supplied recipients to the exiting list + + :param recipients: list of either address strings or + tuples (name, address) or dictionary elements + :type recipients: list[str] or list[tuple] or list[dict] + """ + + if recipients: + if isinstance(recipients, str): + self._recipients.append( + Recipient(address=recipients, parent=self._parent, + field=self._field)) + elif isinstance(recipients, Recipient): + self._recipients.append(recipients) + elif isinstance(recipients, tuple): + name, address = recipients + if address: + self._recipients.append( + Recipient(address=address, name=name, + parent=self._parent, field=self._field)) + elif isinstance(recipients, list): + for recipient in recipients: + self.add(recipient) + else: + raise ValueError('Recipients must be an address string, a ' + 'Recipient instance, a (name, address) ' + 'tuple or a list') + self._track_changes() + + def remove(self, address): + """ Remove an address or multiple addresses + + :param address: list of addresses to remove + :type address: str or list[str] + """ + recipients = [] + if isinstance(address, str): + address = {address} # set + elif isinstance(address, (list, tuple)): + address = set(address) + + for recipient in self._recipients: + if recipient.address not in address: + recipients.append(recipient) + if len(recipients) != len(self._recipients): + self._track_changes() + self._recipients = recipients + + def get_first_recipient_with_address(self): + """ Returns the first recipient found with a non blank address + + :return: First Recipient + :rtype: Recipient + """ + recipients_with_address = [recipient for recipient in self._recipients + if recipient.address] + if recipients_with_address: + return recipients_with_address[0] + else: + return None + + +class HandleRecipientsMixin: + + def _recipients_from_cloud(self, recipients, field=None): + """ Transform a recipient from cloud data to object data """ + recipients_data = [] + for recipient in recipients: + recipients_data.append( + self._recipient_from_cloud(recipient, field=field)) + return Recipients(recipients_data, parent=self, field=field) + + def _recipient_from_cloud(self, recipient, field=None): + """ Transform a recipient from cloud data to object data """ + + if recipient: + recipient = recipient.get(self._cc('emailAddress'), + recipient if isinstance(recipient, + dict) else {}) + address = recipient.get(self._cc('address'), '') + name = recipient.get(self._cc('name'), '') + return Recipient(address=address, name=name, parent=self, + field=field) + else: + return Recipient() + + def _recipient_to_cloud(self, recipient): + """ Transforms a Recipient object to a cloud dict """ + data = None + if recipient: + data = {self._cc('emailAddress'): { + self._cc('address'): recipient.address}} + if recipient.name: + data[self._cc('emailAddress')][ + self._cc('name')] = recipient.name + return data + + +class ApiComponent: + """ Base class for all object interactions with the Cloud Service API + + Exposes common access methods to the api protocol within all Api objects + """ + + _cloud_data_key = '__cloud_data__' # wraps cloud data with this dict key + _endpoints = {} # dict of all API service endpoints needed + + def __init__(self, *, protocol=None, main_resource=None, **kwargs): + """ Object initialization + + :param Protocol protocol: A protocol class or instance to be used with + this connection + :param str main_resource: main_resource to be used in these API + communications + """ + self.protocol = protocol() if isinstance(protocol, type) else protocol + if self.protocol is None: + raise ValueError('Protocol not provided to Api Component') + mr, bu = self.build_base_url(main_resource) + #: The main resource for the components. |br| **Type:** str + self.main_resource = mr + self._base_url = bu + + super().__init__() + + def __str__(self): + return self.__repr__() + + def __repr__(self): + return 'Api Component on resource: {}'.format(self.main_resource) + + @staticmethod + def _parse_resource(resource): + """ Parses and completes resource information """ + resource = resource.strip() if resource else resource + resource_start = list(filter(lambda part: part, resource.split('/')))[0] if resource else resource + + if ':' not in resource_start and '@' not in resource_start: + return resource + + if '@' in resource_start: + # user resource backup + # when for example accessing a shared mailbox the + # resource is set to the email address. we have to prefix + # the email with the resource 'users/' so --> 'users/email_address' + return '{}/{}'.format(USERS_RESOURCE, resource) + elif resource.startswith('user:'): + # user resource shorthand + resource = resource.replace('user:', '', 1) + return '{}/{}'.format(USERS_RESOURCE, resource) + elif resource.startswith('group:'): + # group resource shorthand + resource = resource.replace('group:', '', 1) + return '{}/{}'.format(GROUPS_RESOURCE, resource) + elif resource.startswith('site:'): + # sharepoint site resource shorthand + resource = resource.replace('site:', '', 1) + return '{}/{}'.format(SITES_RESOURCE, resource) + else: + return resource + + def build_base_url(self, resource): + """ + Builds the base url of this ApiComponent + :param str resource: the resource to build the base url + """ + main_resource = self._parse_resource(resource if resource is not None else self.protocol.default_resource) + # noinspection PyUnresolvedReferences + base_url = '{}{}'.format(self.protocol.service_url, main_resource) + if base_url.endswith('/'): + # when self.main_resource is empty then remove the last slash. + base_url = base_url[:-1] + return main_resource, base_url + + def set_base_url(self, resource): + """ + Sets the base urls for this ApiComponent + :param str resource: the resource to build the base url + """ + self.main_resource, self._base_url = self.build_base_url(resource) + + def build_url(self, endpoint): + """ Returns a url for a given endpoint using the protocol + service url + + :param str endpoint: endpoint to build the url for + :return: final url + :rtype: str + """ + return '{}{}'.format(self._base_url, endpoint) + + def _gk(self, keyword): + """ Alias for protocol.get_service_keyword """ + return self.protocol.get_service_keyword(keyword) + + def _cc(self, dict_key): + """ Alias for protocol.convert_case """ + return self.protocol.convert_case(dict_key) + + def _parse_date_time_time_zone(self, + date_time_time_zone: Union[dict, str], + is_all_day: bool = False) -> Union[dt.datetime, None]: + """ + Parses and convert to protocol timezone a dateTimeTimeZone resource + This resource is a dict with a date time and a windows timezone + This is a common structure on Microsoft apis so it's included here. + + Returns a dt.datetime with the datime converted to protocol timezone + """ + if date_time_time_zone is None: + return None + + local_tz = self.protocol.timezone + if isinstance(date_time_time_zone, dict): + try: + timezone = get_iana_tz(date_time_time_zone.get(self._cc('timeZone'), 'UTC')) + except ZoneInfoNotFoundError: + log.debug('TimeZone not found. Using protocol timezone instead.') + timezone = local_tz + date_time = date_time_time_zone.get(self._cc('dateTime'), None) + try: + date_time = parse(date_time).replace(tzinfo=timezone) if date_time else None + except OverflowError as e: + log.debug(f'Could not parse dateTimeTimeZone: {date_time_time_zone}. Error: {e}') + date_time = None + + if date_time and timezone != local_tz: + if not is_all_day: + date_time = date_time.astimezone(local_tz) + else: + date_time = date_time.replace(tzinfo=local_tz) + else: + # Outlook v1.0 api compatibility (fallback to datetime string) + try: + date_time = parse(date_time_time_zone).replace(tzinfo=local_tz) if date_time_time_zone else None + except Exception as e: + log.debug(f'Could not parse dateTimeTimeZone: {date_time_time_zone}. Error: {e}') + date_time = None + + return date_time + + def _build_date_time_time_zone(self, date_time: dt.datetime) -> Dict[str, str]: + """ Converts a datetime to a dateTimeTimeZone resource Dict[datetime, windows timezone] """ + timezone = None + + # extract timezone ZoneInfo from provided datetime + if date_time.tzinfo is not None: + if isinstance(date_time.tzinfo, ZoneInfo): + timezone = date_time.tzinfo + elif isinstance(date_time.tzinfo, dt.tzinfo): + try: + timezone = ZoneInfo(date_time.tzinfo.tzname(date_time)) + except ZoneInfoNotFoundError as e: + log.error(f'Error while converting datetime.tzinfo to Zoneinfo: ' + f'{date_time.tzinfo.tzname(date_time)}') + raise e + else: + raise ValueError("Unexpected tzinfo class. Can't convert to ZoneInfo.") + + # convert ZoneInfo timezone (IANA) to a string windows timezone + timezone = get_windows_tz(timezone or self.protocol.timezone) + + return { + self._cc('dateTime'): date_time.strftime('%Y-%m-%dT%H:%M:%S'), + self._cc('timeZone'): timezone + } + + def new_query(self) -> QueryBuilder: + """ Create a new query to filter results + + :param str attribute: attribute to apply the query for + :return: new QueryBuilder + :rtype: QueryBuilder + """ + return QueryBuilder(protocol=self.protocol) + + q = new_query # alias for new query + + +class Pagination(ApiComponent): + """ Utility class that allows batching requests to the server """ + + def __init__(self, *, parent=None, data=None, constructor=None, + next_link=None, limit=None, **kwargs): + """Returns an iterator that returns data until it's exhausted. + Then will request more data (same amount as the original request) + to the server until this data is exhausted as well. + Stops when no more data exists or limit is reached. + + :param parent: the parent class. Must implement attributes: + con, api_version, main_resource + :param data: the start data to be return + :param constructor: the data constructor for the next batch. + It can be a function. + :param str next_link: the link to request more data to + :param int limit: when to stop retrieving more data + :param kwargs: any extra key-word arguments to pass to the + constructor. + """ + if parent is None: + raise ValueError('Parent must be another Api Component') + + super().__init__(protocol=parent.protocol, + main_resource=parent.main_resource) + + #: The parent. |br| **Type:** any + self.parent = parent + self.con = parent.con + #: The constructor. |br| **Type:** any + self.constructor = constructor + #: The next link for the pagination. |br| **Type:** str + self.next_link = next_link + #: The limit of when to stop. |br| **Type:** int + self.limit = limit + #: The start data. |br| **Type:** any + self.data = data = list(data) if data else [] + + data_count = len(data) + if limit and limit < data_count: + #: Data count. |br| **Type:** int + self.data_count = limit + #: Total count. |br| **Type:** int + self.total_count = limit + else: + self.data_count = data_count + self.total_count = data_count + #: State. |br| **Type:** int + self.state = 0 + #: Extra args. |br| **Type:** dict + self.extra_args = kwargs + + def __str__(self): + return self.__repr__() + + def __repr__(self): + if callable(self.constructor) and not isinstance( + self.constructor, type): + return 'Pagination Iterator' + else: + return "'{}' Iterator".format( + self.constructor.__name__ if self.constructor else 'Unknown') + + def __bool__(self): + return bool(self.data) or bool(self.next_link) + + def __iter__(self): + return self + + def __next__(self): + if self.state < self.data_count: + value = self.data[self.state] + self.state += 1 + return value + else: + if self.limit and self.total_count >= self.limit: + raise StopIteration() + + if self.next_link is None: + raise StopIteration() + + response = self.con.get(self.next_link) + if not response: + raise StopIteration() + + data = response.json() + + self.next_link = data.get(NEXT_LINK_KEYWORD, None) or None + data = data.get('value', []) + if self.constructor: + # Everything from cloud must be passed as self._cloud_data_key + self.data = [] + kwargs = {} + kwargs.update(self.extra_args) + if callable(self.constructor) and not isinstance(self.constructor, type): + for value in data: + kwargs[self._cloud_data_key] = value + self.data.append(self.constructor(value)(parent=self.parent, **kwargs)) + else: + for value in data: + kwargs[self._cloud_data_key] = value + self.data.append(self.constructor(parent=self.parent, **kwargs)) + else: + self.data = data + + items_count = len(data) + if self.limit: + dif = self.limit - (self.total_count + items_count) + if dif < 0: + self.data = self.data[:dif] + self.next_link = None # stop batching + items_count = items_count + dif + if items_count: + self.data_count = items_count + self.total_count += items_count + self.state = 0 + value = self.data[self.state] + self.state += 1 + return value + else: + raise StopIteration() + + +class Query: + """ Helper to conform OData filters """ + _mapping = { + 'from': 'from/emailAddress/address', + 'to': 'toRecipients/emailAddress/address', + 'start': 'start/DateTime', + 'end': 'end/DateTime', + 'due': 'duedatetime/DateTime', + 'reminder': 'reminderdatetime/DateTime', + 'flag': 'flag/flagStatus', + 'body': 'body/content' + } + + def __init__(self, attribute=None, *, protocol): + """ Build a query to apply OData filters + https://docs.microsoft.com/en-us/graph/query-parameters + + :param str attribute: attribute to apply the query for + :param Protocol protocol: protocol to use for connecting + """ + #: Protocol to use. |br| **Type:** protocol + self.protocol = protocol() if isinstance(protocol, type) else protocol + self._attribute = None + self._chain = None + self.new(attribute) + self._negation = False + self._filters = [] # store all the filters + self._order_by = OrderedDict() + self._selects = set() + self._expands = set() + self._search = None + self._open_group_flag = [] # stores if the next attribute must be grouped + self._close_group_flag = [] # stores if the last attribute must be closing a group + + def __str__(self): + return 'Filter: {}\nOrder: {}\nSelect: {}\nExpand: {}\nSearch: {}'.format(self.get_filters(), + self.get_order(), + self.get_selects(), + self.get_expands(), + self._search) + + def __repr__(self): + return self.__str__() + + @fluent + def select(self, *attributes): + """ Adds the attribute to the $select parameter + + :param str attributes: the attributes tuple to select. + If empty, the on_attribute previously set is added. + :rtype: Query + """ + if attributes: + for attribute in attributes: + attribute = self.protocol.convert_case( + attribute) if attribute and isinstance(attribute, + str) else None + if attribute: + if '/' in attribute: + # only parent attribute can be selected + attribute = attribute.split('/')[0] + attribute = self._get_select_mapping(attribute) + self._selects.add(attribute) + else: + if self._attribute: + self._selects.add(self._attribute) + + return self + + @fluent + def expand(self, *relationships): + """ + Adds the relationships (e.g. "event" or "attachments") + that should be expanded with the $expand parameter + Important: The ApiComponent using this should know how to handle this relationships. + + eg: Message knows how to handle attachments, and event (if it's an EventMessage) + + Important: When using expand on multi-value relationships a max of 20 items will be returned. + + :param str relationships: the relationships tuple to expand. + :rtype: Query + """ + + for relationship in relationships: + if relationship == "event": + relationship = "{}/event".format( + self.protocol.get_service_keyword("event_message_type") + ) + self._expands.add(relationship) + + return self + + @fluent + def search(self, text): + """ + Perform a search. + Not from graph docs: + + You can currently search only message and person collections. + A $search request returns up to 250 results. + You cannot use $filter or $orderby in a search request. + + :param str text: the text to search + :return: the Query instance + """ + if text is None: + self._search = None + else: + # filters an order are not allowed + self.clear_filters() + self.clear_order() + self._search = '"{}"'.format(text) + + return self + + def as_params(self): + """ Returns the filters, orders, select, expands and search as query parameters + + :rtype: dict + """ + params = {} + if self.has_filters: + params['$filter'] = self.get_filters() + if self.has_order: + params['$orderby'] = self.get_order() + if self.has_expands and not self.has_selects: + params['$expand'] = self.get_expands() + if self.has_selects and not self.has_expands: + params['$select'] = self.get_selects() + if self.has_expands and self.has_selects: + params['$expand'] = '{}($select={})'.format(self.get_expands(), self.get_selects()) + if self._search: + params['$search'] = self._search + params.pop('$filter', None) + params.pop('$orderby', None) + return params + + @property + def has_filters(self): + """ Whether the query has filters or not + + :rtype: bool + """ + return bool(self._filters) + + @property + def has_order(self): + """ Whether the query has order_by or not + + :rtype: bool + """ + return bool(self._order_by) + + @property + def has_selects(self): + """ Whether the query has select filters or not + + :rtype: bool + """ + return bool(self._selects) + + @property + def has_expands(self): + """ Whether the query has relationships that should be expanded or not + + :rtype: bool + """ + return bool(self._expands) + + def get_filters(self): + """ Returns the result filters + + :rtype: str or None + """ + if self._filters: + filters_list = self._filters + if isinstance(filters_list[-1], Enum): + filters_list = filters_list[:-1] + filters = ' '.join( + [fs.value if isinstance(fs, Enum) else fs[1] for fs in filters_list] + ).strip() + + # closing opened groups automatically + open_groups = len([x for x in self._open_group_flag if x is False]) + for i in range(open_groups - len(self._close_group_flag)): + filters += ')' + + return filters + else: + return None + + def get_order(self): + """ Returns the result order by clauses + + :rtype: str or None + """ + # first get the filtered attributes in order as they must appear + # in the order_by first + if not self.has_order: + return None + + return ','.join(['{} {}'.format(attribute, direction or '').strip() + for attribute, direction in self._order_by.items()]) + + def get_selects(self): + """ Returns the result select clause + + :rtype: str or None + """ + if self._selects: + return ','.join(self._selects) + else: + return None + + def get_expands(self): + """ Returns the result expand clause + + :rtype: str or None + """ + if self._expands: + return ','.join(self._expands) + else: + return None + + def _get_mapping(self, attribute): + if attribute: + mapping = self._mapping.get(attribute) + if mapping: + attribute = '/'.join( + [self.protocol.convert_case(step) for step in + mapping.split('/')]) + else: + attribute = self.protocol.convert_case(attribute) + return attribute + return None + + def _get_select_mapping(self, attribute): + if attribute.lower() in ["meetingMessageType"]: + return ( + f"{self.protocol.keyword_data_store['event_message_type']}/{attribute}" + ) + return attribute + + @fluent + def new(self, attribute, operation=ChainOperator.AND): + """ Combine with a new query + + :param str attribute: attribute of new query + :param ChainOperator operation: operation to combine to new query + :rtype: Query + """ + if isinstance(operation, str): + operation = ChainOperator(operation) + self._chain = operation + self._attribute = self._get_mapping(attribute) if attribute else None + self._negation = False + return self + + def clear_filters(self): + """ Clear filters """ + self._filters = [] + + def clear_order(self): + """ Clears any order commands """ + self._order_by = OrderedDict() + + @fluent + def clear(self): + """ Clear everything + + :rtype: Query + """ + self._filters = [] + self._order_by = OrderedDict() + self._selects = set() + self._negation = False + self._attribute = None + self._chain = None + self._search = None + self._open_group_flag = [] + self._close_group_flag = [] + + return self + + @fluent + def negate(self): + """ Apply a not operator + + :rtype: Query + """ + self._negation = not self._negation + return self + + @fluent + def chain(self, operation=ChainOperator.AND): + """ Start a chain operation + + :param ChainOperator, str operation: how to combine with a new one + :rtype: Query + """ + if isinstance(operation, str): + operation = ChainOperator(operation) + self._chain = operation + return self + + @fluent + def on_attribute(self, attribute): + """ Apply query on attribute, to be used along with chain() + + :param str attribute: attribute name + :rtype: Query + """ + self._attribute = self._get_mapping(attribute) + return self + + @fluent + def on_list_field(self, field): + """ Apply query on a list field, to be used along with chain() + + :param str field: field name (note: name is case sensitive) + :rtype: Query + """ + self._attribute = 'fields/' + field + return self + + def remove_filter(self, filter_attr): + """ Removes a filter given the attribute name """ + filter_attr = self._get_mapping(filter_attr) + new_filters = [] + remove_chain = False + + for flt in self._filters: + if isinstance(flt, list): + if flt[0] == filter_attr: + remove_chain = True + else: + new_filters.append(flt) + else: + # this is a ChainOperator + if remove_chain is False: + new_filters.append(flt) + else: + remove_chain = False + + self._filters = new_filters + + def _add_filter(self, *filter_data): + if self._attribute: + if self._filters and not isinstance(self._filters[-1], + ChainOperator): + self._filters.append(self._chain) + sentence, attrs = filter_data + for i, group in enumerate(self._open_group_flag): + if group is True or group is None: + # Open a group: None Flags a group that is negated + if group is True: + sentence = '(' + sentence + else: + sentence = 'not (' + sentence + self._open_group_flag[i] = False # set to done + self._filters.append([self._attribute, sentence, attrs]) + else: + raise ValueError( + 'Attribute property needed. call on_attribute(attribute) ' + 'or new(attribute)') + + def _parse_filter_word(self, word): + """ Converts the word parameter into the correct format """ + if isinstance(word, str): + word = "'{}'".format(word) + elif isinstance(word, dt.date): + if isinstance(word, dt.datetime): + if word.tzinfo is None: + # if it's a naive datetime, localize the datetime. + word = word.replace(tzinfo=self.protocol.timezone) # localize datetime into local tz + if '/' in self._attribute: + # TODO: this is a fix for the case when the parameter + # filtered is a string instead a dateTimeOffset + # but checking the '/' is not correct, but it will + # differentiate for now the case on events: + # start/dateTime (date is a string here) from + # the case on other dates such as + # receivedDateTime (date is a dateTimeOffset) + word = "'{}'".format( + word.isoformat()) # convert datetime to isoformat. + else: + word = "{}".format( + word.isoformat()) # convert datetime to isoformat + elif isinstance(word, bool): + word = str(word).lower() + elif word is None: + word = 'null' + return word + + @staticmethod + def _prepare_sentence(attribute, operation, word, negation=False): + negation = 'not' if negation else '' + attrs = (negation, attribute, operation, word) + sentence = '{} {} {} {}'.format(negation, attribute, operation, word).strip() + return sentence, attrs + + @fluent + def logical_operator(self, operation, word): + """ Apply a logical operator + + :param str operation: how to combine with a new one + :param word: other parameter for the operation + (a = b) would be like a.logical_operator('eq', 'b') + :rtype: Query + """ + word = self._parse_filter_word(word) + # consume negation + negation = self._negation + if negation: + self._negation = False + self._add_filter( + *self._prepare_sentence(self._attribute, operation, word, negation) + ) + return self + + @fluent + def equals(self, word): + """ Add an equals check + + :param word: word to compare with + :rtype: Query + """ + return self.logical_operator('eq', word) + + @fluent + def unequal(self, word): + """ Add an unequals check + + :param word: word to compare with + :rtype: Query + """ + return self.logical_operator('ne', word) + + @fluent + def greater(self, word): + """ Add a greater than check + + :param word: word to compare with + :rtype: Query + """ + return self.logical_operator('gt', word) + + @fluent + def greater_equal(self, word): + """ Add a greater than or equal to check + + :param word: word to compare with + :rtype: Query + """ + return self.logical_operator('ge', word) + + @fluent + def less(self, word): + """ Add a less than check + + :param word: word to compare with + :rtype: Query + """ + return self.logical_operator('lt', word) + + @fluent + def less_equal(self, word): + """ Add a less than or equal to check + + :param word: word to compare with + :rtype: Query + """ + return self.logical_operator('le', word) + + @staticmethod + def _prepare_function(function_name, attribute, word, negation=False): + negation = 'not' if negation else '' + attrs = (negation, attribute, function_name, word) + return "{} {}({}, {})".format(negation, function_name, attribute, word).strip(), attrs + + @fluent + def function(self, function_name, word): + """ Apply a function on given word + + :param str function_name: function to apply + :param str word: word to apply function on + :rtype: Query + """ + word = self._parse_filter_word(word) + # consume negation + negation = self._negation + if negation: + self._negation = False + self._add_filter( + *self._prepare_function(function_name, self._attribute, word, negation)) + return self + + @fluent + def contains(self, word): + """ Adds a contains word check + + :param str word: word to check + :rtype: Query + """ + return self.function('contains', word) + + @fluent + def startswith(self, word): + """ Adds a startswith word check + + :param str word: word to check + :rtype: Query + """ + return self.function('startswith', word) + + @fluent + def endswith(self, word): + """ Adds a endswith word check + + :param str word: word to check + :rtype: Query + """ + return self.function('endswith', word) + + @fluent + def iterable(self, iterable_name, *, collection, word, attribute=None, func=None, + operation=None, negation=False): + """ Performs a filter with the OData 'iterable_name' keyword + on the collection + + For example: + q.iterable('any', collection='email_addresses', attribute='address', + operation='eq', word='george@best.com') + + will transform to a filter such as: + emailAddresses/any(a:a/address eq 'george@best.com') + + :param str iterable_name: the OData name of the iterable + :param str collection: the collection to apply the 'any' keyword on + :param str word: the word to check + :param str attribute: the attribute of the collection to check + :param str func: the logical function to apply to the attribute inside + the collection + :param str operation: the logical operation to apply to the attribute + inside the collection + :param bool negation: negate the function or operation inside the iterable + :rtype: Query + """ + + if func is None and operation is None: + raise ValueError('Provide a function or an operation to apply') + elif func is not None and operation is not None: + raise ValueError('Provide either a function or an operation but not both') + + current_att = self._attribute + self._attribute = iterable_name + + word = self._parse_filter_word(word) + collection = self._get_mapping(collection) + attribute = self._get_mapping(attribute) + + if attribute is None: + attribute = 'a' # it's the same iterated object + else: + attribute = 'a/{}'.format(attribute) + + if func is not None: + sentence = self._prepare_function(func, attribute, word, negation) + else: + sentence = self._prepare_sentence(attribute, operation, word, negation) + + filter_str, attrs = sentence + + # consume negation + negation = 'not' if self._negation else '' + if self._negation: + self._negation = False + + filter_data = '{} {}/{}(a:{})'.format(negation, collection, iterable_name, filter_str).strip(), attrs + self._add_filter(*filter_data) + + self._attribute = current_att + + return self + + @fluent + def any(self, *, collection, word, attribute=None, func=None, operation=None, negation=False): + """ Performs a filter with the OData 'any' keyword on the collection + + For example: + q.any(collection='email_addresses', attribute='address', + operation='eq', word='george@best.com') + + will transform to a filter such as: + + emailAddresses/any(a:a/address eq 'george@best.com') + + :param str collection: the collection to apply the 'any' keyword on + :param str word: the word to check + :param str attribute: the attribute of the collection to check + :param str func: the logical function to apply to the attribute + inside the collection + :param str operation: the logical operation to apply to the + attribute inside the collection + :param bool negation: negates the function or operation inside the iterable + :rtype: Query + """ + + return self.iterable('any', collection=collection, word=word, + attribute=attribute, func=func, operation=operation, + negation=negation) + + @fluent + def all(self, *, collection, word, attribute=None, func=None, operation=None, negation=False): + """ Performs a filter with the OData 'all' keyword on the collection + + For example: + q.any(collection='email_addresses', attribute='address', + operation='eq', word='george@best.com') + + will transform to a filter such as: + + emailAddresses/all(a:a/address eq 'george@best.com') + + :param str collection: the collection to apply the any keyword on + :param str word: the word to check + :param str attribute: the attribute of the collection to check + :param str func: the logical function to apply to the attribute + inside the collection + :param str operation: the logical operation to apply to the + attribute inside the collection + :param bool negation: negate the function or operation inside the iterable + :rtype: Query + """ + + return self.iterable('all', collection=collection, word=word, + attribute=attribute, func=func, operation=operation, + negation=negation) + + @fluent + def order_by(self, attribute=None, *, ascending=True): + """ Applies a order_by clause + + :param str attribute: attribute to apply on + :param bool ascending: should it apply ascending order or descending + :rtype: Query + """ + attribute = self._get_mapping(attribute) or self._attribute + if attribute: + self._order_by[attribute] = None if ascending else 'desc' + else: + raise ValueError( + 'Attribute property needed. call on_attribute(attribute) ' + 'or new(attribute)') + return self + + def open_group(self): + """ Applies a precedence grouping in the next filters """ + # consume negation + if self._negation: + self._negation = False + self._open_group_flag.append(None) # flag a negated group open with None + else: + self._open_group_flag.append(True) + return self + + def close_group(self): + """ Closes a grouping for previous filters """ + if self._filters: + if len(self._open_group_flag) < (len(self._close_group_flag) + 1): + raise RuntimeError('Not enough open groups to close.') + if isinstance(self._filters[-1], ChainOperator): + flt_sentence = self._filters[-2] + else: + flt_sentence = self._filters[-1] + + flt_sentence[1] = flt_sentence[1] + ')' # closing the group + self._close_group_flag.append(False) # flag a close group was added + else: + raise RuntimeError("No filters present. Can't close a group") + return self + + def get_filter_by_attribute(self, attribute): + """ + Returns a filter value by attribute name. It will match the attribute to the start of each filter attribute + and return the first found. + + :param attribute: the attribute you want to search + :return: The value applied to that attribute or None + """ + + attribute = attribute.lower() + + # iterate over the filters to find the corresponding attribute + for query_data in self._filters: + if not isinstance(query_data, list): + continue + filter_attribute = query_data[0] + # the 2nd position contains the filter data + # and the 3rd position in filter_data contains the value + word = query_data[2][3] + + if filter_attribute.lower().startswith(attribute): + return word + return None \ No newline at end of file diff --git a/O365/utils/windows_tz.py b/O365/utils/windows_tz.py new file mode 100644 index 00000000..1c43f638 --- /dev/null +++ b/O365/utils/windows_tz.py @@ -0,0 +1,640 @@ +""" +Mapping from iana timezones to windows timezones and vice versa +""" +from datetime import tzinfo +from zoneinfo import ZoneInfoNotFoundError, ZoneInfo + +# noinspection SpellCheckingInspection +IANA_TO_WIN = { + "Africa/Abidjan": "Greenwich Standard Time", + "Africa/Accra": "Greenwich Standard Time", + "Africa/Addis_Ababa": "E. Africa Standard Time", + "Africa/Algiers": "W. Central Africa Standard Time", + "Africa/Asmara": "E. Africa Standard Time", + "Africa/Asmera": "E. Africa Standard Time", + "Africa/Bamako": "Greenwich Standard Time", + "Africa/Bangui": "W. Central Africa Standard Time", + "Africa/Banjul": "Greenwich Standard Time", + "Africa/Bissau": "Greenwich Standard Time", + "Africa/Blantyre": "South Africa Standard Time", + "Africa/Brazzaville": "W. Central Africa Standard Time", + "Africa/Bujumbura": "South Africa Standard Time", + "Africa/Cairo": "Egypt Standard Time", + "Africa/Casablanca": "Morocco Standard Time", + "Africa/Ceuta": "Romance Standard Time", + "Africa/Conakry": "Greenwich Standard Time", + "Africa/Dakar": "Greenwich Standard Time", + "Africa/Dar_es_Salaam": "E. Africa Standard Time", + "Africa/Djibouti": "E. Africa Standard Time", + "Africa/Douala": "W. Central Africa Standard Time", + "Africa/El_Aaiun": "Morocco Standard Time", + "Africa/Freetown": "Greenwich Standard Time", + "Africa/Gaborone": "South Africa Standard Time", + "Africa/Harare": "South Africa Standard Time", + "Africa/Johannesburg": "South Africa Standard Time", + "Africa/Juba": "E. Africa Standard Time", + "Africa/Kampala": "E. Africa Standard Time", + "Africa/Khartoum": "Sudan Standard Time", + "Africa/Kigali": "South Africa Standard Time", + "Africa/Kinshasa": "W. Central Africa Standard Time", + "Africa/Lagos": "W. Central Africa Standard Time", + "Africa/Libreville": "W. Central Africa Standard Time", + "Africa/Lome": "Greenwich Standard Time", + "Africa/Luanda": "W. Central Africa Standard Time", + "Africa/Lubumbashi": "South Africa Standard Time", + "Africa/Lusaka": "South Africa Standard Time", + "Africa/Malabo": "W. Central Africa Standard Time", + "Africa/Maputo": "South Africa Standard Time", + "Africa/Maseru": "South Africa Standard Time", + "Africa/Mbabane": "South Africa Standard Time", + "Africa/Mogadishu": "E. Africa Standard Time", + "Africa/Monrovia": "Greenwich Standard Time", + "Africa/Nairobi": "E. Africa Standard Time", + "Africa/Ndjamena": "W. Central Africa Standard Time", + "Africa/Niamey": "W. Central Africa Standard Time", + "Africa/Nouakchott": "Greenwich Standard Time", + "Africa/Ouagadougou": "Greenwich Standard Time", + "Africa/Porto-Novo": "W. Central Africa Standard Time", + "Africa/Sao_Tome": "Sao Tome Standard Time", + "Africa/Timbuktu": "Greenwich Standard Time", + "Africa/Tripoli": "Libya Standard Time", + "Africa/Tunis": "W. Central Africa Standard Time", + "Africa/Windhoek": "Namibia Standard Time", + "America/Adak": "Aleutian Standard Time", + "America/Anchorage": "Alaskan Standard Time", + "America/Anguilla": "SA Western Standard Time", + "America/Antigua": "SA Western Standard Time", + "America/Araguaina": "Tocantins Standard Time", + "America/Argentina/Buenos_Aires": "Argentina Standard Time", + "America/Argentina/Catamarca": "Argentina Standard Time", + "America/Argentina/ComodRivadavia": "Argentina Standard Time", + "America/Argentina/Cordoba": "Argentina Standard Time", + "America/Argentina/Jujuy": "Argentina Standard Time", + "America/Argentina/La_Rioja": "Argentina Standard Time", + "America/Argentina/Mendoza": "Argentina Standard Time", + "America/Argentina/Rio_Gallegos": "Argentina Standard Time", + "America/Argentina/Salta": "Argentina Standard Time", + "America/Argentina/San_Juan": "Argentina Standard Time", + "America/Argentina/San_Luis": "Argentina Standard Time", + "America/Argentina/Tucuman": "Argentina Standard Time", + "America/Argentina/Ushuaia": "Argentina Standard Time", + "America/Aruba": "SA Western Standard Time", + "America/Asuncion": "Paraguay Standard Time", + "America/Atikokan": "SA Pacific Standard Time", + "America/Atka": "Aleutian Standard Time", + "America/Bahia": "Bahia Standard Time", + "America/Bahia_Banderas": "Central Standard Time (Mexico)", + "America/Barbados": "SA Western Standard Time", + "America/Belem": "SA Eastern Standard Time", + "America/Belize": "Central America Standard Time", + "America/Blanc-Sablon": "SA Western Standard Time", + "America/Boa_Vista": "SA Western Standard Time", + "America/Bogota": "SA Pacific Standard Time", + "America/Boise": "Mountain Standard Time", + "America/Buenos_Aires": "Argentina Standard Time", + "America/Cambridge_Bay": "Mountain Standard Time", + "America/Campo_Grande": "Central Brazilian Standard Time", + "America/Cancun": "Eastern Standard Time (Mexico)", + "America/Caracas": "Venezuela Standard Time", + "America/Catamarca": "Argentina Standard Time", + "America/Cayenne": "SA Eastern Standard Time", + "America/Cayman": "SA Pacific Standard Time", + "America/Chicago": "Central Standard Time", + "America/Chihuahua": "Mountain Standard Time (Mexico)", + "America/Coral_Harbour": "SA Pacific Standard Time", + "America/Cordoba": "Argentina Standard Time", + "America/Costa_Rica": "Central America Standard Time", + "America/Creston": "US Mountain Standard Time", + "America/Cuiaba": "Central Brazilian Standard Time", + "America/Curacao": "SA Western Standard Time", + "America/Danmarkshavn": "UTC", + "America/Dawson": "Pacific Standard Time", + "America/Dawson_Creek": "US Mountain Standard Time", + "America/Denver": "Mountain Standard Time", + "America/Detroit": "Eastern Standard Time", + "America/Dominica": "SA Western Standard Time", + "America/Edmonton": "Mountain Standard Time", + "America/Eirunepe": "SA Pacific Standard Time", + "America/El_Salvador": "Central America Standard Time", + "America/Ensenada": "Pacific Standard Time (Mexico)", + "America/Fort_Nelson": "US Mountain Standard Time", + "America/Fort_Wayne": "US Eastern Standard Time", + "America/Fortaleza": "SA Eastern Standard Time", + "America/Glace_Bay": "Atlantic Standard Time", + "America/Godthab": "Greenland Standard Time", + "America/Goose_Bay": "Atlantic Standard Time", + "America/Grand_Turk": "Turks And Caicos Standard Time", + "America/Grenada": "SA Western Standard Time", + "America/Guadeloupe": "SA Western Standard Time", + "America/Guatemala": "Central America Standard Time", + "America/Guayaquil": "SA Pacific Standard Time", + "America/Guyana": "SA Western Standard Time", + "America/Halifax": "Atlantic Standard Time", + "America/Havana": "Cuba Standard Time", + "America/Hermosillo": "US Mountain Standard Time", + "America/Indiana/Indianapolis": "US Eastern Standard Time", + "America/Indiana/Knox": "Central Standard Time", + "America/Indiana/Marengo": "US Eastern Standard Time", + "America/Indiana/Petersburg": "Eastern Standard Time", + "America/Indiana/Tell_City": "Central Standard Time", + "America/Indiana/Vevay": "US Eastern Standard Time", + "America/Indiana/Vincennes": "Eastern Standard Time", + "America/Indiana/Winamac": "Eastern Standard Time", + "America/Indianapolis": "US Eastern Standard Time", + "America/Inuvik": "Mountain Standard Time", + "America/Iqaluit": "Eastern Standard Time", + "America/Jamaica": "SA Pacific Standard Time", + "America/Jujuy": "Argentina Standard Time", + "America/Juneau": "Alaskan Standard Time", + "America/Kentucky/Louisville": "Eastern Standard Time", + "America/Kentucky/Monticello": "Eastern Standard Time", + "America/Knox_IN": "Central Standard Time", + "America/Kralendijk": "SA Western Standard Time", + "America/La_Paz": "SA Western Standard Time", + "America/Lima": "SA Pacific Standard Time", + "America/Los_Angeles": "Pacific Standard Time", + "America/Louisville": "Eastern Standard Time", + "America/Lower_Princes": "SA Western Standard Time", + "America/Maceio": "SA Eastern Standard Time", + "America/Managua": "Central America Standard Time", + "America/Manaus": "SA Western Standard Time", + "America/Marigot": "SA Western Standard Time", + "America/Martinique": "SA Western Standard Time", + "America/Matamoros": "Central Standard Time", + "America/Mazatlan": "Mountain Standard Time (Mexico)", + "America/Mendoza": "Argentina Standard Time", + "America/Menominee": "Central Standard Time", + "America/Merida": "Central Standard Time (Mexico)", + "America/Metlakatla": "Alaskan Standard Time", + "America/Mexico_City": "Central Standard Time (Mexico)", + "America/Miquelon": "Saint Pierre Standard Time", + "America/Moncton": "Atlantic Standard Time", + "America/Monterrey": "Central Standard Time (Mexico)", + "America/Montevideo": "Montevideo Standard Time", + "America/Montreal": "Eastern Standard Time", + "America/Montserrat": "SA Western Standard Time", + "America/Nassau": "Eastern Standard Time", + "America/New_York": "Eastern Standard Time", + "America/Nipigon": "Eastern Standard Time", + "America/Nome": "Alaskan Standard Time", + "America/Noronha": "UTC-02", + "America/North_Dakota/Beulah": "Central Standard Time", + "America/North_Dakota/Center": "Central Standard Time", + "America/North_Dakota/New_Salem": "Central Standard Time", + "America/Ojinaga": "Mountain Standard Time", + "America/Panama": "SA Pacific Standard Time", + "America/Pangnirtung": "Eastern Standard Time", + "America/Paramaribo": "SA Eastern Standard Time", + "America/Phoenix": "US Mountain Standard Time", + "America/Port-au-Prince": "Haiti Standard Time", + "America/Port_of_Spain": "SA Western Standard Time", + "America/Porto_Acre": "SA Pacific Standard Time", + "America/Porto_Velho": "SA Western Standard Time", + "America/Puerto_Rico": "SA Western Standard Time", + "America/Punta_Arenas": "Magallanes Standard Time", + "America/Rainy_River": "Central Standard Time", + "America/Rankin_Inlet": "Central Standard Time", + "America/Recife": "SA Eastern Standard Time", + "America/Regina": "Canada Central Standard Time", + "America/Resolute": "Central Standard Time", + "America/Rio_Branco": "SA Pacific Standard Time", + "America/Rosario": "Argentina Standard Time", + "America/Santa_Isabel": "Pacific Standard Time (Mexico)", + "America/Santarem": "SA Eastern Standard Time", + "America/Santiago": "Pacific SA Standard Time", + "America/Santo_Domingo": "SA Western Standard Time", + "America/Sao_Paulo": "E. South America Standard Time", + "America/Scoresbysund": "Azores Standard Time", + "America/Shiprock": "Mountain Standard Time", + "America/Sitka": "Alaskan Standard Time", + "America/St_Barthelemy": "SA Western Standard Time", + "America/St_Johns": "Newfoundland Standard Time", + "America/St_Kitts": "SA Western Standard Time", + "America/St_Lucia": "SA Western Standard Time", + "America/St_Thomas": "SA Western Standard Time", + "America/St_Vincent": "SA Western Standard Time", + "America/Swift_Current": "Canada Central Standard Time", + "America/Tegucigalpa": "Central America Standard Time", + "America/Thule": "Atlantic Standard Time", + "America/Thunder_Bay": "Eastern Standard Time", + "America/Tijuana": "Pacific Standard Time (Mexico)", + "America/Toronto": "Eastern Standard Time", + "America/Tortola": "SA Western Standard Time", + "America/Vancouver": "Pacific Standard Time", + "America/Virgin": "SA Western Standard Time", + "America/Whitehorse": "Pacific Standard Time", + "America/Winnipeg": "Central Standard Time", + "America/Yakutat": "Alaskan Standard Time", + "America/Yellowknife": "Mountain Standard Time", + "Antarctica/Casey": "W. Australia Standard Time", + "Antarctica/Davis": "SE Asia Standard Time", + "Antarctica/DumontDUrville": "West Pacific Standard Time", + "Antarctica/Macquarie": "Central Pacific Standard Time", + "Antarctica/Mawson": "West Asia Standard Time", + "Antarctica/McMurdo": "New Zealand Standard Time", + "Antarctica/Palmer": "Magallanes Standard Time", + "Antarctica/Rothera": "SA Eastern Standard Time", + "Antarctica/South_Pole": "New Zealand Standard Time", + "Antarctica/Syowa": "E. Africa Standard Time", + "Antarctica/Vostok": "Central Asia Standard Time", + "Arctic/Longyearbyen": "W. Europe Standard Time", + "Asia/Aden": "Arab Standard Time", + "Asia/Almaty": "Central Asia Standard Time", + "Asia/Amman": "Jordan Standard Time", + "Asia/Anadyr": "Russia Time Zone 11", + "Asia/Aqtau": "West Asia Standard Time", + "Asia/Aqtobe": "West Asia Standard Time", + "Asia/Ashgabat": "West Asia Standard Time", + "Asia/Ashkhabad": "West Asia Standard Time", + "Asia/Atyrau": "West Asia Standard Time", + "Asia/Baghdad": "Arabic Standard Time", + "Asia/Bahrain": "Arab Standard Time", + "Asia/Baku": "Azerbaijan Standard Time", + "Asia/Bangkok": "SE Asia Standard Time", + "Asia/Barnaul": "Altai Standard Time", + "Asia/Beirut": "Middle East Standard Time", + "Asia/Bishkek": "Central Asia Standard Time", + "Asia/Brunei": "Singapore Standard Time", + "Asia/Calcutta": "India Standard Time", + "Asia/Chita": "Transbaikal Standard Time", + "Asia/Choibalsan": "Ulaanbaatar Standard Time", + "Asia/Chongqing": "China Standard Time", + "Asia/Chungking": "China Standard Time", + "Asia/Colombo": "Sri Lanka Standard Time", + "Asia/Dacca": "Bangladesh Standard Time", + "Asia/Damascus": "Syria Standard Time", + "Asia/Dhaka": "Bangladesh Standard Time", + "Asia/Dili": "Tokyo Standard Time", + "Asia/Dubai": "Arabian Standard Time", + "Asia/Dushanbe": "West Asia Standard Time", + "Asia/Famagusta": "GTB Standard Time", + "Asia/Gaza": "West Bank Standard Time", + "Asia/Harbin": "China Standard Time", + "Asia/Hebron": "West Bank Standard Time", + "Asia/Ho_Chi_Minh": "SE Asia Standard Time", + "Asia/Hong_Kong": "China Standard Time", + "Asia/Hovd": "W. Mongolia Standard Time", + "Asia/Irkutsk": "North Asia East Standard Time", + "Asia/Istanbul": "Turkey Standard Time", + "Asia/Jakarta": "SE Asia Standard Time", + "Asia/Jayapura": "Tokyo Standard Time", + "Asia/Jerusalem": "Israel Standard Time", + "Asia/Kabul": "Afghanistan Standard Time", + "Asia/Kamchatka": "Kamchatka Standard Time", + "Asia/Karachi": "Pakistan Standard Time", + "Asia/Kashgar": "Central Asia Standard Time", + "Asia/Kathmandu": "Nepal Standard Time", + "Asia/Katmandu": "Nepal Standard Time", + "Asia/Khandyga": "Yakutsk Standard Time", + "Asia/Kolkata": "India Standard Time", + "Asia/Krasnoyarsk": "North Asia Standard Time", + "Asia/Kuala_Lumpur": "Singapore Standard Time", + "Asia/Kuching": "Singapore Standard Time", + "Asia/Kuwait": "Arab Standard Time", + "Asia/Macao": "China Standard Time", + "Asia/Macau": "China Standard Time", + "Asia/Magadan": "Magadan Standard Time", + "Asia/Makassar": "Singapore Standard Time", + "Asia/Manila": "Singapore Standard Time", + "Asia/Muscat": "Arabian Standard Time", + "Asia/Nicosia": "GTB Standard Time", + "Asia/Novokuznetsk": "North Asia Standard Time", + "Asia/Novosibirsk": "N. Central Asia Standard Time", + "Asia/Omsk": "Omsk Standard Time", + "Asia/Oral": "West Asia Standard Time", + "Asia/Phnom_Penh": "SE Asia Standard Time", + "Asia/Pontianak": "SE Asia Standard Time", + "Asia/Pyongyang": "North Korea Standard Time", + "Asia/Qatar": "Arab Standard Time", + "Asia/Qostanay": "Central Asia Standard Time", + "Asia/Qyzylorda": "Qyzylorda Standard Time", + "Asia/Rangoon": "Myanmar Standard Time", + "Asia/Riyadh": "Arab Standard Time", + "Asia/Saigon": "SE Asia Standard Time", + "Asia/Sakhalin": "Sakhalin Standard Time", + "Asia/Samarkand": "West Asia Standard Time", + "Asia/Seoul": "Korea Standard Time", + "Asia/Shanghai": "China Standard Time", + "Asia/Singapore": "Singapore Standard Time", + "Asia/Srednekolymsk": "Russia Time Zone 10", + "Asia/Taipei": "Taipei Standard Time", + "Asia/Tashkent": "West Asia Standard Time", + "Asia/Tbilisi": "Georgian Standard Time", + "Asia/Tehran": "Iran Standard Time", + "Asia/Tel_Aviv": "Israel Standard Time", + "Asia/Thimbu": "Bangladesh Standard Time", + "Asia/Thimphu": "Bangladesh Standard Time", + "Asia/Tokyo": "Tokyo Standard Time", + "Asia/Tomsk": "Tomsk Standard Time", + "Asia/Ujung_Pandang": "Singapore Standard Time", + "Asia/Ulaanbaatar": "Ulaanbaatar Standard Time", + "Asia/Ulan_Bator": "Ulaanbaatar Standard Time", + "Asia/Urumqi": "Central Asia Standard Time", + "Asia/Ust-Nera": "Vladivostok Standard Time", + "Asia/Vientiane": "SE Asia Standard Time", + "Asia/Vladivostok": "Vladivostok Standard Time", + "Asia/Yakutsk": "Yakutsk Standard Time", + "Asia/Yangon": "Myanmar Standard Time", + "Asia/Yekaterinburg": "Ekaterinburg Standard Time", + "Asia/Yerevan": "Caucasus Standard Time", + "Atlantic/Azores": "Azores Standard Time", + "Atlantic/Bermuda": "Atlantic Standard Time", + "Atlantic/Canary": "GMT Standard Time", + "Atlantic/Cape_Verde": "Cape Verde Standard Time", + "Atlantic/Faeroe": "GMT Standard Time", + "Atlantic/Faroe": "GMT Standard Time", + "Atlantic/Jan_Mayen": "W. Europe Standard Time", + "Atlantic/Madeira": "GMT Standard Time", + "Atlantic/Reykjavik": "Greenwich Standard Time", + "Atlantic/South_Georgia": "UTC-02", + "Atlantic/St_Helena": "Greenwich Standard Time", + "Atlantic/Stanley": "SA Eastern Standard Time", + "Australia/ACT": "AUS Eastern Standard Time", + "Australia/Adelaide": "Cen. Australia Standard Time", + "Australia/Brisbane": "E. Australia Standard Time", + "Australia/Broken_Hill": "Cen. Australia Standard Time", + "Australia/Canberra": "AUS Eastern Standard Time", + "Australia/Currie": "Tasmania Standard Time", + "Australia/Darwin": "AUS Central Standard Time", + "Australia/Eucla": "Aus Central W. Standard Time", + "Australia/Hobart": "Tasmania Standard Time", + "Australia/LHI": "Lord Howe Standard Time", + "Australia/Lindeman": "E. Australia Standard Time", + "Australia/Lord_Howe": "Lord Howe Standard Time", + "Australia/Melbourne": "AUS Eastern Standard Time", + "Australia/NSW": "AUS Eastern Standard Time", + "Australia/North": "AUS Central Standard Time", + "Australia/Perth": "W. Australia Standard Time", + "Australia/Queensland": "E. Australia Standard Time", + "Australia/South": "Cen. Australia Standard Time", + "Australia/Sydney": "AUS Eastern Standard Time", + "Australia/Tasmania": "Tasmania Standard Time", + "Australia/Victoria": "AUS Eastern Standard Time", + "Australia/West": "W. Australia Standard Time", + "Australia/Yancowinna": "Cen. Australia Standard Time", + "Brazil/Acre": "SA Pacific Standard Time", + "Brazil/DeNoronha": "UTC-02", + "Brazil/East": "E. South America Standard Time", + "Brazil/West": "SA Western Standard Time", + "CET": "Romance Standard Time", + "CST6CDT": "Central Standard Time", + "Canada/Atlantic": "Atlantic Standard Time", + "Canada/Central": "Central Standard Time", + "Canada/East-Saskatchewan": "Canada Central Standard Time", + "Canada/Eastern": "Eastern Standard Time", + "Canada/Mountain": "Mountain Standard Time", + "Canada/Newfoundland": "Newfoundland Standard Time", + "Canada/Pacific": "Pacific Standard Time", + "Canada/Saskatchewan": "Canada Central Standard Time", + "Canada/Yukon": "Pacific Standard Time", + "Chile/Continental": "Pacific SA Standard Time", + "Chile/EasterIsland": "Easter Island Standard Time", + "Cuba": "Cuba Standard Time", + "EET": "GTB Standard Time", + "EST": "SA Pacific Standard Time", + "EST5EDT": "Eastern Standard Time", + "Egypt": "Egypt Standard Time", + "Eire": "GMT Standard Time", + "Etc/GMT": "UTC", + "Etc/GMT+0": "UTC", + "Etc/GMT+1": "Cape Verde Standard Time", + "Etc/GMT+10": "Hawaiian Standard Time", + "Etc/GMT+11": "UTC-11", + "Etc/GMT+12": "Dateline Standard Time", + "Etc/GMT+2": "Mid-Atlantic Standard Time", + "Etc/GMT+3": "SA Eastern Standard Time", + "Etc/GMT+4": "SA Western Standard Time", + "Etc/GMT+5": "SA Pacific Standard Time", + "Etc/GMT+6": "Central America Standard Time", + "Etc/GMT+7": "US Mountain Standard Time", + "Etc/GMT+8": "UTC-08", + "Etc/GMT+9": "UTC-09", + "Etc/GMT-0": "UTC", + "Etc/GMT-1": "W. Central Africa Standard Time", + "Etc/GMT-10": "West Pacific Standard Time", + "Etc/GMT-11": "Central Pacific Standard Time", + "Etc/GMT-12": "UTC+12", + "Etc/GMT-13": "UTC+13", + "Etc/GMT-14": "Line Islands Standard Time", + "Etc/GMT-2": "South Africa Standard Time", + "Etc/GMT-3": "E. Africa Standard Time", + "Etc/GMT-4": "Arabian Standard Time", + "Etc/GMT-5": "West Asia Standard Time", + "Etc/GMT-6": "Central Asia Standard Time", + "Etc/GMT-7": "SE Asia Standard Time", + "Etc/GMT-8": "Singapore Standard Time", + "Etc/GMT-9": "Tokyo Standard Time", + "Etc/GMT0": "UTC", + "Etc/Greenwich": "UTC", + "Etc/UCT": "UTC", + "Etc/UTC": "UTC", + "Etc/Universal": "UTC", + "Etc/Zulu": "UTC", + "Europe/Amsterdam": "W. Europe Standard Time", + "Europe/Andorra": "W. Europe Standard Time", + "Europe/Astrakhan": "Astrakhan Standard Time", + "Europe/Athens": "GTB Standard Time", + "Europe/Belfast": "GMT Standard Time", + "Europe/Belgrade": "Central European Standard Time", + "Europe/Berlin": "W. Europe Standard Time", + "Europe/Bratislava": "Central Europe Standard Time", + "Europe/Brussels": "Romance Standard Time", + "Europe/Bucharest": "GTB Standard Time", + "Europe/Budapest": "Central Europe Standard Time", + "Europe/Busingen": "W. Europe Standard Time", + "Europe/Chisinau": "E. Europe Standard Time", + "Europe/Copenhagen": "Romance Standard Time", + "Europe/Dublin": "GMT Standard Time", + "Europe/Gibraltar": "W. Europe Standard Time", + "Europe/Guernsey": "GMT Standard Time", + "Europe/Helsinki": "FLE Standard Time", + "Europe/Isle_of_Man": "GMT Standard Time", + "Europe/Istanbul": "Turkey Standard Time", + "Europe/Jersey": "GMT Standard Time", + "Europe/Kaliningrad": "Kaliningrad Standard Time", + "Europe/Kyiv": "FLE Standard Time", + "Europe/Kiev": "FLE Standard Time", + "Europe/Kirov": "Russian Standard Time", + "Europe/Lisbon": "GMT Standard Time", + "Europe/Ljubljana": "Central European Standard Time", + "Europe/London": "GMT Standard Time", + "Europe/Luxembourg": "W. Europe Standard Time", + "Europe/Madrid": "Romance Standard Time", + "Europe/Malta": "W. Europe Standard Time", + "Europe/Mariehamn": "FLE Standard Time", + "Europe/Minsk": "Belarus Standard Time", + "Europe/Monaco": "W. Europe Standard Time", + "Europe/Moscow": "Russian Standard Time", + "Europe/Nicosia": "GTB Standard Time", + "Europe/Oslo": "W. Europe Standard Time", + "Europe/Paris": "Romance Standard Time", + "Europe/Podgorica": "Central European Standard Time", + "Europe/Prague": "Central Europe Standard Time", + "Europe/Riga": "FLE Standard Time", + "Europe/Rome": "W. Europe Standard Time", + "Europe/Samara": "Russia Time Zone 3", + "Europe/San_Marino": "W. Europe Standard Time", + "Europe/Sarajevo": "Central European Standard Time", + "Europe/Saratov": "Saratov Standard Time", + "Europe/Simferopol": "Russian Standard Time", + "Europe/Skopje": "Central European Standard Time", + "Europe/Sofia": "FLE Standard Time", + "Europe/Stockholm": "W. Europe Standard Time", + "Europe/Tallinn": "FLE Standard Time", + "Europe/Tirane": "Central Europe Standard Time", + "Europe/Tiraspol": "E. Europe Standard Time", + "Europe/Ulyanovsk": "Astrakhan Standard Time", + "Europe/Uzhgorod": "FLE Standard Time", + "Europe/Vaduz": "W. Europe Standard Time", + "Europe/Vatican": "W. Europe Standard Time", + "Europe/Vienna": "W. Europe Standard Time", + "Europe/Vilnius": "FLE Standard Time", + "Europe/Volgograd": "Volgograd Standard Time", + "Europe/Warsaw": "Central European Standard Time", + "Europe/Zagreb": "Central European Standard Time", + "Europe/Zaporozhye": "FLE Standard Time", + "Europe/Zurich": "W. Europe Standard Time", + "GB": "GMT Standard Time", + "GB-Eire": "GMT Standard Time", + "GMT": "UTC", + "GMT+0": "UTC", + "GMT-0": "UTC", + "GMT0": "UTC", + "Greenwich": "UTC", + "HST": "Hawaiian Standard Time", + "Hongkong": "China Standard Time", + "Iceland": "Greenwich Standard Time", + "Indian/Antananarivo": "E. Africa Standard Time", + "Indian/Chagos": "Central Asia Standard Time", + "Indian/Christmas": "SE Asia Standard Time", + "Indian/Cocos": "Myanmar Standard Time", + "Indian/Comoro": "E. Africa Standard Time", + "Indian/Kerguelen": "West Asia Standard Time", + "Indian/Mahe": "Mauritius Standard Time", + "Indian/Maldives": "West Asia Standard Time", + "Indian/Mauritius": "Mauritius Standard Time", + "Indian/Mayotte": "E. Africa Standard Time", + "Indian/Reunion": "Mauritius Standard Time", + "Iran": "Iran Standard Time", + "Israel": "Israel Standard Time", + "Jamaica": "SA Pacific Standard Time", + "Japan": "Tokyo Standard Time", + "Kwajalein": "UTC+12", + "Libya": "Libya Standard Time", + "MET": "W. Europe Standard Time", + "MST": "US Mountain Standard Time", + "MST7MDT": "Mountain Standard Time", + "Mexico/BajaNorte": "Pacific Standard Time (Mexico)", + "Mexico/BajaSur": "Mountain Standard Time (Mexico)", + "Mexico/General": "Central Standard Time (Mexico)", + "NZ": "New Zealand Standard Time", + "NZ-CHAT": "Chatham Islands Standard Time", + "Navajo": "Mountain Standard Time", + "PRC": "China Standard Time", + "PST8PDT": "Pacific Standard Time", + "Pacific/Apia": "Samoa Standard Time", + "Pacific/Auckland": "New Zealand Standard Time", + "Pacific/Bougainville": "Bougainville Standard Time", + "Pacific/Chatham": "Chatham Islands Standard Time", + "Pacific/Chuuk": "West Pacific Standard Time", + "Pacific/Easter": "Easter Island Standard Time", + "Pacific/Efate": "Central Pacific Standard Time", + "Pacific/Enderbury": "UTC+13", + "Pacific/Fakaofo": "UTC+13", + "Pacific/Fiji": "Fiji Standard Time", + "Pacific/Funafuti": "UTC+12", + "Pacific/Galapagos": "Central America Standard Time", + "Pacific/Gambier": "UTC-09", + "Pacific/Guadalcanal": "Central Pacific Standard Time", + "Pacific/Guam": "West Pacific Standard Time", + "Pacific/Honolulu": "Hawaiian Standard Time", + "Pacific/Johnston": "Hawaiian Standard Time", + "Pacific/Kiritimati": "Line Islands Standard Time", + "Pacific/Kosrae": "Central Pacific Standard Time", + "Pacific/Kwajalein": "UTC+12", + "Pacific/Majuro": "UTC+12", + "Pacific/Marquesas": "Marquesas Standard Time", + "Pacific/Midway": "UTC-11", + "Pacific/Nauru": "UTC+12", + "Pacific/Niue": "UTC-11", + "Pacific/Norfolk": "Norfolk Standard Time", + "Pacific/Noumea": "Central Pacific Standard Time", + "Pacific/Pago_Pago": "UTC-11", + "Pacific/Palau": "Tokyo Standard Time", + "Pacific/Pitcairn": "UTC-08", + "Pacific/Pohnpei": "Central Pacific Standard Time", + "Pacific/Ponape": "Central Pacific Standard Time", + "Pacific/Port_Moresby": "West Pacific Standard Time", + "Pacific/Rarotonga": "Hawaiian Standard Time", + "Pacific/Saipan": "West Pacific Standard Time", + "Pacific/Samoa": "UTC-11", + "Pacific/Tahiti": "Hawaiian Standard Time", + "Pacific/Tarawa": "UTC+12", + "Pacific/Tongatapu": "Tonga Standard Time", + "Pacific/Truk": "West Pacific Standard Time", + "Pacific/Wake": "UTC+12", + "Pacific/Wallis": "UTC+12", + "Pacific/Yap": "West Pacific Standard Time", + "Poland": "Central European Standard Time", + "Portugal": "GMT Standard Time", + "ROC": "Taipei Standard Time", + "ROK": "Korea Standard Time", + "Singapore": "Singapore Standard Time", + "Turkey": "Turkey Standard Time", + "UCT": "UTC", + "US/Alaska": "Alaskan Standard Time", + "US/Aleutian": "Aleutian Standard Time", + "US/Arizona": "US Mountain Standard Time", + "US/Central": "Central Standard Time", + "US/East-Indiana": "US Eastern Standard Time", + "US/Eastern": "Eastern Standard Time", + "US/Hawaii": "Hawaiian Standard Time", + "US/Indiana-Starke": "Central Standard Time", + "US/Michigan": "Eastern Standard Time", + "US/Mountain": "Mountain Standard Time", + "US/Pacific": "Pacific Standard Time", + "US/Pacific-New": "Pacific Standard Time", + "US/Samoa": "UTC-11", + "UTC": "UTC", + "Universal": "UTC", + "W-SU": "Russian Standard Time", + "WET": "GMT Standard Time", + "Zulu": "UTC" +} + +# when converting to Iana, only consider for win UTC the value Iana UTC +WIN_TO_IANA = {v: k for k, v in IANA_TO_WIN.items() if v != 'UTC' or (v == 'UTC' and k == 'UTC')} + + +def get_iana_tz(windows_tz: str) -> ZoneInfo: + """ Returns a valid pytz TimeZone (Iana/Olson Timezones) from a given + windows TimeZone + + :param windows_tz: windows format timezone usually returned by + microsoft api response + """ + timezone: str = WIN_TO_IANA.get(windows_tz) + if timezone is None: + # Nope, that didn't work. Try adding "Standard Time", + # it seems to work a lot of times: + timezone = WIN_TO_IANA.get(windows_tz + ' Standard Time') + + # Return what we have. + if timezone is None: + raise ZoneInfoNotFoundError(f"Can't find Windows TimeZone: {windows_tz}") + + return ZoneInfo(timezone) + + +def get_windows_tz(iana_tz: ZoneInfo) -> str: + """ Returns a valid windows TimeZone from a given pytz TimeZone + (Iana/Olson Timezones) + Note: Windows Timezones are SHIT!... no ... really THEY ARE + HOLY FUCKING SHIT!. + """ + timezone = IANA_TO_WIN.get( + iana_tz.key if isinstance(iana_tz, tzinfo) else iana_tz) + if timezone is None: + raise ZoneInfoNotFoundError(f"Can't find Iana timezone: {iana_tz.key}") + + return timezone diff --git a/README.md b/README.md index ca6f4226..3fc6b300 100644 --- a/README.md +++ b/README.md @@ -1,150 +1,55 @@ -# Python-O365 - Office365 for you server +[![Downloads](https://pepy.tech/badge/O365)](https://pepy.tech/project/O365) +[![PyPI](https://img.shields.io/pypi/v/O365.svg)](https://pypi.python.org/pypi/O365) +[![PyPI pyversions](https://img.shields.io/pypi/pyversions/O365.svg)](https://pypi.python.org/pypi/O365/) -The objective O365 is to make it easy to make utilities that are to be run against an Office 365 account. If you wanted to script sending an email it could be as simple as: +# O365 - Microsoft Graph and related APIs made easy -```python -from O365 import Message -authenticiation = ('YourAccount@office365.com','YourPassword') -m = Message(auth=authenticiation) -m.setRecipients('reciving@office365.com') -m.setSubject('I made an email script.') -m.setBody('Talk to the computer, cause the human does not want to hear it any more.') -m.sendMessage() -``` - -To keep the library simple but powerful there are wrapper methods to access most of the attributes: -```python -m.setBody('a body!') -``` - -But all attributes listed on the documenation for the [Office365 API](https://msdn.microsoft.com/office/office365/APi/api-catalog) are available through the json representation stored in the instance of every O365 object: -```python -if m.json['IsReadReceiptRequested']: - m.reply('Got it.') -``` - -## Table of contents +This project aims to make interacting with the Microsoft api, and related apis, easy to do in a Pythonic way. +Access to Email, Calendar, Contacts, OneDrive, Sharepoint, etc. Are easy to do in a way that feel easy and straight forward to beginners and feels just right to seasoned python programmer. -- [Email](#email) -- [Calendar](#calendar) -- [Contacts](#contacts) -- [Files - One Drive](#files) +The project is currently developed and maintained by [alejcas](https://github.com/alejcas). -## Email -There are two classes for working with emails in O365. -#### Inbox -A collection of emails. This is used when ever you are requesting an email or emails. It can be set with filters so that you only download the emails which your script is interested in. -#### Message -An actual email with all it's associated data. +#### Core developers +- [Alejcas](https://github.com/alejcas) +- [Toben Archer](https://github.com/Narcolapser) +- [Geethanadh](https://github.com/GeethanadhP) -In the [Fetch File](https://github.com/Narcolapser/python-o365/blob/master/examples/fetchFile.py) example a filter is used to get only the unread messages with the subject line "Fetch File" -```python -i = Inbox(e,p,getNow=False) #Email, Password, Delay fetching so I can change the filters. - -i.setFilter("IsRead eq false & Subject eq 'Fetch File'") +**We are always open to new pull requests!** -i.getMessages() -``` +## Detailed docs and api reference on [O365 Docs site](https://o365.github.io/python-o365/latest/index.html) -When the inbox has run it's getMessages method, whether when it is instanced or later, all the messages it retrieves will be stored in a list local to the instance of inbox. Inbox.messages +### Quick example on sending a message: -While the Inbox class is used exclusively for incoming mail, as the name might imply, the message class is incoming and out going. In the fetch file example in it's processMessage method it work with both an incoming message, "m", and prepares an out going message, "resp": ```python -def processMessage(m): - path = m.json['BodyPreview'] - - path = path[:path.index('\n')] - if path[-1] == '\r': - path = path[:-1] - - att = Attachment(path=path) +from O365 import Account - resp = Message(auth=auth) - resp.setRecipients(m.getSender()) +credentials = ('client_id', 'client_secret') - resp.setSubject('Your file sir!') - resp.setBody(path) - resp.attachments.append(att) - resp.sendMessage() - - return True +account = Account(credentials) +m = account.new_message() +m.to.add('to_example@example.com') +m.subject = 'Testing!' +m.body = "George Best quote: I've stopped drinking, but only while I'm asleep." +m.send() ``` -In this method we pull the BodyPreview, less likely to have Markup, and pull out it's first line to get the path to a file. That path is then sent to the attachment class and a response message is created and sent. Simple and straight forward. -The attachment class is a relatively simple class for handling downloading and creating attachments. Attachments in Office365 are stored seperately from the email in most cases and as such will have to be downloaded and uploaded seperately as well. This however is also taken care of behind the scenes with O365. Simply call a message's getAttachments method to download the attachments locally to your process. This creates a list of attachments local to the instance of Message, as is seen in the [Email Printing example](https://github.com/Narcolapser/python-o365/blob/master/examples/EmailPrinting/emailprinting.py): -```python -m.fetchAttachments() -for att in m.attachments: - processAttachment(att,resp) -#various un-related bits left out for brevity. -``` -The attachment class stores the files as base64 encoded files. But this doesn't matter to you! The attachment class can work with you if you want to just send/recieve raw binary or base64. You can also just give it a path to a file if you want to creat an attachment: -```python -att = Attachment(path=path) -``` -or if you want to save the file -``` -att.save(path) -``` - -## Calendar -Events are on a Calendar, Calendars are grouped into a Schedule. In the [Vehicle Booking](https://github.com/Narcolapser/python-o365/blob/master/examples/VehicleBookings/veh.py) example the purpose of the script is to create a json file with information to be imported into another program for presentation. We want to know all of the times the vehicles are booked out, for each vehicle, and by who, etc. This is done by simple getting the schedule and calendar for each vehicle and spitting out it's events: -```python -for veh in vj: - e = veh['email'] - p = veh['password'] - - schedule = Schedule(e,p) - try: - result = schedule.getCalendars() - print 'Fetched calendars for',e,'was successful:',result - except: - print 'Login failed for',e - - bookings = [] - - for cal in schedule.calendars: - print 'attempting to fetch events for',e - try: - result = cal.getEvents() - print 'Got events',result,'got',len(cal.events) - except: - print 'failed to fetch events' - print 'attempting for event information' - for event in cal.events: - print 'HERE!' - bookings.append(event.fullcalendarioJson()) - json_outs[e] = bookings -``` - -Events can be made relatively easily too. You just have to create a event class: -```python -e = Event(authentication,parentCalendar) -``` -and give it a few nesessary details: -```python -import time -e.setSubject('Coffee!') -e.setStart(time.gmtime(time.time()+3600)) #start an hour from now. -e.setEnd(time.gmtime(time.time()+7200)) #end two hours from now. -new_e = e.create() -``` - -## Contacts -Contacts are a small part of this library, but can have their use. You can store email addresses in your contacts list in folders and then use this as a form of mailing list: -```python -e = 'youremail@office365.com' -p = 'embarrassingly simple password.' -group = Group(e,p,'Contact folder name') -m = Message(auth=(e,p)) -m.setSubject('News for today') -m.setBody(open('news.html','r').read()) -m.setRecipients(group) -m.sendMessage() -``` -## Files -This will be a new feature in 0.8. Coming soon. +### Why choose O365? +- Almost Full Support for MsGraph Rest Api. +- Good Abstraction layer for the Api. +- Full oauth support with automatic handling of refresh tokens. +- Automatic handling between local datetimes and server datetimes. Work with your local datetime and let this library do the rest. +- Change between different resource with ease: access shared mailboxes, other users resources, SharePoint resources, etc. +- Pagination support through a custom iterator that handles future requests automatically. Request Infinite items! +- A query helper to help you build custom OData queries (filter, order, select and search). +- Modular ApiComponents can be created and built to achieve further functionality. +___ -#### Soli Deo Gloria +This project was also a learning resource for us. This is a list of not so common python idioms used in this project: +- New unpacking technics: `def method(argument, *, with_name=None, **other_params):` +- Enums: `from enum import Enum` +- Factory paradigm +- Package organization +- Timezone conversion and timezone aware datetimes +- Etc. ([see the code!](https://github.com/O365/python-o365/tree/master/O365)) diff --git a/build_docs.sh b/build_docs.sh new file mode 100755 index 00000000..b8745e10 --- /dev/null +++ b/build_docs.sh @@ -0,0 +1 @@ +sphinx-build -b html -c ./docs/source/ ./docs/source/ ./docs/latest/ diff --git a/devdocs/15 03 17 b/devdocs/15 03 17 deleted file mode 100644 index 5ce5efde..00000000 --- a/devdocs/15 03 17 +++ /dev/null @@ -1,41 +0,0 @@ -I'm starting to have some change of mind in how this whole library is structured. It has been x -sensible enough to have the information saved as local variables, but I think that in the long run -that won't be as useful as it seems. I'm leaning towards using instead json to store all the -information for the class. It feels a little weird to be honest. Why even have classes then? Well, I -think the reason for that would be that people using this are still going to be thinking object -oriented programming as they try to use it. - -I wonder if there is a way I could blend it. Like using getattr to make the json elements seem like -local variables. must test! - -haha! score: - ->>> class Val: -... def __init__(self,bob): -... self.bob = bob -... def __getattr__(self,name): -... return self.bob[name] -... ->>> v = Val(j) ->>> v.bar -2 ->>> v.foo -5 ->>> - -I shall integrate this into my classes. That will allow the classes to fake having the json as -their local variables. - -__getattr__ -__setattr__ - - -*tries to implement* - -Ok. that was a bad idea. The problem is that this leads to a lot of infinite recussion. So I'm -probably going to give up this idea and simply move to just having a class.json arrangement. this -would allow me to have the json and any time a user wants data or wants to change something, he -can do it through that. - -I like the idea of having the json form the data structure, even if it is a little ridiculous. - diff --git a/devdocs/15 04 20 b/devdocs/15 04 20 deleted file mode 100644 index 8ffa5ab8..00000000 --- a/devdocs/15 04 20 +++ /dev/null @@ -1,5 +0,0 @@ -Getting tonsilitous doesn't help with development. - -I've gotten O365 uploaded to PyPi and to my suprise in 10 days there have been 246 downloads! Awesome! But this means I need to work out a lot of the problems. - -I've got a lot of proper work setup on github as well. Milestones and issues and such to help me keep track of what I'm trying to do. It's going along quite nicely. diff --git a/docs/.nojekyll b/docs/.nojekyll new file mode 100644 index 00000000..e69de29b diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 00000000..67460c14 --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,19 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line. +SPHINXOPTS = +SPHINXBUILD = sphinx-build +SOURCEDIR = source +BUILDDIR = latest + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/index.html b/docs/index.html new file mode 100644 index 00000000..db88adca --- /dev/null +++ b/docs/index.html @@ -0,0 +1,6 @@ + + + + + + diff --git a/docs/latest/.buildinfo b/docs/latest/.buildinfo new file mode 100644 index 00000000..19816749 --- /dev/null +++ b/docs/latest/.buildinfo @@ -0,0 +1,4 @@ +# Sphinx build info version 1 +# This file records the configuration used when building these files. When it is not found, a full rebuild will be done. +config: 4cc798a6c0cfb63a7c23fbec72cf8b54 +tags: 645f666f9bcd5a90fca523b33c5a78b7 diff --git a/docs/latest/.buildinfo.bak b/docs/latest/.buildinfo.bak new file mode 100644 index 00000000..0ed96ae6 --- /dev/null +++ b/docs/latest/.buildinfo.bak @@ -0,0 +1,4 @@ +# Sphinx build info version 1 +# This file records the configuration used when building these files. When it is not found, a full rebuild will be done. +config: 7c4370ffb66904ca9b2ae0e7eb0059ce +tags: 645f666f9bcd5a90fca523b33c5a78b7 diff --git a/docs/latest/.nojekyll b/docs/latest/.nojekyll new file mode 100644 index 00000000..e69de29b diff --git a/docs/latest/_modules/O365/account.html b/docs/latest/_modules/O365/account.html new file mode 100644 index 00000000..23355d44 --- /dev/null +++ b/docs/latest/_modules/O365/account.html @@ -0,0 +1,457 @@ + + + + + + + + + + + O365.account — O365 documentation + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + +
+ +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+
+
+
+ +

Source code for O365.account

+from .connection import Connection, Protocol, MSGraphProtocol
+from .utils import ME_RESOURCE
+
+
+
[docs]class Account: + + connection_constructor = Connection + +
[docs] def __init__(self, credentials, *, protocol=None, main_resource=None, **kwargs): + """ Creates an object which is used to access resources related to the + specified credentials + + :param tuple credentials: a tuple containing the client_id + and client_secret + :param Protocol protocol: the protocol to be used in this account + :param str main_resource: the resource to be used by this account + ('me' or 'users', etc.) + :param kwargs: any extra args to be passed to the Connection instance + :raises ValueError: if an invalid protocol is passed + """ + + protocol = protocol or MSGraphProtocol # Defaults to Graph protocol + self.protocol = protocol(default_resource=main_resource, + **kwargs) if isinstance(protocol, + type) else protocol + + if not isinstance(self.protocol, Protocol): + raise ValueError("'protocol' must be a subclass of Protocol") + + auth_flow_type = kwargs.get('auth_flow_type', 'authorization') + scopes = kwargs.get('scopes', None) # retrieve scopes + + if auth_flow_type in ('authorization', 'public'): + # convert the provided scopes to protocol scopes: + if scopes is not None: + kwargs['scopes'] = self.protocol.get_scopes_for(scopes) + elif auth_flow_type == 'credentials': + # for client credential grant flow solely: + # append the default scope if it's not provided + if not scopes: + kwargs['scopes'] = [self.protocol.prefix_scope('.default')] + + # set main_resource to blank when it's the 'ME' resource + if self.protocol.default_resource == ME_RESOURCE: + self.protocol.default_resource = '' + if main_resource == ME_RESOURCE: + main_resource = '' + else: + raise ValueError('"auth_flow_type" must be "authorization", "credentials" or "public"') + + self.con = self.connection_constructor(credentials, **kwargs) + self.main_resource = main_resource or self.protocol.default_resource
+ + def __repr__(self): + if self.con.auth: + return 'Account Client Id: {}'.format(self.con.auth[0]) + else: + return 'Unidentified Account' + + @property + def is_authenticated(self): + """ + Checks whether the library has the authentication and that is not expired + :return: True if authenticated, False otherwise + """ + token = self.con.token_backend.token + if not token: + token = self.con.token_backend.get_token() + + return token is not None and not token.is_expired + +
[docs] def authenticate(self, *, scopes=None, **kwargs): + """ Performs the oauth authentication flow using the console resulting in a stored token. + It uses the credentials passed on instantiation + + :param list[str] or None scopes: list of protocol user scopes to be converted + by the protocol or scope helpers + :param kwargs: other configurations to be passed to the + Connection.get_authorization_url and Connection.request_token methods + :return: Success / Failure + :rtype: bool + """ + + if self.con.auth_flow_type in ('authorization', 'public'): + if scopes is not None: + if self.con.scopes is not None: + raise RuntimeError('The scopes must be set either at the Account instantiation or on the account.authenticate method.') + self.con.scopes = self.protocol.get_scopes_for(scopes) + else: + if self.con.scopes is None: + raise ValueError('The scopes are not set. Define the scopes requested.') + + consent_url, _ = self.con.get_authorization_url(**kwargs) + + print('Visit the following url to give consent:') + print(consent_url) + + token_url = input('Paste the authenticated url here:\n') + + if token_url: + result = self.con.request_token(token_url, **kwargs) # no need to pass state as the session is the same + if result: + print('Authentication Flow Completed. Oauth Access Token Stored. You can now use the API.') + else: + print('Something go wrong. Please try again.') + + return bool(result) + else: + print('Authentication Flow aborted.') + return False + + elif self.con.auth_flow_type == 'credentials': + return self.con.request_token(None, requested_scopes=scopes) + else: + raise ValueError('Connection "auth_flow_type" must be "authorization", "public" or "credentials"')
+ +
[docs] def get_current_user(self): + """ Returns the current user """ + if self.con.auth_flow_type in ('authorization', 'public'): + directory = self.directory(resource=ME_RESOURCE) + return directory.get_current_user() + else: + return None
+ + @property + def connection(self): + """ Alias for self.con + + :rtype: type(self.connection_constructor) + """ + return self.con + +
[docs] def new_message(self, resource=None): + """ Creates a new message to be sent or stored + + :param str resource: Custom resource to be used in this message + (Defaults to parent main_resource) + :return: New empty message + :rtype: Message + """ + from .message import Message + return Message(parent=self, main_resource=resource, is_draft=True)
+ +
[docs] def mailbox(self, resource=None): + """ Get an instance to the mailbox for the specified account resource + + :param str resource: Custom resource to be used in this mailbox + (Defaults to parent main_resource) + :return: a representation of account mailbox + :rtype: O365.mailbox.MailBox + """ + from .mailbox import MailBox + return MailBox(parent=self, main_resource=resource, name='MailBox')
+ +
[docs] def address_book(self, *, resource=None, address_book='personal'): + """ Get an instance to the specified address book for the + specified account resource + + :param str resource: Custom resource to be used in this address book + (Defaults to parent main_resource) + :param str address_book: Choose from 'Personal' or 'Directory' + :return: a representation of the specified address book + :rtype: AddressBook or GlobalAddressList + :raises RuntimeError: if invalid address_book is specified + """ + if address_book.lower() == 'personal': + from .address_book import AddressBook + + return AddressBook(parent=self, main_resource=resource, + name='Personal Address Book') + elif address_book.lower() in ('gal', 'directory'): + # for backwards compatibility only + from .directory import Directory + + return Directory(parent=self, main_resource=resource) + else: + raise RuntimeError( + 'address_book must be either "Personal" ' + '(resource address book) or "Directory" (Active Directory)')
+ +
[docs] def directory(self, resource=None): + """ Returns the active directory instance""" + from .directory import Directory, USERS_RESOURCE + + return Directory(parent=self, main_resource=resource or USERS_RESOURCE)
+ +
[docs] def schedule(self, *, resource=None): + """ Get an instance to work with calendar events for the + specified account resource + + :param str resource: Custom resource to be used in this schedule object + (Defaults to parent main_resource) + :return: a representation of calendar events + :rtype: Schedule + """ + from .calendar import Schedule + return Schedule(parent=self, main_resource=resource)
+ +
[docs] def storage(self, *, resource=None): + """ Get an instance to handle file storage (OneDrive / Sharepoint) + for the specified account resource + + :param str resource: Custom resource to be used in this drive object + (Defaults to parent main_resource) + :return: a representation of OneDrive File Storage + :rtype: Storage + :raises RuntimeError: if protocol doesn't support the feature + """ + if not isinstance(self.protocol, MSGraphProtocol): + # TODO: Custom protocol accessing OneDrive/Sharepoint Api fails here + raise RuntimeError( + 'Drive options only works on Microsoft Graph API') + from .drive import Storage + return Storage(parent=self, main_resource=resource)
+ +
[docs] def sharepoint(self, *, resource=''): + """ Get an instance to read information from Sharepoint sites for the + specified account resource + + :param str resource: Custom resource to be used in this sharepoint + object (Defaults to parent main_resource) + :return: a representation of Sharepoint Sites + :rtype: Sharepoint + :raises RuntimeError: if protocol doesn't support the feature + """ + + if not isinstance(self.protocol, MSGraphProtocol): + # TODO: Custom protocol accessing OneDrive/Sharepoint Api fails here + raise RuntimeError( + 'Sharepoint api only works on Microsoft Graph API') + + from .sharepoint import Sharepoint + return Sharepoint(parent=self, main_resource=resource)
+ +
[docs] def planner(self, *, resource=''): + """ Get an instance to read information from Microsoft planner """ + + if not isinstance(self.protocol, MSGraphProtocol): + # TODO: Custom protocol accessing OneDrive/Sharepoint Api fails here + raise RuntimeError( + 'planner api only works on Microsoft Graph API') + + from .planner import Planner + return Planner(parent=self, main_resource=resource)
+ +
[docs] def teams(self, *, resource=''): + """ Get an instance to read information from Microsoft Teams """ + + if not isinstance(self.protocol, MSGraphProtocol): + raise RuntimeError( + 'teams api only works on Microsoft Graph API') + + from .teams import Teams + return Teams(parent=self, main_resource=resource)
+ +
[docs] def outlook_categories(self, *, resource=''): + """ Returns a Categories object to handle the available Outlook Categories """ + from .category import Categories + + return Categories(parent=self, main_resource=resource)
+
+ +
+ +
+ + +
+
+ +
+ +
+ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/O365/address_book.html b/docs/latest/_modules/O365/address_book.html new file mode 100644 index 00000000..24f3d7fc --- /dev/null +++ b/docs/latest/_modules/O365/address_book.html @@ -0,0 +1,1196 @@ + + + + + + + + + + + O365.address_book — O365 documentation + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + +
+ +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+
+
+
+ +

Source code for O365.address_book

+import datetime as dt
+import logging
+
+from dateutil.parser import parse
+from requests.exceptions import HTTPError
+
+from .utils import Recipients
+from .utils import AttachableMixin, TrackerSet
+from .utils import Pagination, NEXT_LINK_KEYWORD, ApiComponent
+from .message import Message, RecipientType
+from .category import Category
+
+
+log = logging.getLogger(__name__)
+
+
+
[docs]class Contact(ApiComponent, AttachableMixin): + """ Contact manages lists of events on associated contact on office365. """ + + _endpoints = { + 'contact': '/contacts', + 'root_contact': '/contacts/{id}', + 'child_contact': '/contactFolders/{folder_id}/contacts', + 'photo': '/contacts/{id}/photo/$value', + 'photo_size': '/contacts/{id}/photos/{size}/$value', + } + + message_constructor = Message + +
[docs] def __init__(self, *, parent=None, con=None, **kwargs): + """ Create a contact API component + + :param parent: parent account for this folder + :type parent: Account + :param Connection con: connection to use if no parent specified + :param Protocol protocol: protocol to use if no parent specified + (kwargs) + :param str main_resource: use this resource instead of parent resource + (kwargs) + """ + if parent and con: + raise ValueError('Need a parent or a connection but not both') + self.con = parent.con if parent else con + + # Choose the main_resource passed in kwargs over parent main_resource + main_resource = kwargs.pop('main_resource', None) or ( + getattr(parent, 'main_resource', None) if parent else None) + + super().__init__( + protocol=parent.protocol if parent else kwargs.get('protocol'), + main_resource=main_resource) + + cloud_data = kwargs.get(self._cloud_data_key, {}) + cc = self._cc # alias to shorten the code + + # internal to know which properties need to be updated on the server + self._track_changes = TrackerSet(casing=cc) + + self.object_id = cloud_data.get(cc('id'), None) + self.__created = cloud_data.get(cc('createdDateTime'), None) + self.__modified = cloud_data.get(cc('lastModifiedDateTime'), None) + + local_tz = self.protocol.timezone + self.__created = parse(self.__created).astimezone( + local_tz) if self.__created else None + self.__modified = parse(self.__modified).astimezone( + local_tz) if self.__modified else None + + self.__display_name = cloud_data.get(cc('displayName'), '') + self.__name = cloud_data.get(cc('givenName'), '') + self.__surname = cloud_data.get(cc('surname'), '') + + self.__title = cloud_data.get(cc('title'), '') + self.__job_title = cloud_data.get(cc('jobTitle'), '') + self.__company_name = cloud_data.get(cc('companyName'), '') + self.__department = cloud_data.get(cc('department'), '') + self.__office_location = cloud_data.get(cc('officeLocation'), '') + self.__business_phones = cloud_data.get(cc('businessPhones'), []) or [] + self.__mobile_phone = cloud_data.get(cc('mobilePhone'), '') + self.__home_phones = cloud_data.get(cc('homePhones'), []) or [] + + emails = cloud_data.get(cc('emailAddresses'), []) + self.__emails = Recipients( + recipients=[(rcp.get(cc('name'), ''), rcp.get(cc('address'), '')) + for rcp in emails], + parent=self, field=cc('emailAddresses')) + email = cloud_data.get(cc('email')) + self.__emails.untrack = True + if email and email not in self.__emails: + # a Contact from OneDrive? + self.__emails.add(email) + self.__business_address = cloud_data.get(cc('businessAddress'), {}) + self.__home_address = cloud_data.get(cc('homesAddress'), {}) + self.__other_address = cloud_data.get(cc('otherAddress'), {}) + self.__preferred_language = cloud_data.get(cc('preferredLanguage'), + None) + + self.__categories = cloud_data.get(cc('categories'), []) + self.__folder_id = cloud_data.get(cc('parentFolderId'), None) + + self.__personal_notes = cloud_data.get(cc('personalNotes'), '') + + # When using Users endpoints (GAL) + # Missing keys: ['mail', 'userPrincipalName'] + mail = cloud_data.get(cc('mail'), None) + user_principal_name = cloud_data.get(cc('userPrincipalName'), None) + if mail and mail not in self.emails: + self.emails.add(mail) + if user_principal_name and user_principal_name not in self.emails: + self.emails.add(user_principal_name) + self.__emails.untrack = False
+ + def __str__(self): + return self.__repr__() + + def __repr__(self): + return self.display_name or self.full_name or 'Unknown Name' + + def __eq__(self, other): + return self.object_id == other.object_id + + @property + def created(self): + """ Created Time + + :rtype: datetime + """ + return self.__created + + @property + def modified(self): + """ Last Modified Time + + :rtype: datetime + """ + return self.__modified + + @property + def display_name(self): + """ Display Name + + :getter: Get the display name of the contact + :setter: Update the display name + :type: str + """ + return self.__display_name + + @display_name.setter + def display_name(self, value): + self.__display_name = value + self._track_changes.add(self._cc('displayName')) + + @property + def name(self): + """ First Name + + :getter: Get the name of the contact + :setter: Update the name + :type: str + """ + return self.__name + + @name.setter + def name(self, value): + self.__name = value + self._track_changes.add(self._cc('givenName')) + + @property + def surname(self): + """ Surname of Contact + + :getter: Get the surname of the contact + :setter: Update the surname + :type: str + """ + return self.__surname + + @surname.setter + def surname(self, value): + self.__surname = value + self._track_changes.add(self._cc('surname')) + + @property + def full_name(self): + """ Full Name (Name + Surname) + + :rtype: str + """ + return '{} {}'.format(self.name, self.surname).strip() + + @property + def title(self): + """ Title (Mr., Ms., etc..) + + :getter: Get the title of the contact + :setter: Update the title + :type: str + """ + return self.__title + + @title.setter + def title(self, value): + self.__title = value + self._track_changes.add(self._cc('title')) + + @property + def job_title(self): + """ Job Title + + :getter: Get the job title of contact + :setter: Update the job title + :type: str + """ + return self.__job_title + + @job_title.setter + def job_title(self, value): + self.__job_title = value + self._track_changes.add(self._cc('jobTitle')) + + @property + def company_name(self): + """ Name of the company + + :getter: Get the company name of contact + :setter: Update the company name + :type: str + """ + return self.__company_name + + @company_name.setter + def company_name(self, value): + self.__company_name = value + self._track_changes.add(self._cc('companyName')) + + @property + def department(self): + """ Department + + :getter: Get the department of contact + :setter: Update the department + :type: str + """ + return self.__department + + @department.setter + def department(self, value): + self.__department = value + self._track_changes.add(self._cc('department')) + + @property + def office_location(self): + """ Office Location + + :getter: Get the office location of contact + :setter: Update the office location + :type: str + """ + return self.__office_location + + @office_location.setter + def office_location(self, value): + self.__office_location = value + self._track_changes.add(self._cc('officeLocation')) + + @property + def business_phones(self): + """ Business Contact numbers + + :getter: Get the contact numbers of contact + :setter: Update the contact numbers + :type: list[str] + """ + return self.__business_phones + + @business_phones.setter + def business_phones(self, value): + if isinstance(value, tuple): + value = list(value) + if not isinstance(value, list): + value = [value] + self.__business_phones = value + self._track_changes.add(self._cc('businessPhones')) + + @property + def mobile_phone(self): + """ Personal Contact numbers + + :getter: Get the contact numbers of contact + :setter: Update the contact numbers + :type: list[str] + """ + return self.__mobile_phone + + @mobile_phone.setter + def mobile_phone(self, value): + self.__mobile_phone = value + self._track_changes.add(self._cc('mobilePhone')) + + @property + def home_phones(self): + """ Home Contact numbers + + :getter: Get the contact numbers of contact + :setter: Update the contact numbers + :type: list[str] + """ + return self.__home_phones + + @home_phones.setter + def home_phones(self, value): + if isinstance(value, list): + self.__home_phones = value + elif isinstance(value, str): + self.__home_phones = [value] + elif isinstance(value, tuple): + self.__home_phones = list(value) + else: + raise ValueError('home_phones must be a list') + self._track_changes.add(self._cc('homePhones')) + + @property + def emails(self): + """ List of email ids of the Contact + + :rtype: Recipients + """ + return self.__emails + + @property + def main_email(self): + """ Primary(First) email id of the Contact + + :rtype: str + """ + if not self.emails: + return None + return self.emails[0].address + + @property + def business_address(self): + """ Business Address + + :getter: Get the address of contact + :setter: Update the address + :type: dict + """ + return self.__business_address + + @business_address.setter + def business_address(self, value): + if not isinstance(value, dict): + raise ValueError('"business_address" must be dict') + self.__business_address = value + self._track_changes.add(self._cc('businessAddress')) + + @property + def home_address(self): + """ Home Address + + :getter: Get the address of contact + :setter: Update the address + :type: dict + """ + return self.__home_address + + @home_address.setter + def home_address(self, value): + if not isinstance(value, dict): + raise ValueError('"home_address" must be dict') + self.__home_address = value + self._track_changes.add(self._cc('homesAddress')) + + @property + def other_address(self): + """ Other Address + + :getter: Get the address of contact + :setter: Update the address + :type: dict + """ + return self.__other_address + + @other_address.setter + def other_address(self, value): + if not isinstance(value, dict): + raise ValueError('"other_address" must be dict') + self.__other_address = value + self._track_changes.add(self._cc('otherAddress')) + + @property + def preferred_language(self): + """ Preferred Language + + :getter: Get the language of contact + :setter: Update the language + :type: str + """ + return self.__preferred_language + + @preferred_language.setter + def preferred_language(self, value): + self.__preferred_language = value + self._track_changes.add(self._cc('preferredLanguage')) + + @property + def categories(self): + """ Assigned Categories + + :getter: Get the categories + :setter: Update the categories + :type: list[str] + """ + return self.__categories + + @categories.setter + def categories(self, value): + if isinstance(value, list): + self.__categories = [] + for val in value: + if isinstance(val, Category): + self.__categories.append(val.name) + else: + self.__categories.append(val) + elif isinstance(value, str): + self.__categories = [value] + elif isinstance(value, Category): + self.__categories = [value.name] + else: + raise ValueError('categories must be a list') + self._track_changes.add(self._cc('categories')) + + @property + def personal_notes(self): + return self.__personal_notes + + @personal_notes.setter + def personal_notes(self, value): + self.__personal_notes = value + self._track_changes.add(self._cc('personalNotes')) + + @property + def folder_id(self): + """ ID of the folder + + :rtype: str + """ + return self.__folder_id + +
[docs] def to_api_data(self, restrict_keys=None): + """ Returns a dictionary in cloud format + + :param restrict_keys: a set of keys to restrict the returned data to. + """ + cc = self._cc # alias + + data = { + cc('displayName'): self.__display_name, + cc('givenName'): self.__name, + cc('surname'): self.__surname, + cc('title'): self.__title, + cc('jobTitle'): self.__job_title, + cc('companyName'): self.__company_name, + cc('department'): self.__department, + cc('officeLocation'): self.__office_location, + cc('businessPhones'): self.__business_phones, + cc('mobilePhone'): self.__mobile_phone, + cc('homePhones'): self.__home_phones, + cc('emailAddresses'): [{self._cc('name'): recipient.name or '', + self._cc('address'): recipient.address} + for recipient in self.emails], + cc('businessAddress'): self.__business_address, + cc('homesAddress'): self.__home_address, + cc('otherAddress'): self.__other_address, + cc('categories'): self.__categories, + cc('personalNotes'): self.__personal_notes, + } + + if restrict_keys: + restrict_keys.add(cc( + 'givenName')) # GivenName is required by the api all the time. + for key in list(data.keys()): + if key not in restrict_keys: + del data[key] + return data
+ +
[docs] def delete(self): + """ Deletes this contact + + :return: Success or Failure + :rtype: bool + :raises RuntimeError: if contact is not yet saved to cloud + """ + if not self.object_id: + raise RuntimeError('Attempting to delete an unsaved Contact') + + url = self.build_url( + self._endpoints.get('root_contact').format(id=self.object_id)) + + response = self.con.delete(url) + + return bool(response)
+ +
[docs] def save(self): + """ Saves this contact to the cloud (create or update existing one + based on what values have changed) + + :return: Saved or Not + :rtype: bool + """ + if self.object_id: + # Update Contact + if not self._track_changes: + return True # there's nothing to update + url = self.build_url( + self._endpoints.get('root_contact').format(id=self.object_id)) + method = self.con.patch + data = self.to_api_data(restrict_keys=self._track_changes) + else: + # Save new Contact + if self.__folder_id: + url = self.build_url( + self._endpoints.get('child_contact').format( + folder_id=self.__folder_id)) + else: + url = self.build_url(self._endpoints.get('contact')) + method = self.con.post + data = self.to_api_data(restrict_keys=self._track_changes) + response = method(url, data=data) + + if not response: + return False + + if not self.object_id: + # New Contact + contact = response.json() + + self.object_id = contact.get(self._cc('id'), None) + + self.__created = contact.get(self._cc('createdDateTime'), None) + self.__modified = contact.get(self._cc('lastModifiedDateTime'), + None) + + local_tz = self.protocol.timezone + self.__created = parse(self.created).astimezone( + local_tz) if self.__created else None + self.__modified = parse(self.modified).astimezone( + local_tz) if self.__modified else None + else: + self.__modified = self.protocol.timezone.localize(dt.datetime.now()) + + return True
+ +
[docs] def new_message(self, recipient=None, *, recipient_type=RecipientType.TO): + """ This method returns a new draft Message instance with + contacts first email as a recipient + + :param Recipient recipient: a Recipient instance where to send this + message. If None first email of this contact will be used + :param RecipientType recipient_type: section to add recipient into + :return: newly created message + :rtype: Message or None + """ + + if isinstance(recipient_type, str): + recipient_type = RecipientType(recipient_type) + + recipient = recipient or self.emails.get_first_recipient_with_address() + if not recipient: + return None + + new_message = self.message_constructor(parent=self, is_draft=True) + + target_recipients = getattr(new_message, str(recipient_type.value)) + target_recipients.add(recipient) + + return new_message
+ +
[docs] def get_profile_photo(self, size=None): + """ Returns this contact profile photo + :param str size: 48x48, 64x64, 96x96, 120x120, 240x240, + 360x360, 432x432, 504x504, and 648x648 + """ + if size is None: + url = self.build_url(self._endpoints.get('photo').format(id=self.object_id)) + else: + url = self.build_url(self._endpoints.get('photo_size').format(id=self.object_id, size=size)) + + try: + response = self.con.get(url) + except HTTPError as e: + log.debug('Error while retrieving the contact profile photo. Error: {}'.format(e)) + return None + + if not response: + return None + + return response.content
+ +
[docs] def update_profile_photo(self, photo): + """ Updates this contact profile photo + :param bytes photo: the photo data in bytes + """ + + url = self.build_url(self._endpoints.get('photo').format(id=self.object_id)) + response = self.con.patch(url, data=photo, headers={'Content-type': 'image/jpeg'}) + + return bool(response)
+ + +
[docs]class BaseContactFolder(ApiComponent): + """ Base Contact Folder Grouping Functionality """ + + _endpoints = { + 'root_contacts': '/contacts', + 'folder_contacts': '/contactFolders/{id}/contacts', + 'get_folder': '/contactFolders/{id}', + 'root_folders': '/contactFolders', + 'child_folders': '/contactFolders/{id}/childFolders' + } + + contact_constructor = Contact + message_constructor = Message + +
[docs] def __init__(self, *, parent=None, con=None, **kwargs): + """ Create a contact folder component + + :param parent: parent folder/account for this folder + :type parent: BaseContactFolder or Account + :param Connection con: connection to use if no parent specified + :param Protocol protocol: protocol to use if no parent specified + (kwargs) + :param str main_resource: use this resource instead of parent resource + (kwargs) + """ + if parent and con: + raise ValueError('Need a parent or a connection but not both') + self.con = parent.con if parent else con + + # Choose the main_resource passed in kwargs over parent main_resource + main_resource = kwargs.pop('main_resource', None) or ( + getattr(parent, 'main_resource', None) if parent else None) + + super().__init__( + protocol=parent.protocol if parent else kwargs.get('protocol'), + main_resource=main_resource) + + # This folder has no parents if root = True. + self.root = kwargs.pop('root', False) + + cloud_data = kwargs.get(self._cloud_data_key, {}) + + # Fallback to manual folder if nothing available on cloud data + self.name = cloud_data.get(self._cc('displayName'), + kwargs.get('name', + '')) + # TODO: Most of above code is same as mailbox.Folder __init__ + + self.folder_id = cloud_data.get(self._cc('id'), None) + self.parent_id = cloud_data.get(self._cc('parentFolderId'), None)
+ + def __str__(self): + return self.__repr__() + + def __repr__(self): + return 'Contact Folder: {}'.format(self.name) + + def __eq__(self, other): + return self.folder_id == other.folder_id + +
[docs] def get_contacts(self, limit=100, *, query=None, order_by=None, batch=None): + """ Gets a list of contacts from this address book + + To use query an order_by check the OData specification here: + http://docs.oasis-open.org/odata/odata/v4.0/errata03/os/complete/ + part2-url-conventions/odata-v4.0-errata03-os-part2-url-conventions + -complete.html + + :param limit: max no. of contacts to get. Over 999 uses batch. + :type limit: int or None + :param query: applies a OData filter to the request + :type query: Query or str + :param order_by: orders the result set based on this condition + :type order_by: Query or str + :param int batch: batch size, retrieves items in + batches allowing to retrieve more items than the limit. + :return: list of contacts + :rtype: list[Contact] or Pagination + """ + + if self.root: + url = self.build_url(self._endpoints.get('root_contacts')) + else: + url = self.build_url( + self._endpoints.get('folder_contacts').format( + id=self.folder_id)) + + if limit is None or limit > self.protocol.max_top_value: + batch = self.protocol.max_top_value + + params = {'$top': batch if batch else limit} + + if order_by: + params['$orderby'] = order_by + + if query: + if isinstance(query, str): + params['$filter'] = query + else: + params.update(query.as_params()) + + response = self.con.get(url, params=params) + if not response: + return iter(()) + + data = response.json() + + # Everything received from cloud must be passed as self._cloud_data_key + contacts = (self.contact_constructor(parent=self, + **{self._cloud_data_key: contact}) + for contact in data.get('value', [])) + + next_link = data.get(NEXT_LINK_KEYWORD, None) + + if batch and next_link: + return Pagination(parent=self, data=contacts, + constructor=self.contact_constructor, + next_link=next_link, limit=limit) + else: + return contacts
+ +
[docs] def get_contact_by_email(self, email): + """ Returns a Contact by it's email + + :param email: email to get contact for + :return: Contact for specified email + :rtype: Contact + """ + if not email: + return None + + query = self.q().any(collection='email_addresses', attribute='address', + word=email.strip(), operation='eq') + contacts = list(self.get_contacts(limit=1, query=query)) + return contacts[0] if contacts else None
+ + +
[docs]class ContactFolder(BaseContactFolder): + """ A Contact Folder representation """ + +
[docs] def get_folder(self, folder_id=None, folder_name=None): + """ Returns a Contact Folder by it's id or child folders by name + + :param folder_id: the folder_id to be retrieved. + Can be any folder Id (child or not) + :param folder_name: the folder name to be retrieved. + Must be a child of this folder + :return: a single contact folder + :rtype: ContactFolder + """ + + if folder_id and folder_name: + raise RuntimeError('Provide only one of the options') + + if not folder_id and not folder_name: + raise RuntimeError('Provide one of the options') + + if folder_id: + # get folder by it's id, independent of the parent of this folder_id + url = self.build_url( + self._endpoints.get('get_folder').format(id=folder_id)) + params = None + else: + # get folder by name. Only looks up in child folders. + if self.root: + url = self.build_url(self._endpoints.get('root_folders')) + else: + url = self.build_url( + self._endpoints.get('child_folders').format( + id=self.folder_id)) + + params = {'$filter': "{} eq '{}'".format(self._cc('displayName'), + folder_name), '$top': 1} + + response = self.con.get(url, params=params) + if not response: + return None + + if folder_id: + folder = response.json() + else: + folder = response.json().get('value') + folder = folder[0] if folder else None + if folder is None: + return None + + # Everything received from cloud must be passed as self._cloud_data_key + # we don't pass parent, as this folder may not be a child of self. + return self.__class__(con=self.con, protocol=self.protocol, + main_resource=self.main_resource, + **{self._cloud_data_key: folder})
+ +
[docs] def get_folders(self, limit=None, *, query=None, order_by=None): + """ Returns a list of child folders + + :param int limit: max no. of folders to get. Over 999 uses batch. + :param query: applies a OData filter to the request + :type query: Query or str + :param order_by: orders the result set based on this condition + :type order_by: Query or str + :return: list of folders + :rtype: list[ContactFolder] + """ + if self.root: + url = self.build_url(self._endpoints.get('root_folders')) + else: + url = self.build_url( + self._endpoints.get('child_folders').format(id=self.folder_id)) + + params = {} + + if limit: + params['$top'] = limit + + if order_by: + params['$orderby'] = order_by + + if query: + if isinstance(query, str): + params['$filter'] = query + else: + params.update(query.as_params()) + + response = self.con.get(url, params=params or None) + if not response: + return [] + + data = response.json() + + return [self.__class__(parent=self, **{self._cloud_data_key: folder}) + for folder in data.get('value', [])]
+ +
[docs] def create_child_folder(self, folder_name): + """ Creates a new child folder + + :param str folder_name: name of the new folder to create + :return: newly created folder + :rtype: ContactFolder or None + """ + + if not folder_name: + return None + + if self.root: + url = self.build_url(self._endpoints.get('root_folders')) + else: + url = self.build_url( + self._endpoints.get('child_folders').format(id=self.folder_id)) + + response = self.con.post(url, + data={self._cc('displayName'): folder_name}) + if not response: + return None + + folder = response.json() + + # Everything received from cloud must be passed as self._cloud_data_key + return self.__class__(parent=self, **{self._cloud_data_key: folder})
+ +
[docs] def update_folder_name(self, name): + """ Change this folder name + + :param str name: new name to change to + :return: Updated or Not + :rtype: bool + """ + if self.root: + return False + if not name: + return False + + url = self.build_url( + self._endpoints.get('get_folder').format(id=self.folder_id)) + + response = self.con.patch(url, data={self._cc('displayName'): name}) + if not response: + return False + + folder = response.json() + + self.name = folder.get(self._cc('displayName'), '') + self.parent_id = folder.get(self._cc('parentFolderId'), None) + + return True
+ +
[docs] def move_folder(self, to_folder): + """ Change this folder name + + :param to_folder: folder_id/ContactFolder to move into + :type to_folder: str or ContactFolder + :return: Moved or Not + :rtype: bool + """ + if self.root: + return False + if not to_folder: + return False + + url = self.build_url( + self._endpoints.get('get_folder').format(id=self.folder_id)) + + if isinstance(to_folder, ContactFolder): + folder_id = to_folder.folder_id + elif isinstance(to_folder, str): + folder_id = to_folder + else: + return False + + response = self.con.patch(url, + data={self._cc('parentFolderId'): folder_id}) + if not response: + return False + + folder = response.json() + + self.name = folder.get(self._cc('displayName'), '') + self.parent_id = folder.get(self._cc('parentFolderId'), None) + + return True
+ +
[docs] def delete(self): + """ Deletes this folder + + :return: Deleted or Not + :rtype: bool + """ + + if self.root or not self.folder_id: + return False + + url = self.build_url( + self._endpoints.get('get_folder').format(id=self.folder_id)) + + response = self.con.delete(url) + if not response: + return False + + self.folder_id = None + + return True
+ +
[docs] def new_contact(self): + """ Creates a new contact to be saved into it's parent folder + + :return: newly created contact + :rtype: Contact + """ + contact = self.contact_constructor(parent=self) + if not self.root: + contact.__folder_id = self.folder_id + return contact
+ +
[docs] def new_message(self, recipient_type=RecipientType.TO, *, query=None): + """ This method returns a new draft Message instance with all the + contacts first email as a recipient + + :param RecipientType recipient_type: section to add recipient into + :param query: applies a OData filter to the request + :type query: Query or str + :return: newly created message + :rtype: Message or None + """ + + if isinstance(recipient_type, str): + recipient_type = RecipientType(recipient_type) + + recipients = [contact.emails[0] + for contact in self.get_contacts(limit=None, query=query) + if contact.emails and contact.emails[0].address] + + if not recipients: + return None + + new_message = self.message_constructor(parent=self, is_draft=True) + target_recipients = getattr(new_message, str(recipient_type.value)) + target_recipients.add(recipients) + + return new_message
+ + +
[docs]class AddressBook(ContactFolder): + """ A class representing an address book """ + +
[docs] def __init__(self, *, parent=None, con=None, **kwargs): + # Set instance to be a root instance + super().__init__(parent=parent, con=con, root=True, **kwargs)
+ + def __repr__(self): + return 'Address Book resource: {}'.format(self.main_resource)
+
+ +
+ +
+ + +
+
+ +
+ +
+ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/O365/calendar.html b/docs/latest/_modules/O365/calendar.html new file mode 100644 index 00000000..f953814b --- /dev/null +++ b/docs/latest/_modules/O365/calendar.html @@ -0,0 +1,2220 @@ + + + + + + + + + + + O365.calendar — O365 documentation + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + +
+ +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+
+
+
+ +

Source code for O365.calendar

+import calendar
+import datetime as dt
+import logging
+
+import pytz
+# noinspection PyPep8Naming
+from bs4 import BeautifulSoup as bs
+from dateutil.parser import parse
+
+from .utils import CaseEnum
+from .utils import HandleRecipientsMixin
+from .utils import AttachableMixin, ImportanceLevel, TrackerSet
+from .utils import BaseAttachments, BaseAttachment
+from .utils import Pagination, NEXT_LINK_KEYWORD, ApiComponent
+from .utils.windows_tz import get_windows_tz
+from .category import Category
+
+log = logging.getLogger(__name__)
+
+MONTH_NAMES = [calendar.month_name[x] for x in range(1, 13)]
+
+
+
[docs]class EventResponse(CaseEnum): + Organizer = 'organizer' + TentativelyAccepted = 'tentativelyAccepted' + Accepted = 'accepted' + Declined = 'declined' + NotResponded = 'notResponded'
+ + +
[docs]class AttendeeType(CaseEnum): + Required = 'required' + Optional = 'optional' + Resource = 'resource'
+ + +
[docs]class EventSensitivity(CaseEnum): + Normal = 'normal' + Personal = 'personal' + Private = 'private' + Confidential = 'confidential'
+ + +
[docs]class EventShowAs(CaseEnum): + Free = 'free' + Tentative = 'tentative' + Busy = 'busy' + Oof = 'oof' + WorkingElsewhere = 'workingElsewhere' + Unknown = 'unknown'
+ + +
[docs]class CalendarColor(CaseEnum): + LightBlue = 'lightBlue' + LightGreen = 'lightGreen' + LightOrange = 'lightOrange' + LightGray = 'lightGray' + LightYellow = 'lightYellow' + LightTeal = 'lightTeal' + LightPink = 'lightPink' + LightBrown = 'lightBrown' + LightRed = 'lightRed' + MaxColor = 'maxColor' + Auto = 'auto'
+ + +
[docs]class EventType(CaseEnum): + SingleInstance = 'singleInstance' # a normal (non-recurring) event + Occurrence = 'occurrence' # all the other recurring events that is not the first one (seriesMaster) + Exception = 'exception' # ? + SeriesMaster = 'seriesMaster' # the first recurring event of the series
+ +
[docs]class OnlineMeetingProviderType(CaseEnum): + Unknown = 'unknown' + TeamsForBusiness = 'teamsForBusiness' + SkypeForBusiness = 'skypeForBusiness' + SkypeForConsumer = 'skypeForConsumer'
+ +
[docs]class EventAttachment(BaseAttachment): + _endpoints = {'attach': '/events/{id}/attachments'}
+ + +
[docs]class EventAttachments(BaseAttachments): + _endpoints = {'attachments': '/events/{id}/attachments'} + + _attachment_constructor = EventAttachment
+ + +
[docs]class DailyEventFrequency: +
[docs] def __init__(self, recurrence_type, interval): + self.recurrence_type = recurrence_type + self.interval = interval
+ + +# noinspection PyAttributeOutsideInit +
[docs]class EventRecurrence(ApiComponent): +
[docs] def __init__(self, event, recurrence=None): + """ A representation of an event recurrence properties + + :param Event event: event object + :param dict recurrence: recurrence information + """ + super().__init__(protocol=event.protocol, + main_resource=event.main_resource) + + self._event = event + recurrence = recurrence or {} + # recurrence pattern + recurrence_pattern = recurrence.get(self._cc('pattern'), {}) + + self.__interval = recurrence_pattern.get(self._cc('interval'), None) + self.__days_of_week = recurrence_pattern.get(self._cc('daysOfWeek'), + set()) + self.__first_day_of_week = recurrence_pattern.get( + self._cc('firstDayOfWeek'), None) + if 'type' in recurrence_pattern.keys(): + if 'weekly' not in recurrence_pattern['type'].lower(): + self.__first_day_of_week = None + + self.__day_of_month = recurrence_pattern.get(self._cc('dayOfMonth'), + None) + self.__month = recurrence_pattern.get(self._cc('month'), None) + self.__index = recurrence_pattern.get(self._cc('index'), 'first') + + # recurrence range + recurrence_range = recurrence.get(self._cc('range'), {}) + + self.__occurrences = recurrence_range.get( + self._cc('numberOfOccurrences'), None) + self.__start_date = recurrence_range.get(self._cc('startDate'), None) + self.__end_date = recurrence_range.get(self._cc('endDate'), None) + self.__recurrence_time_zone = recurrence_range.get( + self._cc('recurrenceTimeZone'), + get_windows_tz(self.protocol.timezone)) + # time and time zones are not considered in recurrence ranges... + # I don't know why 'recurrenceTimeZone' is present here + # Sending a startDate datetime to the server results in an Error: + # Cannot convert the literal 'datetime' to the expected type 'Edm.Date' + if recurrence_range: + self.__start_date = parse( + self.__start_date).date() if self.__start_date else None + self.__end_date = parse( + self.__end_date).date() if self.__end_date else None
+ + def __repr__(self): + if self.__interval: + pattern = 'Daily: every {} day/s'.format(self.__interval) + if self.__days_of_week: + days = ' or '.join(list(self.__days_of_week)) + pattern = 'Relative Monthly: {} {} every {} month/s'.format( + self.__index, days, self.__interval) + if self.__first_day_of_week: + pattern = 'Weekly: every {} week/s on {}'.format( + self.__interval, days) + elif self.__month: + pattern = ('Relative Yearly: {} {} every {} year/s on {}' + ''.format(self.__index, days, + self.__interval, + MONTH_NAMES[self.__month - 1])) + elif self.__day_of_month: + pattern = ('Absolute Monthly: on day {} every {} month/s' + ''.format(self.__day_of_month, self.__interval)) + if self.__month: + pattern = ('Absolute Yearly: on {} {} every {} year/s' + ''.format(MONTH_NAMES[self.__month - 1], + self.__day_of_month, + self.__interval)) + + r_range = '' + if self.__start_date: + r_range = 'Starting on {}'.format(self.__start_date) + ends_on = 'with no end' + if self.__end_date: + ends_on = 'ending on {}'.format(self.__end_date) + elif self.__occurrences: + ends_on = 'up to {} occurrences'.format(self.__occurrences) + r_range = '{} {}'.format(r_range, ends_on) + return '{}. {}'.format(pattern, r_range) + else: + return 'No recurrence enabled' + + def __str__(self): + return self.__repr__() + + def __bool__(self): + return bool(self.__interval) + + def _track_changes(self): + """ Update the track_changes on the event to reflect a needed + update on this field """ + self._event._track_changes.add('recurrence') + + @property + def interval(self): + """ Repeat interval for the event + + :getter: Get the current interval + :setter: Update to a new interval + :type: int + """ + return self.__interval + + @interval.setter + def interval(self, value): + self.__interval = value + self._track_changes() + + @property + def days_of_week(self): + """ Days in week to repeat + + :getter: Get the current list of days + :setter: Set the list of days to repeat + :type: set(str) + """ + return self.__days_of_week + + @days_of_week.setter + def days_of_week(self, value): + self.__days_of_week = value + self._track_changes() + + @property + def first_day_of_week(self): + """ Which day to consider start of the week + + :getter: Get the current start of week + :setter: Set the start day of week + :type: str + """ + return self.__first_day_of_week + + @first_day_of_week.setter + def first_day_of_week(self, value): + self.__first_day_of_week = value + self._track_changes() + + @property + def day_of_month(self): + """ Repeat on this day of month + + :getter: Get the repeat day of month + :setter: Set the repeat day of month + :type: int + """ + return self.__day_of_month + + @day_of_month.setter + def day_of_month(self, value): + self.__day_of_month = value + self._track_changes() + + @property + def month(self): + """ Month of the event + + :getter: Get month + :setter: Update month + :type: int + """ + return self.__month + + @month.setter + def month(self, value): + self.__month = value + self._track_changes() + + @property + def index(self): + """ Index + + :getter: Get index + :setter: Set index + :type: str + """ + return self.__index + + @index.setter + def index(self, value): + self.__index = value + self._track_changes() + + @property + def occurrences(self): + """ No. of occurrences + + :getter: Get the no. of occurrences + :setter: Set the no. of occurrences + :type: int + """ + return self.__occurrences + + @occurrences.setter + def occurrences(self, value): + self.__occurrences = value + self._track_changes() + + @property + def recurrence_time_zone(self): + """ Timezone to consider for repeating + + :getter: Get the timezone + :setter: Set the timezone + :type: str + """ + return self.__recurrence_time_zone + + @recurrence_time_zone.setter + def recurrence_time_zone(self, value): + self.__recurrence_time_zone = value + self._track_changes() + + @property + def start_date(self): + """ Start date of repetition + + :getter: get the start date + :setter: set the start date + :type: date + """ + return self.__start_date + + @start_date.setter + def start_date(self, value): + if not isinstance(value, dt.date): + raise ValueError('start_date value must be a valid date object') + if isinstance(value, dt.datetime): + value = value.date() + self.__start_date = value + self._track_changes() + + @property + def end_date(self): + """ End date of repetition + + :getter: get the end date + :setter: set the end date + :type: date + """ + return self.__end_date + + @end_date.setter + def end_date(self, value): + if not isinstance(value, dt.date): + raise ValueError('end_date value must be a valid date object') + if isinstance(value, dt.datetime): + value = value.date() + self.__end_date = value + self._track_changes() + +
[docs] def to_api_data(self): + """ Returns a dict to communicate with the server + + :rtype: dict + """ + data = {} + # recurrence pattern + if self.__interval and isinstance(self.__interval, int): + recurrence_pattern = data[self._cc('pattern')] = {} + recurrence_pattern[self._cc('type')] = 'daily' + recurrence_pattern[self._cc('interval')] = self.__interval + if self.__days_of_week and isinstance(self.__days_of_week, + (list, tuple, set)): + recurrence_pattern[self._cc('type')] = 'relativeMonthly' + recurrence_pattern[self._cc('daysOfWeek')] = list( + self.__days_of_week) + if self.__first_day_of_week: + recurrence_pattern[self._cc('type')] = 'weekly' + recurrence_pattern[ + self._cc('firstDayOfWeek')] = self.__first_day_of_week + elif self.__month and isinstance(self.__month, int): + recurrence_pattern[self._cc('type')] = 'relativeYearly' + recurrence_pattern[self._cc('month')] = self.__month + if self.__index: + recurrence_pattern[self._cc('index')] = self.__index + else: + if self.__index: + recurrence_pattern[self._cc('index')] = self.__index + + elif self.__day_of_month and isinstance(self.__day_of_month, int): + recurrence_pattern[self._cc('type')] = 'absoluteMonthly' + recurrence_pattern[self._cc('dayOfMonth')] = self.__day_of_month + if self.__month and isinstance(self.__month, int): + recurrence_pattern[self._cc('type')] = 'absoluteYearly' + recurrence_pattern[self._cc('month')] = self.__month + + # recurrence range + if self.__start_date: + recurrence_range = data[self._cc('range')] = {} + recurrence_range[self._cc('type')] = 'noEnd' + recurrence_range[ + self._cc('startDate')] = self.__start_date.isoformat() + recurrence_range[ + self._cc('recurrenceTimeZone')] = self.__recurrence_time_zone + + if self.__end_date: + recurrence_range[self._cc('type')] = 'endDate' + recurrence_range[ + self._cc('endDate')] = self.__end_date.isoformat() + elif self.__occurrences is not None and isinstance( + self.__occurrences, + int): + recurrence_range[self._cc('type')] = 'numbered' + recurrence_range[ + self._cc('numberOfOccurrences')] = self.__occurrences + + return data
+ + def _clear_pattern(self): + """ Clears this event recurrence """ + # pattern group + self.__interval = None + self.__days_of_week = set() + self.__first_day_of_week = None + self.__day_of_month = None + self.__month = None + self.__index = 'first' + # range group + self.__start_date = None + self.__end_date = None + self.__occurrences = None + +
[docs] def set_range(self, start=None, end=None, occurrences=None): + """ Set the range of recurrence + + :param date start: Start date of repetition + :param date end: End date of repetition + :param int occurrences: no of occurrences + """ + if start is None: + if self.__start_date is None: + self.__start_date = dt.date.today() + else: + self.start_date = start + + if end: + self.end_date = end + elif occurrences: + self.__occurrences = occurrences + self._track_changes()
+ +
[docs] def set_daily(self, interval, **kwargs): + """ Set to repeat every x no. of days + + :param int interval: no. of days to repeat at + :keyword date start: Start date of repetition (kwargs) + :keyword date end: End date of repetition (kwargs) + :keyword int occurrences: no of occurrences (kwargs) + """ + self._clear_pattern() + self.__interval = interval + self.set_range(**kwargs)
+ +
[docs] def set_weekly(self, interval, *, days_of_week, first_day_of_week, + **kwargs): + """ Set to repeat every week on specified days for every x no. of days + + :param int interval: no. of days to repeat at + :param str first_day_of_week: starting day for a week + :param list[str] days_of_week: list of days of the week to repeat + :keyword date start: Start date of repetition (kwargs) + :keyword date end: End date of repetition (kwargs) + :keyword int occurrences: no of occurrences (kwargs) + """ + self.set_daily(interval, **kwargs) + self.__days_of_week = set(days_of_week) + self.__first_day_of_week = first_day_of_week
+ +
[docs] def set_monthly(self, interval, *, day_of_month=None, days_of_week=None, + index=None, **kwargs): + """ Set to repeat every month on specified days for every x no. of days + + :param int interval: no. of days to repeat at + :param int day_of_month: repeat day of a month + :param list[str] days_of_week: list of days of the week to repeat + :param index: index + :keyword date start: Start date of repetition (kwargs) + :keyword date end: End date of repetition (kwargs) + :keyword int occurrences: no of occurrences (kwargs) + """ + if not day_of_month and not days_of_week: + raise ValueError('Must provide day_of_month or days_of_week values') + if day_of_month and days_of_week: + raise ValueError('Must provide only one of the two options') + self.set_daily(interval, **kwargs) + if day_of_month: + self.__day_of_month = day_of_month + elif days_of_week: + self.__days_of_week = set(days_of_week) + if index: + self.__index = index
+ +
[docs] def set_yearly(self, interval, month, *, day_of_month=None, + days_of_week=None, index=None, **kwargs): + """ Set to repeat every month on specified days for every x no. of days + + :param int interval: no. of days to repeat at + :param int month: month to repeat + :param int day_of_month: repeat day of a month + :param list[str] days_of_week: list of days of the week to repeat + :param index: index + :keyword date start: Start date of repetition (kwargs) + :keyword date end: End date of repetition (kwargs) + :keyword int occurrences: no of occurrences (kwargs) + """ + self.set_monthly(interval, day_of_month=day_of_month, + days_of_week=days_of_week, index=index, **kwargs) + self.__month = month
+ + +
[docs]class ResponseStatus(ApiComponent): + """ An event response status (status, time) """ + +
[docs] def __init__(self, parent, response_status): + """ An event response status (status, time) + + :param parent: parent of this + :type parent: Attendees or Event + :param dict response_status: status info frm cloud + """ + super().__init__(protocol=parent.protocol, + main_resource=parent.main_resource) + self.status = response_status.get(self._cc('response'), 'none') + self.status = None if self.status == 'none' else EventResponse.from_value(self.status) + if self.status: + self.response_time = response_status.get(self._cc('time'), None) + if self.response_time == '0001-01-01T00:00:00Z': + # consider there's no response time + # this way we don't try to convert this Iso 8601 datetime to the + # local timezone which generated parse errors + self.response_time = None + if self.response_time: + try: + self.response_time = parse(self.response_time).astimezone( + self.protocol.timezone) + except OverflowError: + log.debug("Couldn't parse event response time: {}".format(self.response_time)) + self.response_time = None + else: + self.response_time = None
+ + def __repr__(self): + return self.status or 'None' + + def __str__(self): + return self.__repr__()
+ + +
[docs]class Attendee: + """ A Event attendee """ + +
[docs] def __init__(self, address, *, name=None, attendee_type=None, + response_status=None, event=None): + """ Create a event attendee + + :param str address: email address of the attendee + :param str name: name of the attendee + :param AttendeeType attendee_type: requirement of attendee + :param Response response_status: response status requirement + :param Event event: event for which to assign the attendee + """ + self._untrack = True + self._address = address + self._name = name + self._event = event + if isinstance(response_status, ResponseStatus): + self.__response_status = response_status + else: + self.__response_status = None + self.__attendee_type = AttendeeType.Required + if attendee_type: + self.attendee_type = attendee_type + self._untrack = False
+ + def __repr__(self): + if self.name: + return '{}: {} ({})'.format(self.attendee_type.name, self.name, + self.address) + else: + return '{}: {}'.format(self.attendee_type.name, self.address) + + def __str__(self): + return self.__repr__() + + @property + def address(self): + """ Email address + + :getter: Get the email address of attendee + :setter: Set the email address of attendee + :type: str + """ + return self._address + + @address.setter + def address(self, value): + self._address = value + self._name = '' + self._track_changes() + + @property + def name(self): + """ Name + + :getter: Get the name of attendee + :setter: Set the name of attendee + :type: str + """ + return self._name + + @name.setter + def name(self, value): + self._name = value + self._track_changes() + + def _track_changes(self): + """ Update the track_changes on the event to reflect a + needed update on this field """ + if self._untrack is False: + self._event._track_changes.add('attendees') + + @property + def response_status(self): + """ Response status of the attendee + + :type: ResponseStatus + """ + return self.__response_status + + @property + def attendee_type(self): + """ Requirement of the attendee + + :getter: Get the requirement of attendee + :setter: Set the requirement of attendee + :type: AttendeeType + """ + return self.__attendee_type + + @attendee_type.setter + def attendee_type(self, value): + if isinstance(value, AttendeeType): + self.__attendee_type = value + else: + self.__attendee_type = AttendeeType.from_value(value) + self._track_changes()
+ + +
[docs]class Attendees(ApiComponent): + """ A Collection of Attendees """ + +
[docs] def __init__(self, event, attendees=None): + """ Create a collection of attendees + + :param Event event: event for which to assign the attendees + :param attendees: list of attendees to add + :type attendees: str or tuple(str, str) or Attendee or list[str] or + list[tuple(str,str)] or list[Attendee] + """ + super().__init__(protocol=event.protocol, + main_resource=event.main_resource) + self._event = event + self.__attendees = [] + self.untrack = True + if attendees: + self.add(attendees) + self.untrack = False
+ + def __iter__(self): + return iter(self.__attendees) + + def __getitem__(self, key): + return self.__attendees[key] + + def __contains__(self, item): + return item in {attendee.email for attendee in self.__attendees} + + def __len__(self): + return len(self.__attendees) + + def __str__(self): + return self.__repr__() + + def __repr__(self): + return 'Attendees Count: {}'.format(len(self.__attendees)) + +
[docs] def clear(self): + """ Clear the attendees list """ + self.__attendees = [] + self._track_changes()
+ + def _track_changes(self): + """ Update the track_changes on the event to reflect a needed + update on this field """ + if self.untrack is False: + self._event._track_changes.add('attendees') + +
[docs] def add(self, attendees): + """ Add attendees to the parent event + + :param attendees: list of attendees to add + :type attendees: str or tuple(str, str) or Attendee or list[str] or + list[tuple(str,str)] or list[Attendee] + """ + if attendees: + if isinstance(attendees, str): + self.__attendees.append( + Attendee(address=attendees, event=self._event)) + self._track_changes() + elif isinstance(attendees, Attendee): + self.__attendees.append(attendees) + self._track_changes() + elif isinstance(attendees, tuple): + name, address = attendees + if address: + self.__attendees.append( + Attendee(address=address, name=name, event=self._event)) + self._track_changes() + elif isinstance(attendees, list): + for attendee in attendees: + self.add(attendee) + elif isinstance(attendees, + dict) and self._cloud_data_key in attendees: + attendees = attendees.get(self._cloud_data_key) + for attendee in attendees: + email = attendee.get(self._cc('emailAddress'), {}) + address = email.get(self._cc('address'), None) + if address: + name = email.get(self._cc('name'), None) + # default value + attendee_type = attendee.get(self._cc('type'), + 'required') + self.__attendees.append( + Attendee(address=address, name=name, + attendee_type=attendee_type, + event=self._event, + response_status= + ResponseStatus(parent=self, + response_status= + attendee.get( + self._cc('status'), + {})))) + else: + raise ValueError('Attendees must be an address string, an ' + 'Attendee instance, a (name, address) ' + 'tuple or a list')
+ +
[docs] def remove(self, attendees): + """ Remove the provided attendees from the event + + :param attendees: list of attendees to add + :type attendees: str or tuple(str, str) or Attendee or list[str] or + list[tuple(str,str)] or list[Attendee] + """ + if isinstance(attendees, (list, tuple)): + attendees = { + attendee.address if isinstance(attendee, Attendee) else attendee + for + attendee in attendees} + elif isinstance(attendees, str): + attendees = {attendees} + elif isinstance(attendees, Attendee): + attendees = {attendees.address} + else: + raise ValueError('Incorrect parameter type for attendees') + + new_attendees = [] + for attendee in self.__attendees: + if attendee.address not in attendees: + new_attendees.append(attendee) + self.__attendees = new_attendees + self._track_changes()
+ +
[docs] def to_api_data(self): + """ Returns a dict to communicate with the server + + :rtype: dict + """ + data = [] + for attendee in self.__attendees: + if attendee.address: + att_data = { + self._cc('emailAddress'): { + self._cc('address'): attendee.address, + self._cc('name'): attendee.name + }, + self._cc('type'): self._cc(attendee.attendee_type.value) + } + data.append(att_data) + return data
+ + +# noinspection PyAttributeOutsideInit +
[docs]class Event(ApiComponent, AttachableMixin, HandleRecipientsMixin): + """ A Calendar event """ + + _endpoints = { + 'calendar': '/calendars/{id}', + 'event': '/events/{id}', + 'event_default': '/calendar/events', + 'event_calendar': '/calendars/{id}/events', + 'occurrences': '/events/{id}/instances', + } + +
[docs] def __init__(self, *, parent=None, con=None, **kwargs): + """ Create a calendar event representation + + :param parent: parent for this operation + :type parent: Calendar or Schedule or ApiComponent + :param Connection con: connection to use if no parent specified + :param Protocol protocol: protocol to use if no parent specified + (kwargs) + :param str main_resource: use this resource instead of parent resource + (kwargs) + :param str calendar_id: id of the calender to add this event in + (kwargs) + :param bool download_attachments: whether or not to download attachments + (kwargs) + :param str subject: subject of the event (kwargs) + """ + if parent and con: + raise ValueError('Need a parent or a connection but not both') + self.con = parent.con if parent else con + + # Choose the main_resource passed in kwargs over parent main_resource + main_resource = kwargs.pop('main_resource', None) or ( + getattr(parent, 'main_resource', None) if parent else None) + + super().__init__( + protocol=parent.protocol if parent else kwargs.get('protocol'), + main_resource=main_resource) + + cc = self._cc # alias + # internal to know which properties need to be updated on the server + self._track_changes = TrackerSet(casing=cc) + self.calendar_id = kwargs.get('calendar_id', None) + download_attachments = kwargs.get('download_attachments') + cloud_data = kwargs.get(self._cloud_data_key, {}) + + self.object_id = cloud_data.get(cc('id'), None) + self.__subject = cloud_data.get(cc('subject'), + kwargs.get('subject', '') or '') + body = cloud_data.get(cc('body'), {}) + self.__body = body.get(cc('content'), '') + self.body_type = body.get(cc('contentType'), + 'HTML') # default to HTML for new messages + + self.__attendees = Attendees(event=self, attendees={ + self._cloud_data_key: cloud_data.get(cc('attendees'), [])}) + self.__categories = cloud_data.get(cc('categories'), []) + + self.__created = cloud_data.get(cc('createdDateTime'), None) + self.__modified = cloud_data.get(cc('lastModifiedDateTime'), None) + + local_tz = self.protocol.timezone + self.__created = parse(self.__created).astimezone( + local_tz) if self.__created else None + self.__modified = parse(self.__modified).astimezone( + local_tz) if self.__modified else None + + start_obj = cloud_data.get(cc('start'), {}) + self.__start = self._parse_date_time_time_zone(start_obj) + + end_obj = cloud_data.get(cc('end'), {}) + self.__end = self._parse_date_time_time_zone(end_obj) + + self.has_attachments = cloud_data.get(cc('hasAttachments'), False) + self.__attachments = EventAttachments(parent=self, attachments=[]) + if self.has_attachments and download_attachments: + self.attachments.download_attachments() + self.__categories = cloud_data.get(cc('categories'), []) + self.ical_uid = cloud_data.get(cc('iCalUId'), None) + self.__importance = ImportanceLevel.from_value( + cloud_data.get(cc('importance'), 'normal') or 'normal') + self.__is_all_day = cloud_data.get(cc('isAllDay'), False) + self.is_cancelled = cloud_data.get(cc('isCancelled'), False) + self.is_organizer = cloud_data.get(cc('isOrganizer'), True) + self.__location = cloud_data.get(cc('location'), {}) + self.locations = cloud_data.get(cc('locations'), []) # TODO + + self.online_meeting_url = cloud_data.get(cc('onlineMeetingUrl'), None) + self.__is_online_meeting = cloud_data.get(cc('isOnlineMeeting'), False) + self.__online_meeting_provider = OnlineMeetingProviderType.from_value( + cloud_data.get(cc('onlineMeetingProvider'), 'teamsForBusiness')) + self.online_meeting = cloud_data.get(cc('onlineMeeting'), None) + if not self.online_meeting_url and self.is_online_meeting: + self.online_meeting_url = self.online_meeting.get(cc('joinUrl'), None) \ + if self.online_meeting else None + + self.__organizer = self._recipient_from_cloud( + cloud_data.get(cc('organizer'), None), field=cc('organizer')) + self.__recurrence = EventRecurrence(event=self, + recurrence=cloud_data.get( + cc('recurrence'), None)) + self.__is_reminder_on = cloud_data.get(cc('isReminderOn'), True) + self.__remind_before_minutes = cloud_data.get( + cc('reminderMinutesBeforeStart'), 15) + self.__response_requested = cloud_data.get(cc('responseRequested'), + True) + self.__response_status = ResponseStatus(parent=self, + response_status=cloud_data.get( + cc('responseStatus'), {})) + self.__sensitivity = EventSensitivity.from_value( + cloud_data.get(cc('sensitivity'), 'normal')) + self.series_master_id = cloud_data.get(cc('seriesMasterId'), None) + self.__show_as = EventShowAs.from_value(cloud_data.get(cc('showAs'), 'busy')) + self.__event_type = EventType.from_value(cloud_data.get(cc('type'), 'singleInstance'))
+ + def __str__(self): + return self.__repr__() + + def __repr__(self): + if self.start.date() == self.end.date(): + return 'Subject: {} (on: {} from: {} to: {})'.format(self.subject, self.start.date(), self.start.time(), self.end.time()) + else: + return 'Subject: {} (starts: {} {} and ends: {} {})'.format(self.subject, self.start.date(), self.start.time(), self.end.date(), + self.end.time()) + + def __eq__(self, other): + return self.object_id == other.object_id + +
[docs] def to_api_data(self, restrict_keys=None): + """ Returns a dict to communicate with the server + + :param restrict_keys: a set of keys to restrict the returned data to + :rtype: dict + """ + cc = self._cc # alias + if self.__location: + if isinstance(self.__location, dict): + location = self.__location + else: + location = {cc('displayName'): self.__location} + else: + location = {cc('displayName'): ''} + + data = { + cc('subject'): self.__subject, + cc('body'): { + cc('contentType'): self.body_type, + cc('content'): self.__body}, + cc('start'): self._build_date_time_time_zone(self.__start), + cc('end'): self._build_date_time_time_zone(self.__end), + cc('attendees'): self.__attendees.to_api_data(), + cc('location'): location, + cc('categories'): self.__categories, + cc('isAllDay'): self.__is_all_day, + cc('importance'): cc(self.__importance.value), + cc('isReminderOn'): self.__is_reminder_on, + cc('reminderMinutesBeforeStart'): self.__remind_before_minutes, + cc('responseRequested'): self.__response_requested, + cc('sensitivity'): cc(self.__sensitivity.value), + cc('showAs'): cc(self.__show_as.value), + cc('isOnlineMeeting'): cc(self.__is_online_meeting), + cc('onlineMeetingProvider'): cc(self.__online_meeting_provider.value), + } + + if self.__recurrence: + data[cc('recurrence')] = self.__recurrence.to_api_data() + + if self.has_attachments: + data[cc('attachments')] = self.__attachments.to_api_data() + + if restrict_keys: + for key in list(data.keys()): + if key not in restrict_keys: + del data[key] + return data
+ + @property + def created(self): + """ Created time of the event + + :rtype: datetime + """ + return self.__created + + @property + def modified(self): + """ Last modified time of the event + + :rtype: datetime + """ + return self.__modified + + @property + def body(self): + """ Body of the event + + :getter: Get body text + :setter: Set body of event + :type: str + """ + return self.__body + + @body.setter + def body(self, value): + self.__body = value + self._track_changes.add(self._cc('body')) + + @property + def subject(self): + """ Subject of the event + + :getter: Get subject + :setter: Set subject of event + :type: str + """ + return self.__subject + + @subject.setter + def subject(self, value): + self.__subject = value + self._track_changes.add(self._cc('subject')) + + @property + def start(self): + """ Start Time of event + + :getter: get the start time + :setter: set the start time + :type: datetime + """ + return self.__start + + @start.setter + def start(self, value): + if not isinstance(value, dt.date): + raise ValueError("'start' must be a valid datetime object") + if not isinstance(value, dt.datetime): + # force datetime + value = dt.datetime(value.year, value.month, value.day) + if value.tzinfo is None: + # localize datetime + value = self.protocol.timezone.localize(value) + elif value.tzinfo != self.protocol.timezone: + value = value.astimezone(self.protocol.timezone) + self.__start = value + if not self.end: + self.end = self.__start + dt.timedelta(minutes=30) + self._track_changes.add(self._cc('start')) + + @property + def end(self): + """ End Time of event + + :getter: get the end time + :setter: set the end time + :type: datetime + """ + return self.__end + + @end.setter + def end(self, value): + if not isinstance(value, dt.date): + raise ValueError("'end' must be a valid datetime object") + if not isinstance(value, dt.datetime): + # force datetime + value = dt.datetime(value.year, value.month, value.day) + if value.tzinfo is None: + # localize datetime + value = self.protocol.timezone.localize(value) + elif value.tzinfo != self.protocol.timezone: + value = value.astimezone(self.protocol.timezone) + self.__end = value + self._track_changes.add(self._cc('end')) + + @property + def importance(self): + """ Event Priority + + :getter: get importance of event + :setter: set the importance of event + :type: ImportanceLevel + """ + return self.__importance + + @importance.setter + def importance(self, value): + self.__importance = (value if isinstance(value, ImportanceLevel) + else ImportanceLevel.from_value(value)) + self._track_changes.add(self._cc('importance')) + + @property + def is_all_day(self): + """ Is the event for whole day + + :getter: get the current status of is_all_day property + :setter: set if the event is all day or not + :type: bool + """ + return self.__is_all_day + + @is_all_day.setter + def is_all_day(self, value): + self.__is_all_day = value + if value: + # Api requirement: start and end must be set to midnight + # is_all_day needs event.start included in the request on updates + # is_all_day needs event.end included in the request on updates + start = self.__start or dt.date.today() + end = self.__end or dt.date.today() + + if (start + dt.timedelta(hours=24)) > end: + # Api requires that under is_all_day=True start and + # end must be at least 24 hours away + end = start + dt.timedelta(hours=24) + + # set to midnight + start = dt.datetime(start.year, start.month, start.day) + end = dt.datetime(end.year, end.month, end.day) + + self.start = start + self.end = end + self._track_changes.add(self._cc('isAllDay')) + + @property + def location(self): + """ Location of event + + :getter: get current location configured for the event + :setter: set a location for the event + :type: str + """ + return self.__location + + @location.setter + def location(self, value): + self.__location = value + self._track_changes.add(self._cc('location')) + + @property + def is_reminder_on(self): + """ Status of the Reminder + + :getter: check is reminder enabled or not + :setter: enable or disable reminder option + :type: bool + """ + return self.__is_reminder_on + + @is_reminder_on.setter + def is_reminder_on(self, value): + self.__is_reminder_on = value + self._track_changes.add(self._cc('isReminderOn')) + self._track_changes.add(self._cc('reminderMinutesBeforeStart')) + + @property + def remind_before_minutes(self): + """ No. of minutes to remind before the meeting + + :getter: get current minutes + :setter: set to remind before new x minutes + :type: int + """ + return self.__remind_before_minutes + + @remind_before_minutes.setter + def remind_before_minutes(self, value): + self.__is_reminder_on = True + self.__remind_before_minutes = int(value) + self._track_changes.add(self._cc('isReminderOn')) + self._track_changes.add(self._cc('reminderMinutesBeforeStart')) + + @property + def response_requested(self): + """ Is response requested or not + + :getter: Is response requested or not + :setter: set the event to request response or not + :type: bool + """ + return self.__response_requested + + @response_requested.setter + def response_requested(self, value): + self.__response_requested = value + self._track_changes.add(self._cc('responseRequested')) + + @property + def recurrence(self): + """ Recurrence information of the event + + :rtype: EventRecurrence + """ + return self.__recurrence + + @property + def organizer(self): + """ Organizer of the meeting event + + :rtype: Recipient + """ + return self.__organizer + + @property + def show_as(self): + """ Show as "busy" or any other status during the event + + :getter: Current status during the event + :setter: update show as status + :type: EventShowAs + """ + return self.__show_as + + @show_as.setter + def show_as(self, value): + self.__show_as = (value if isinstance(value, EventShowAs) + else EventShowAs.from_value(value)) + self._track_changes.add(self._cc('showAs')) + + @property + def sensitivity(self): + """ Sensitivity of the Event + + :getter: Get the current sensitivity + :setter: Set a new sensitivity + :type: EventSensitivity + """ + return self.__sensitivity + + @sensitivity.setter + def sensitivity(self, value): + self.__sensitivity = (value if isinstance(value, EventSensitivity) + else EventSensitivity.from_value(value)) + self._track_changes.add(self._cc('sensitivity')) + + @property + def response_status(self): + """ Your response + + :rtype: ResponseStatus + """ + return self.__response_status + + @property + def attachments(self): + """ List of attachments + + :rtype: EventAttachments + """ + return self.__attachments + + @property + def attendees(self): + """ List of meeting attendees + + :rtype: Attendees + """ + return self.__attendees + + @property + def categories(self): + """ Categories of the event + + :getter: get the list of categories + :setter: set the list of categories + :type: list[str] + """ + return self.__categories + + @categories.setter + def categories(self, value): + if isinstance(value, list): + self.__categories = [] + for val in value: + if isinstance(val, Category): + self.__categories.append(val.name) + else: + self.__categories.append(val) + elif isinstance(value, str): + self.__categories = [value] + elif isinstance(value, Category): + self.__categories = [value.name] + else: + raise ValueError('categories must be a list') + self._track_changes.add(self._cc('categories')) + + @property + def event_type(self): + return self.__event_type + + @property + def is_online_meeting(self): + """ Status of the online_meeting + + :getter: check is online_meeting enabled or not + :setter: enable or disable online_meeting option + :type: bool + """ + return self.__is_online_meeting + + @is_online_meeting.setter + def is_online_meeting(self, value): + self.__is_online_meeting = value + self._track_changes.add(self._cc('isOnlineMeeting')) + + @property + def online_meeting_provider(self): + """ online_meeting_provider of event + + :getter: get current online_meeting_provider configured for the event + :setter: set a online_meeting_provider for the event + :type: OnlineMeetingProviderType + """ + return self.__online_meeting_provider + + @online_meeting_provider.setter + def online_meeting_provider(self, value): + self.__online_meeting_provider = (value if isinstance(value, OnlineMeetingProviderType) + else OnlineMeetingProviderType.from_value(value)) + self._track_changes.add(self._cc('onlineMeetingProvider')) + +
[docs] def get_occurrences(self, start, end, *, limit=None, query=None, order_by=None, batch=None): + """ + Returns all the occurrences of a seriesMaster event for a specified time range. + :type start: datetime + :param start: the start of the time range + :type end: datetime + :param end: the end of the time range + :param int limit: ax no. of events to get. Over 999 uses batch. + :type query: Query or str + :param query: optional. extra filters or ordes to apply to this query + :type order_by: str + :param order_by: orders the result set based on this condition + :param int batch: batch size, retrieves items in + batches allowing to retrieve more items than the limit. + :return: a list of events + :rtype: list[Event] or Pagination + """ + if self.event_type != EventType.SeriesMaster: + # you can only get occurrences if its a seriesMaster + return [] + + url = self.build_url( + self._endpoints.get('occurrences').format(id=self.object_id)) + + if limit is None or limit > self.protocol.max_top_value: + batch = self.protocol.max_top_value + + params = {'$top': batch if batch else limit} + + if order_by: + params['$orderby'] = order_by + + if query: + if isinstance(query, str): + params['$filter'] = query + else: + params.update(query.as_params()) + + if start.tzinfo is None: + # if it's a naive datetime, localize the datetime. + start = self.protocol.timezone.localize(start) # localize datetime into local tz + if start.tzinfo != pytz.utc: + start = start.astimezone(pytz.utc) # transform local datetime to utc + + if end.tzinfo is None: + # if it's a naive datetime, localize the datetime. + end = self.protocol.timezone.localize(end) # localize datetime into local tz + if end.tzinfo != pytz.utc: + end = end.astimezone(pytz.utc) # transform local datetime to utc + + params[self._cc('startDateTime')] = start.isoformat() + params[self._cc('endDateTime')] = end.isoformat() + + response = self.con.get(url, params=params, + headers={'Prefer': 'outlook.timezone="UTC"'}) + if not response: + return iter(()) + + data = response.json() + + # Everything received from cloud must be passed as self._cloud_data_key + events = (self.__class__(parent=self, **{self._cloud_data_key: event}) + for event in data.get('value', [])) + next_link = data.get(NEXT_LINK_KEYWORD, None) + if batch and next_link: + return Pagination(parent=self, data=events, + constructor=self.__class__, + next_link=next_link, limit=limit) + else: + return events
+ +
[docs] def delete(self): + """ Deletes a stored event + + :return: Success / Failure + :rtype: bool + """ + if self.object_id is None: + raise RuntimeError('Attempting to delete an unsaved event') + + url = self.build_url( + self._endpoints.get('event').format(id=self.object_id)) + + response = self.con.delete(url) + + return bool(response)
+ +
[docs] def save(self): + """ Create a new event or update an existing one by checking what + values have changed and update them on the server + + :return: Success / Failure + :rtype: bool + """ + + if self.object_id: + # update event + if not self._track_changes: + return True # there's nothing to update + url = self.build_url( + self._endpoints.get('event').format(id=self.object_id)) + method = self.con.patch + data = self.to_api_data(restrict_keys=self._track_changes) + else: + # new event + if self.calendar_id: + url = self.build_url( + self._endpoints.get('event_calendar').format( + id=self.calendar_id)) + else: + url = self.build_url(self._endpoints.get('event_default')) + method = self.con.post + data = self.to_api_data() + + response = method(url, data=data) + if not response: + return False + + self._track_changes.clear() # clear the tracked changes + + if not self.object_id: + # new event + event = response.json() + + self.object_id = event.get(self._cc('id'), None) + + self.__created = event.get(self._cc('createdDateTime'), None) + self.__modified = event.get(self._cc('lastModifiedDateTime'), None) + + self.__created = parse(self.__created).astimezone( + self.protocol.timezone) if self.__created else None + self.__modified = parse(self.__modified).astimezone( + self.protocol.timezone) if self.__modified else None + else: + self.__modified = self.protocol.timezone.localize(dt.datetime.now()) + + return True
+ +
[docs] def accept_event(self, comment=None, *, send_response=True, + tentatively=False): + """ Accept the event + + :param comment: comment to add + :param send_response: whether or not to send response back + :param tentatively: whether acceptance is tentative + :return: Success / Failure + :rtype: bool + """ + if not self.object_id: + raise RuntimeError("Can't accept event that doesn't exist") + + url = self.build_url( + self._endpoints.get('event').format(id=self.object_id)) + url = url + '/tentativelyAccept' if tentatively else url + '/accept' + + data = {} + if comment and isinstance(comment, str): + data[self._cc('comment')] = comment + if send_response is False: + data[self._cc('sendResponse')] = send_response + + response = self.con.post(url, data=data or None) + + return bool(response)
+ +
[docs] def decline_event(self, comment=None, *, send_response=True): + """ Decline the event + + :param str comment: comment to add + :param bool send_response: whether or not to send response back + :return: Success / Failure + :rtype: bool + """ + if not self.object_id: + raise RuntimeError("Can't accept event that doesn't exist") + + url = self.build_url( + self._endpoints.get('event').format(id=self.object_id)) + url = url + '/decline' + + data = {} + if comment and isinstance(comment, str): + data[self._cc('comment')] = comment + if send_response is False: + data[self._cc('sendResponse')] = send_response + + response = self.con.post(url, data=data or None) + + return bool(response)
+ +
[docs] def get_body_text(self): + """ Parse the body html and returns the body text using bs4 + + :return: body text + :rtype: str + """ + if self.body_type != 'HTML': + return self.body + + try: + soup = bs(self.body, 'html.parser') + except RuntimeError: + return self.body + else: + return soup.body.text
+ +
[docs] def get_body_soup(self): + """ Returns the beautifulsoup4 of the html body + + :return: Html body + :rtype: BeautifulSoup + """ + if self.body_type != 'HTML': + return None + else: + return bs(self.body, 'html.parser')
+ + +
[docs]class Calendar(ApiComponent, HandleRecipientsMixin): + _endpoints = { + 'calendar': '/calendars/{id}', + 'get_events': '/calendars/{id}/events', + 'default_events': '/calendar/events', + 'events_view': '/calendars/{id}/calendarView', + 'default_events_view': '/calendar/calendarView', + 'get_event': '/calendars/{id}/events/{ide}', + } + event_constructor = Event + +
[docs] def __init__(self, *, parent=None, con=None, **kwargs): + """ Create a Calendar Representation + + :param parent: parent for this operation + :type parent: Schedule + :param Connection con: connection to use if no parent specified + :param Protocol protocol: protocol to use if no parent specified + (kwargs) + :param str main_resource: use this resource instead of parent resource + (kwargs) + """ + if parent and con: + raise ValueError('Need a parent or a connection but not both') + self.con = parent.con if parent else con + + # Choose the main_resource passed in kwargs over parent main_resource + main_resource = kwargs.pop('main_resource', None) or ( + getattr(parent, 'main_resource', None) if parent else None) + + super().__init__( + protocol=parent.protocol if parent else kwargs.get('protocol'), + main_resource=main_resource) + + cloud_data = kwargs.get(self._cloud_data_key, {}) + + self.name = cloud_data.get(self._cc('name'), '') + self.calendar_id = cloud_data.get(self._cc('id'), None) + self.__owner = self._recipient_from_cloud( + cloud_data.get(self._cc('owner'), {}), field='owner') + color = cloud_data.get(self._cc('color'), 'auto') + try: + self.color = CalendarColor.from_value(color) + except: + self.color = CalendarColor.from_value('auto') + self.can_edit = cloud_data.get(self._cc('canEdit'), False) + self.can_share = cloud_data.get(self._cc('canShare'), False) + self.can_view_private_items = cloud_data.get( + self._cc('canViewPrivateItems'), False)
+ + def __str__(self): + return self.__repr__() + + def __repr__(self): + return 'Calendar: {} from {}'.format(self.name, self.owner) + + def __eq__(self, other): + return self.calendar_id == other.calendar_id + + @property + def owner(self): + """ Owner of the calendar + + :rtype: str + """ + return self.__owner + +
[docs] def update(self): + """ Updates this calendar. Only name and color can be changed. + + :return: Success / Failure + :rtype: bool + """ + + if not self.calendar_id: + return False + + url = self.build_url(self._endpoints.get('calendar')) + + data = { + self._cc('name'): self.name, + self._cc('color'): self._cc(self.color.value + if isinstance(self.color, CalendarColor) + else self.color) + } + + response = self.con.patch(url, data=data) + + return bool(response)
+ +
[docs] def delete(self): + """ Deletes this calendar + + :return: Success / Failure + :rtype: bool + """ + + if not self.calendar_id: + return False + + url = self.build_url( + self._endpoints.get('calendar').format(id=self.calendar_id)) + + response = self.con.delete(url) + if not response: + return False + + self.calendar_id = None + + return True
+ +
[docs] def get_events(self, limit=25, *, query=None, order_by=None, batch=None, + download_attachments=False, include_recurring=True): + """ Get events from the this Calendar + + :param int limit: max no. of events to get. Over 999 uses batch. + :param query: applies a OData filter to the request + :type query: Query or str + :param order_by: orders the result set based on this condition + :type order_by: Query or str + :param int batch: batch size, retrieves items in + batches allowing to retrieve more items than the limit. + :param download_attachments: downloads event attachments + :param bool include_recurring: whether to include recurring events or not + :return: list of events in this calendar + :rtype: list[Event] or Pagination + """ + + if self.calendar_id is None: + # I'm the default calendar + if include_recurring: + url = self.build_url(self._endpoints.get('default_events_view')) + else: + url = self.build_url(self._endpoints.get('default_events')) + else: + if include_recurring: + url = self.build_url( + self._endpoints.get('events_view').format(id=self.calendar_id)) + else: + url = self.build_url( + self._endpoints.get('get_events').format(id=self.calendar_id)) + + if limit is None or limit > self.protocol.max_top_value: + batch = self.protocol.max_top_value + + if batch: + download_attachments = False + + params = {'$top': batch if batch else limit} + + if include_recurring: + start = None + end = None + if query and not isinstance(query, str): + # extract start and end from query because + # those are required by a calendarView + for query_data in query._filters: + if not isinstance(query_data, list): + continue + attribute = query_data[0] + # the 2nd position contains the filter data + # and the 3rd position in filter_data contains the value + word = query_data[2][3] + + if attribute.lower().startswith('start/'): + start = word.replace("'", '') # remove the quotes + query.remove_filter('start') + if attribute.lower().startswith('end/'): + end = word.replace("'", '') # remove the quotes + query.remove_filter('end') + + if start is None or end is None: + raise ValueError("When 'include_recurring' is True you must provide a 'start' and 'end' datetimes inside a Query instance.") + + if end < start: + raise ValueError('When using "include_recurring=True", the date asigned to the "end" datetime' + ' should be greater or equal than the date asigned to the "start" datetime.') + + params[self._cc('startDateTime')] = start + params[self._cc('endDateTime')] = end + + if order_by: + params['$orderby'] = order_by + + if query: + if isinstance(query, str): + params['$filter'] = query + else: + params.update(query.as_params()) + + response = self.con.get(url, params=params, + headers={'Prefer': 'outlook.timezone="UTC"'}) + if not response: + return iter(()) + + data = response.json() + + # Everything received from cloud must be passed as self._cloud_data_key + events = (self.event_constructor(parent=self, + download_attachments= + download_attachments, + **{self._cloud_data_key: event}) + for event in data.get('value', [])) + next_link = data.get(NEXT_LINK_KEYWORD, None) + if batch and next_link: + return Pagination(parent=self, data=events, + constructor=self.event_constructor, + next_link=next_link, limit=limit) + else: + return events
+ +
[docs] def new_event(self, subject=None): + """ Returns a new (unsaved) Event object + + :rtype: Event + """ + return self.event_constructor(parent=self, subject=subject, + calendar_id=self.calendar_id)
+ +
[docs] def get_event(self, param): + """ Returns an Event instance by it's id + + :param param: an event_id or a Query instance + :return: event for the specified info + :rtype: Event + """ + + if param is None: + return None + if isinstance(param, str): + url = self.build_url( + self._endpoints.get('get_event').format(id=self.calendar_id, + ide=param)) + params = None + by_id = True + else: + url = self.build_url( + self._endpoints.get('get_events').format(id=self.calendar_id)) + params = {'$top': 1} + params.update(param.as_params()) + by_id = False + + response = self.con.get(url, params=params, + headers={'Prefer': 'outlook.timezone="UTC"'}) + if not response: + return None + + if by_id: + event = response.json() + else: + event = response.json().get('value', []) + if event: + event = event[0] + else: + return None + return self.event_constructor(parent=self, + **{self._cloud_data_key: event})
+ + +
[docs]class Schedule(ApiComponent): + _endpoints = { + 'root_calendars': '/calendars', + 'get_calendar': '/calendars/{id}', + 'default_calendar': '/calendar', + 'get_availability': '/calendar/getSchedule', + } + + calendar_constructor = Calendar + event_constructor = Event + +
[docs] def __init__(self, *, parent=None, con=None, **kwargs): + """ Create a wrapper around calendars and events + + :param parent: parent for this operation + :type parent: Account + :param Connection con: connection to use if no parent specified + :param Protocol protocol: protocol to use if no parent specified + (kwargs) + :param str main_resource: use this resource instead of parent resource + (kwargs) + """ + if parent and con: + raise ValueError('Need a parent or a connection but not both') + self.con = parent.con if parent else con + + # Choose the main_resource passed in kwargs over parent main_resource + main_resource = kwargs.pop('main_resource', None) or ( + getattr(parent, 'main_resource', None) if parent else None) + + super().__init__( + protocol=parent.protocol if parent else kwargs.get('protocol'), + main_resource=main_resource)
+ + def __str__(self): + return self.__repr__() + + def __repr__(self): + return 'Schedule resource: {}'.format(self.main_resource) + +
[docs] def list_calendars(self, limit=None, *, query=None, order_by=None): + """ Gets a list of calendars + + To use query an order_by check the OData specification here: + http://docs.oasis-open.org/odata/odata/v4.0/errata03/os/complete/ + part2-url-conventions/odata-v4.0-errata03-os-part2-url-conventions + -complete.html + + :param int limit: max no. of calendars to get. Over 999 uses batch. + :param query: applies a OData filter to the request + :type query: Query or str + :param order_by: orders the result set based on this condition + :type order_by: Query or str + :return: list of calendars + :rtype: list[Calendar] + + """ + url = self.build_url(self._endpoints.get('root_calendars')) + + params = {} + if limit: + params['$top'] = limit + if query: + params['$filter'] = str(query) + if order_by: + params['$orderby'] = order_by + + response = self.con.get(url, params=params or None) + if not response: + return [] + + data = response.json() + + # Everything received from cloud must be passed as self._cloud_data_key + contacts = [self.calendar_constructor(parent=self, **{ + self._cloud_data_key: x}) for x in data.get('value', [])] + + return contacts
+ +
[docs] def new_calendar(self, calendar_name): + """ Creates a new calendar + + :param str calendar_name: name of the new calendar + :return: a new Calendar instance + :rtype: Calendar + """ + if not calendar_name: + return None + + url = self.build_url(self._endpoints.get('root_calendars')) + + response = self.con.post(url, data={self._cc('name'): calendar_name}) + if not response: + return None + + data = response.json() + + # Everything received from cloud must be passed as self._cloud_data_key + return self.calendar_constructor(parent=self, + **{self._cloud_data_key: data})
+ +
[docs] def get_calendar(self, calendar_id=None, calendar_name=None): + """ Returns a calendar by it's id or name + + :param str calendar_id: the calendar id to be retrieved. + :param str calendar_name: the calendar name to be retrieved. + :return: calendar for the given info + :rtype: Calendar + """ + if calendar_id and calendar_name: + raise RuntimeError('Provide only one of the options') + + if not calendar_id and not calendar_name: + raise RuntimeError('Provide one of the options') + + if calendar_id: + # get calendar by it's id + url = self.build_url( + self._endpoints.get('get_calendar').format(id=calendar_id)) + params = None + else: + # get calendar by name + url = self.build_url(self._endpoints.get('root_calendars')) + params = { + '$filter': "{} eq '{}'".format(self._cc('name'), calendar_name), + '$top': 1} + + response = self.con.get(url, params=params) + if not response: + return None + + if calendar_id: + data = response.json() + else: + data = response.json().get('value') + data = data[0] if data else None + if data is None: + return None + + # Everything received from cloud must be passed as self._cloud_data_key + return self.calendar_constructor(parent=self, + **{self._cloud_data_key: data})
+ +
[docs] def get_default_calendar(self): + """ Returns the default calendar for the current user + + :rtype: Calendar + """ + + url = self.build_url(self._endpoints.get('default_calendar')) + + response = self.con.get(url) + if not response: + return None + + data = response.json() + + # Everything received from cloud must be passed as self._cloud_data_key + return self.calendar_constructor(parent=self, + **{self._cloud_data_key: data})
+ +
[docs] def get_events(self, limit=25, *, query=None, order_by=None, batch=None, + download_attachments=False, include_recurring=True): + """ Get events from the default Calendar + + :param int limit: max no. of events to get. Over 999 uses batch. + :param query: applies a OData filter to the request + :type query: Query or str + :param order_by: orders the result set based on this condition + :type order_by: Query or str + :param int batch: batch size, retrieves items in + batches allowing to retrieve more items than the limit. + :param bool download_attachments: downloads event attachments + :param bool include_recurring: whether to include recurring events or not + :return: list of items in this folder + :rtype: list[Event] or Pagination + """ + + default_calendar = self.calendar_constructor(parent=self) + + return default_calendar.get_events(limit=limit, query=query, + order_by=order_by, batch=batch, + download_attachments=download_attachments, + include_recurring=include_recurring)
+ +
[docs] def new_event(self, subject=None): + """ Returns a new (unsaved) Event object in the default calendar + + :param str subject: subject text for the new event + :return: new event + :rtype: Event + """ + return self.event_constructor(parent=self, subject=subject)
+ +
[docs] def get_availability(self, schedules, start, end, interval=60): + """ + Returns the free/busy availability for a set of users in a given time frame + :param list schedules: a list of strings (email addresses) + :param datetime start: the start time frame to look for available space + :param datetime end: the end time frame to look for available space + :param int interval: the number of minutes to look for space + """ + url = self.build_url(self._endpoints.get('get_availability')) + + data = { + 'startTime': self._build_date_time_time_zone(start), + 'endTime': self._build_date_time_time_zone(end), + 'availabilityViewInterval': interval, + 'schedules': schedules + } + + response = self.con.post(url, data=data) + if not response: + return [] + + data = response.json().get('value', []) + + # transform dates and availabilityView + availability_view_codes = { + '0': 'free', + '1': 'tentative', + '2': 'busy', + '3': 'out of office', + '4': 'working elsewhere', + } + for schedule in data: + a_view = schedule.get('availabilityView', '') + schedule['availabilityView'] = [availability_view_codes.get(code, 'unkknown') for code in a_view] + for item in schedule.get('scheduleItems', []): + item['start'] = self._parse_date_time_time_zone(item.get('start')) + item['end'] = self._parse_date_time_time_zone(item.get('end')) + + return data
+
+ +
+ +
+ + +
+
+ +
+ +
+ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/O365/category.html b/docs/latest/_modules/O365/category.html new file mode 100644 index 00000000..5b8684f1 --- /dev/null +++ b/docs/latest/_modules/O365/category.html @@ -0,0 +1,345 @@ + + + + + + + + O365.category — O365 documentation + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+ +
+
+
+
+ +

Source code for O365.category

+from enum import Enum
+
+from .utils import ApiComponent
+
+
+
+[docs] +class CategoryColor(Enum): + RED = 'preset0' # 0 + ORANGE = 'preset1' # 1 + BROWN = 'preset2' # 2 + YELLOW = 'preset3' # 3 + GREEN = 'preset4' # 4 + TEAL = 'preset5' # 5 + OLIVE = 'preset6' # 6 + BLUE = 'preset7' # 7 + PURPLE = 'preset8' # 8 + CRANBERRY = 'preset9' # 9 + STEEL = 'preset10' # 10 + DARKSTEEL = 'preset11' # 11 + GRAY = 'preset12' # 12 + DARKGREY = 'preset13' # 13 + BLACK = 'preset14' # 14 + DARKRED = 'preset15' # 15 + DARKORANGE = 'preset16' # 16 + DARKBROWN = 'preset17' # 17 + DARKYELLOW = 'preset18' # 18 + DARKGREEN = 'preset19' # 19 + DARKTEAL = 'preset20' # 20 + DARKOLIVE = 'preset21' # 21 + DARKBLUE = 'preset22' # 22 + DARKPURPLE = 'preset23' # 23 + DARKCRANBERRY = 'preset24' # 24 + +
+[docs] + @classmethod + def get(cls, color): + """ + Gets a color by name or value. + Raises ValueError if not found whithin the collection of colors. + """ + try: + return cls(color.capitalize()) # 'preset0' to 'Preset0' + except ValueError: + pass + try: + return cls[color.upper()] # 'red' to 'RED' + except KeyError: + raise ValueError('color is not a valid color from CategoryColor') from None
+
+ + + +
+[docs] +class Category(ApiComponent): + + _endpoints = { + 'update': '/outlook/masterCategories/{id}' + } + +
+[docs] + def __init__(self, *, parent=None, con=None, **kwargs): + """Represents a category by which a user can group Outlook items such as messages and events. + It can be used in conjunction with Event, Message, Contact and Post. + + :param parent: parent object + :type parent: Account + :param Connection con: connection to use if no parent specified + :param Protocol protocol: protocol to use if no parent specified + (kwargs) + :param str main_resource: use this resource instead of parent resource + (kwargs) + + """ + + if parent and con: + raise ValueError('Need a parent or a connection but not both') + self.con = parent.con if parent else con + + # Choose the main_resource passed in kwargs over parent main_resource + main_resource = kwargs.pop('main_resource', None) or ( + getattr(parent, 'main_resource', None) if parent else None) + + super().__init__( + protocol=parent.protocol if parent else kwargs.get('protocol'), + main_resource=main_resource) + + cloud_data = kwargs.get(self._cloud_data_key, {}) + + self.object_id = cloud_data.get('id') + self.name = cloud_data.get(self._cc('displayName')) + color = cloud_data.get(self._cc('color')) + self.color = CategoryColor(color) if color else None
+ + + def __str__(self): + return self.__repr__() + + def __repr__(self): + return '{} (color: {})'.format(self.name, self.color.name if self.color else None) + +
+[docs] + def update_color(self, color): + """ + Updates this Category color + :param None or str or CategoryColor color: the category color + """ + url = self.build_url(self._endpoints.get('update').format(id=self.object_id)) + if color is not None and not isinstance(color, CategoryColor): + color = CategoryColor.get(color) + + response = self.con.patch(url, data={'color': color.value if color else None}) + if not response: + return False + + self.color = color + return True
+ + +
+[docs] + def delete(self): + """ Deletes this Category """ + url = self.build_url(self._endpoints.get('update').format(id=self.object_id)) + + response = self.con.delete(url) + + return bool(response)
+
+ + + +
+[docs] +class Categories(ApiComponent): + + _endpoints = { + 'list': '/outlook/masterCategories', + 'get': '/outlook/masterCategories/{id}', + } + + category_constructor = Category + +
+[docs] + def __init__(self, *, parent=None, con=None, **kwargs): + """ Object to retrive categories + + :param parent: parent object + :type parent: Account + :param Connection con: connection to use if no parent specified + :param Protocol protocol: protocol to use if no parent specified + (kwargs) + :param str main_resource: use this resource instead of parent resource + (kwargs) + """ + + if parent and con: + raise ValueError('Need a parent or a connection but not both') + self.con = parent.con if parent else con + + # Choose the main_resource passed in kwargs over parent main_resource + main_resource = kwargs.pop('main_resource', None) or ( + getattr(parent, 'main_resource', None) if parent else None) + + super().__init__( + protocol=parent.protocol if parent else kwargs.get('protocol'), + main_resource=main_resource)
+ + +
+[docs] + def get_categories(self): + """ Returns a list of categories""" + url = self.build_url(self._endpoints.get('list')) + + response = self.con.get(url) + if not response: + return [] + + data = response.json() + + return [ + self.category_constructor(parent=self, **{self._cloud_data_key: category}) + for category in data.get('value', []) + ]
+ + +
+[docs] + def get_category(self, category_id): + """ Returns a category by id""" + url = self.build_url(self._endpoints.get('get').format(id=category_id)) + + response = self.con.get(url) + if not response: + return None + + data = response.json() + + return self.category_constructor(parent=self, **{self._cloud_data_key: data})
+ + +
+[docs] + def create_category(self, name, color='auto'): + """ + Creates a category. + If the color is not provided it will be choosed from the pool of unused colors. + + :param str name: The name of this outlook category. Must be unique. + :param str or CategoryColor color: optional color. If not provided will be assigned automatically. + :return: bool + """ + if color == 'auto': + used_colors = {category.color for category in self.get_categories()} + all_colors = {color for color in CategoryColor} + available_colors = all_colors - used_colors + try: + color = available_colors.pop() + except KeyError: + # re-use a color + color = all_colors.pop() + else: + if color is not None and not isinstance(color, CategoryColor): + color = CategoryColor.get(color) + + url = self.build_url(self._endpoints.get('list')) + data = {self._cc('displayName'): name, 'color': color.value if color else None} + response = self.con.post(url, data=data) + if not response: + return None + + category = response.json() + + return self.category_constructor(parent=self, **{self._cloud_data_key: category})
+
+ +
+ +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/docs/latest/_modules/O365/connection.html b/docs/latest/_modules/O365/connection.html new file mode 100644 index 00000000..439ca402 --- /dev/null +++ b/docs/latest/_modules/O365/connection.html @@ -0,0 +1,1100 @@ + + + + + + + + + + + O365.connection — O365 documentation + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + +
+ +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+
+
+
+ +

Source code for O365.connection

+import json
+import logging
+import os
+import time
+
+from oauthlib.oauth2 import TokenExpiredError, WebApplicationClient, BackendApplicationClient
+from requests import Session
+from requests.adapters import HTTPAdapter
+from requests.exceptions import HTTPError, RequestException, ProxyError
+from requests.exceptions import SSLError, Timeout, ConnectionError
+# Dynamic loading of module Retry by requests.packages
+# noinspection PyUnresolvedReferences
+from requests.packages.urllib3.util.retry import Retry
+from requests_oauthlib import OAuth2Session
+from stringcase import pascalcase, camelcase, snakecase
+from tzlocal import get_localzone
+from pytz import UnknownTimeZoneError, UTC, timezone as get_timezone
+
+from .utils import ME_RESOURCE, BaseTokenBackend, FileSystemTokenBackend, Token
+
+log = logging.getLogger(__name__)
+
+O365_API_VERSION = 'v2.0'
+GRAPH_API_VERSION = 'v1.0'
+OAUTH_REDIRECT_URL = 'https://login.microsoftonline.com/common/oauth2/nativeclient'  # version <= 1.1.3.  : 'https://outlook.office365.com/owa/'
+
+RETRIES_STATUS_LIST = (
+    429,  # Status code for TooManyRequests
+    500, 502, 503, 504  # Server errors
+)
+RETRIES_BACKOFF_FACTOR = 0.5
+
+DEFAULT_SCOPES = {
+    # wrap any scope in a 1 element tuple to avoid prefixing
+    'basic': [('offline_access',), 'User.Read'],
+    'mailbox': ['Mail.Read'],
+    'mailbox_shared': ['Mail.Read.Shared'],
+    'message_send': ['Mail.Send'],
+    'message_send_shared': ['Mail.Send.Shared'],
+    'message_all': ['Mail.ReadWrite', 'Mail.Send'],
+    'message_all_shared': ['Mail.ReadWrite.Shared', 'Mail.Send.Shared'],
+    'address_book': ['Contacts.Read'],
+    'address_book_shared': ['Contacts.Read.Shared'],
+    'address_book_all': ['Contacts.ReadWrite'],
+    'address_book_all_shared': ['Contacts.ReadWrite.Shared'],
+    'calendar': ['Calendars.Read'],
+    'calendar_shared': ['Calendars.Read.Shared'],
+    'calendar_all': ['Calendars.ReadWrite'],
+    'calendar_shared_all': ['Calendars.ReadWrite.Shared'],
+    'users': ['User.ReadBasic.All'],
+    'onedrive': ['Files.Read.All'],
+    'onedrive_all': ['Files.ReadWrite.All'],
+    'sharepoint': ['Sites.Read.All'],
+    'sharepoint_dl': ['Sites.ReadWrite.All'],
+    'settings_all': ['MailboxSettings.ReadWrite'],
+}
+
+
+
[docs]class Protocol: + """ Base class for all protocols """ + + # Override these in subclass + _protocol_url = 'not_defined' # Main url to request. + _oauth_scope_prefix = '' # Prefix for scopes + _oauth_scopes = {} # Dictionary of {scopes_name: [scope1, scope2]} + +
[docs] def __init__(self, *, protocol_url=None, api_version=None, + default_resource=None, + casing_function=None, protocol_scope_prefix=None, + timezone=None, **kwargs): + """ Create a new protocol object + + :param str protocol_url: the base url used to communicate with the + server + :param str api_version: the api version + :param str default_resource: the default resource to use when there is + nothing explicitly specified during the requests + :param function casing_function: the casing transform function to be + used on api keywords (camelcase / pascalcase) + :param str protocol_scope_prefix: prefix url for scopes + :param pytz.UTC or str timezone: preferred timezone, defaults to the + system timezone + :raises ValueError: if protocol_url or api_version are not supplied + """ + if protocol_url is None or api_version is None: + raise ValueError( + 'Must provide valid protocol_url and api_version values') + self.protocol_url = protocol_url or self._protocol_url + self.protocol_scope_prefix = protocol_scope_prefix or '' + self.api_version = api_version + self.service_url = '{}{}/'.format(protocol_url, api_version) + self.default_resource = default_resource or ME_RESOURCE + self.use_default_casing = True if casing_function is None else False + self.casing_function = casing_function or camelcase + if timezone and isinstance(timezone, str): + timezone = get_timezone(timezone) + try: + self.timezone = timezone or get_localzone() # pytz timezone + except UnknownTimeZoneError as e: + log.info('Timezone not provided and the local timezone could not be found. Default to UTC.') + self.timezone = UTC # pytz.timezone('UTC') + self.max_top_value = 500 # Max $top parameter value + + # define any keyword that can be different in this protocol + # for example, attachments Odata type differs between Outlook + # rest api and graph: (graph = #microsoft.graph.fileAttachment and + # outlook = #Microsoft.OutlookServices.FileAttachment') + self.keyword_data_store = {}
+ +
[docs] def get_service_keyword(self, keyword): + """ Returns the data set to the key in the internal data-key dict + + :param str keyword: key to get value for + :return: value of the keyword + """ + return self.keyword_data_store.get(keyword, None)
+ +
[docs] def convert_case(self, key): + """ Returns a key converted with this protocol casing method + + Converts case to send/read from the cloud + + When using Microsoft Graph API, the keywords of the API use + lowerCamelCase Casing + + When using Office 365 API, the keywords of the API use PascalCase Casing + + Default case in this API is lowerCamelCase + + :param str key: a dictionary key to convert + :return: key after case conversion + :rtype: str + """ + return key if self.use_default_casing else self.casing_function(key)
+ +
[docs] @staticmethod + def to_api_case(key): + """ Converts key to snake_case + + :param str key: key to convert into snake_case + :return: key after case conversion + :rtype: str + """ + return snakecase(key)
+ +
[docs] def get_scopes_for(self, user_provided_scopes): + """ Returns a list of scopes needed for each of the + scope_helpers provided, by adding the prefix to them if required + + :param user_provided_scopes: a list of scopes or scope helpers + :type user_provided_scopes: list or tuple or str + :return: scopes with url prefix added + :rtype: list + :raises ValueError: if unexpected datatype of scopes are passed + """ + if user_provided_scopes is None: + # return all available scopes + user_provided_scopes = [app_part for app_part in self._oauth_scopes] + elif isinstance(user_provided_scopes, str): + user_provided_scopes = [user_provided_scopes] + + if not isinstance(user_provided_scopes, (list, tuple)): + raise ValueError( + "'user_provided_scopes' must be a list or a tuple of strings") + + scopes = set() + for app_part in user_provided_scopes: + for scope in self._oauth_scopes.get(app_part, [(app_part,)]): + scopes.add(self.prefix_scope(scope)) + + return list(scopes)
+ +
[docs] def prefix_scope(self, scope): + """ Inserts the protocol scope prefix if required""" + if self.protocol_scope_prefix: + if isinstance(scope, tuple): + return scope[0] + elif scope.startswith(self.protocol_scope_prefix): + return scope + else: + return '{}{}'.format(self.protocol_scope_prefix, scope) + else: + if isinstance(scope, tuple): + return scope[0] + else: + return scope
+ + +
[docs]class MSGraphProtocol(Protocol): + """ A Microsoft Graph Protocol Implementation + https://docs.microsoft.com/en-us/outlook/rest/compare-graph-outlook + """ + + _protocol_url = 'https://graph.microsoft.com/' + _oauth_scope_prefix = 'https://graph.microsoft.com/' + _oauth_scopes = DEFAULT_SCOPES + +
[docs] def __init__(self, api_version='v1.0', default_resource=None, + **kwargs): + """ Create a new Microsoft Graph protocol object + + _protocol_url = 'https://graph.microsoft.com/' + + _oauth_scope_prefix = 'https://graph.microsoft.com/' + + :param str api_version: api version to use + :param str default_resource: the default resource to use when there is + nothing explicitly specified during the requests + """ + super().__init__(protocol_url=self._protocol_url, + api_version=api_version, + default_resource=default_resource, + casing_function=camelcase, + protocol_scope_prefix=self._oauth_scope_prefix, + **kwargs) + + self.keyword_data_store['message_type'] = 'microsoft.graph.message' + self.keyword_data_store['event_message_type'] = 'microsoft.graph.eventMessage' + self.keyword_data_store[ + 'file_attachment_type'] = '#microsoft.graph.fileAttachment' + self.keyword_data_store[ + 'item_attachment_type'] = '#microsoft.graph.itemAttachment' + self.max_top_value = 999 # Max $top parameter value
+ + +
[docs]class MSOffice365Protocol(Protocol): + """ A Microsoft Office 365 Protocol Implementation + https://docs.microsoft.com/en-us/outlook/rest/compare-graph-outlook + """ + + _protocol_url = 'https://outlook.office.com/api/' + _oauth_scope_prefix = 'https://outlook.office.com/' + _oauth_scopes = DEFAULT_SCOPES + +
[docs] def __init__(self, api_version='v2.0', default_resource=None, + **kwargs): + """ Create a new Office 365 protocol object + + _protocol_url = 'https://outlook.office.com/api/' + + _oauth_scope_prefix = 'https://outlook.office.com/' + + :param str api_version: api version to use + :param str default_resource: the default resource to use when there is + nothing explicitly specified during the requests + """ + super().__init__(protocol_url=self._protocol_url, + api_version=api_version, + default_resource=default_resource, + casing_function=pascalcase, + protocol_scope_prefix=self._oauth_scope_prefix, + **kwargs) + + self.keyword_data_store[ + 'message_type'] = 'Microsoft.OutlookServices.Message' + self.keyword_data_store[ + 'event_message_type'] = 'Microsoft.OutlookServices.EventMessage' + self.keyword_data_store[ + 'file_attachment_type'] = '#Microsoft.OutlookServices.' \ + 'FileAttachment' + self.keyword_data_store[ + 'item_attachment_type'] = '#Microsoft.OutlookServices.' \ + 'ItemAttachment' + self.max_top_value = 999 # Max $top parameter value
+ + +
[docs]class MSBusinessCentral365Protocol(Protocol): + + """ A Microsoft Business Central Protocol Implementation + https://docs.microsoft.com/en-us/dynamics-nav/api-reference/v1.0/endpoints-apis-for-dynamics + """ + + _protocol_url = 'https://api.businesscentral.dynamics.com/' + _oauth_scope_prefix = 'https://api.businesscentral.dynamics.com/' + _oauth_scopes = DEFAULT_SCOPES + _protocol_scope_prefix = 'https://api.businesscentral.dynamics.com/' + +
[docs] def __init__(self, api_version='v1.0', default_resource=None,environment=None, + **kwargs): + """ Create a new Microsoft Graph protocol object + + _protocol_url = 'https://api.businesscentral.dynamics.com/' + + _oauth_scope_prefix = 'https://api.businesscentral.dynamics.com/' + + :param str api_version: api version to use + :param str default_resource: the default resource to use when there is + nothing explicitly specified during the requests + """ + if environment: + _version = "2.0" + _environment = "/"+environment + else: + _version = "1.0" + _environment = '' + + self._protocol_url = "{}v{}{}/api/".format(self._protocol_url, _version, _environment) + + super().__init__(protocol_url=self._protocol_url, + api_version=api_version, + default_resource=default_resource, + casing_function=camelcase, + protocol_scope_prefix=self._protocol_scope_prefix, + **kwargs) + + self.keyword_data_store['message_type'] = 'microsoft.graph.message' + self.keyword_data_store['event_message_type'] = 'microsoft.graph.eventMessage' + self.keyword_data_store[ + 'file_attachment_type'] = '#microsoft.graph.fileAttachment' + self.keyword_data_store[ + 'item_attachment_type'] = '#microsoft.graph.itemAttachment' + self.max_top_value = 999 # Max $top parameter value
+ + +
[docs]class Connection: + """ Handles all communication (requests) between the app and the server """ + + _allowed_methods = ['get', 'post', 'put', 'patch', 'delete'] + +
[docs] def __init__(self, credentials, *, scopes=None, + proxy_server=None, proxy_port=8080, proxy_username=None, + proxy_password=None, requests_delay=200, raise_http_errors=True, + request_retries=3, token_backend=None, + tenant_id='common', + auth_flow_type='authorization', + timeout=None, json_encoder=None, + verify_ssl=True, **kwargs): + """ Creates an API connection object + + :param tuple credentials: a tuple of (client_id, client_secret) + + Generate client_id and client_secret in https://apps.dev.microsoft.com + :param list[str] scopes: list of scopes to request access to + :param str proxy_server: the proxy server + :param int proxy_port: the proxy port, defaults to 8080 + :param str proxy_username: the proxy username + :param str proxy_password: the proxy password + :param int requests_delay: number of milliseconds to wait between api + calls. + The Api will respond with 429 Too many requests if more than + 17 requests are made per second. Defaults to 200 milliseconds + just in case more than 1 connection is making requests + across multiple processes. + :param bool raise_http_errors: If True Http 4xx and 5xx status codes + will raise as exceptions + :param int request_retries: number of retries done when the server + responds with 5xx error codes. + :param BaseTokenBackend token_backend: the token backend used to get + and store tokens + :param str tenant_id: use this specific tenant id, defaults to common + :param str auth_flow_type: the auth method flow style used: Options: + - 'authorization': 2 step web style grant flow using an authentication url + - 'public': 2 step web style grant flow using an authentication url for public apps where + client secret cannot be secured + - 'credentials': also called client credentials grant flow using only the cliend id and secret + :param float or tuple timeout: How long to wait for the server to send + data before giving up, as a float, or a tuple (connect timeout, read timeout) + :param JSONEncoder json_encoder: The JSONEnocder to use during the JSON serialization on the request. + :param bool verify_ssl: set the verify flag on the requests library + :param dict kwargs: any extra params passed to Connection + :raises ValueError: if credentials is not tuple of + (client_id, client_secret) + """ + if auth_flow_type == 'public': # allow client id only for public flow + if not isinstance(credentials, tuple) or len(credentials) != 1 or (not credentials[0]): + raise ValueError('Provide client id only for public flow credentials') + else: + if not isinstance(credentials, tuple) or len(credentials) != 2 or (not credentials[0] and not credentials[1]): + raise ValueError('Provide valid auth credentials') + + self._auth_flow_type = auth_flow_type # 'authorization' or 'credentials' or 'public' + if auth_flow_type == 'credentials' and tenant_id == 'common': + raise ValueError('When using the "credentials" auth_flow the "tenant_id" must be set') + + self.tenant_id = tenant_id + self.auth = credentials + self.scopes = scopes + self.store_token = True + token_backend = token_backend or FileSystemTokenBackend() + if not isinstance(token_backend, BaseTokenBackend): + raise ValueError('"token_backend" must be an instance of a subclass of BaseTokenBackend') + self.token_backend = token_backend + self.session = None # requests Oauth2Session object + + self.proxy = {} + self.set_proxy(proxy_server, proxy_port, proxy_username, proxy_password) + self.requests_delay = requests_delay or 0 + self._previous_request_at = None # store previous request time + self.raise_http_errors = raise_http_errors + self.request_retries = request_retries + self.timeout = timeout + self.json_encoder = json_encoder + self.verify_ssl = verify_ssl + + self.naive_session = None # lazy loaded: holds a requests Session object + + self._oauth2_authorize_url = 'https://login.microsoftonline.com/' \ + '{}/oauth2/v2.0/authorize'.format(tenant_id) + self._oauth2_token_url = 'https://login.microsoftonline.com/' \ + '{}/oauth2/v2.0/token'.format(tenant_id) + self.oauth_redirect_url = 'https://login.microsoftonline.com/common/oauth2/nativeclient'
+ + @property + def auth_flow_type(self): + return self._auth_flow_type + +
[docs] def set_proxy(self, proxy_server, proxy_port, proxy_username, + proxy_password): + """ Sets a proxy on the Session + + :param str proxy_server: the proxy server + :param int proxy_port: the proxy port, defaults to 8080 + :param str proxy_username: the proxy username + :param str proxy_password: the proxy password + """ + if proxy_server and proxy_port: + if proxy_username and proxy_password: + self.proxy = { + "http": "http://{}:{}@{}:{}".format(proxy_username, + proxy_password, + proxy_server, + proxy_port), + "https": "https://{}:{}@{}:{}".format(proxy_username, + proxy_password, + proxy_server, + proxy_port), + } + else: + self.proxy = { + "http": "http://{}:{}".format(proxy_server, proxy_port), + "https": "https://{}:{}".format(proxy_server, proxy_port), + }
+ +
[docs] def get_authorization_url(self, requested_scopes=None, + redirect_uri=None, **kwargs): + """ Initializes the oauth authorization flow, getting the + authorization url that the user must approve. + + :param list[str] requested_scopes: list of scopes to request access for + :param str redirect_uri: redirect url configured in registered app + :param kwargs: allow to pass unused params in conjunction with Connection + :return: authorization url + :rtype: str + """ + + redirect_uri = redirect_uri or self.oauth_redirect_url + + scopes = requested_scopes or self.scopes + if not scopes: + raise ValueError('Must provide at least one scope') + + self.session = oauth = self.get_session(redirect_uri=redirect_uri, + scopes=scopes) + + # TODO: access_type='offline' has no effect according to documentation + # This is done through scope 'offline_access'. + auth_url, state = oauth.authorization_url( + url=self._oauth2_authorize_url, access_type='offline') + + return auth_url, state
+ +
[docs] def request_token(self, authorization_url, *, + state=None, + redirect_uri=None, + requested_scopes=None, + store_token=True, + **kwargs): + """ Authenticates for the specified url and gets the token, save the + token for future based if requested + + :param str or None authorization_url: url given by the authorization flow + :param str state: session-state identifier for web-flows + :param str redirect_uri: callback url for web-flows + :param lst requested_scopes: a list of scopes to be requested. + Only used when auth_flow_type is 'credentials' + :param bool store_token: whether or not to store the token, + so you don't have to keep opening the auth link and + authenticating every time + :param kwargs: allow to pass unused params in conjunction with Connection + :return: Success/Failure + :rtype: bool + """ + + redirect_uri = redirect_uri or self.oauth_redirect_url + + # Allow token scope to not match requested scope. + # (Other auth libraries allow this, but Requests-OAuthlib + # raises exception on scope mismatch by default.) + os.environ['OAUTHLIB_RELAX_TOKEN_SCOPE'] = '1' + os.environ['OAUTHLIB_IGNORE_SCOPE_CHANGE'] = '1' + + scopes = requested_scopes or self.scopes + + if self.session is None: + if self.auth_flow_type in ('authorization', 'public'): + self.session = self.get_session(state=state, + redirect_uri=redirect_uri) + elif self.auth_flow_type == 'credentials': + self.session = self.get_session(scopes=scopes) + else: + raise ValueError('"auth_flow_type" must be "authorization", "public" or "credentials"') + + try: + if self.auth_flow_type == 'authorization': + self.token_backend.token = Token(self.session.fetch_token( + token_url=self._oauth2_token_url, + authorization_response=authorization_url, + include_client_id=True, + client_secret=self.auth[1])) + elif self.auth_flow_type == 'public': + self.token_backend.token = Token(self.session.fetch_token( + token_url=self._oauth2_token_url, + authorization_response=authorization_url, + include_client_id=True)) + elif self.auth_flow_type == 'credentials': + self.token_backend.token = Token(self.session.fetch_token( + token_url=self._oauth2_token_url, + include_client_id=True, + client_secret=self.auth[1], + scope=scopes)) + except Exception as e: + log.error('Unable to fetch auth token. Error: {}'.format(str(e))) + return False + + if store_token: + self.token_backend.save_token() + return True
+ +
[docs] def get_session(self, *, state=None, + redirect_uri=None, + load_token=False, + scopes=None): + """ Create a requests Session object + + :param str state: session-state identifier to rebuild OAuth session (CSRF protection) + :param str redirect_uri: callback URL specified in previous requests + :param list(str) scopes: list of scopes we require access to + :param bool load_token: load and ensure token is present + :return: A ready to use requests session, or a rebuilt in-flow session + :rtype: OAuth2Session + """ + + redirect_uri = redirect_uri or self.oauth_redirect_url + + client_id = self.auth[0] + + if self.auth_flow_type in ('authorization', 'public'): + oauth_client = WebApplicationClient(client_id=client_id) + elif self.auth_flow_type == 'credentials': + oauth_client = BackendApplicationClient(client_id=client_id) + else: + raise ValueError('"auth_flow_type" must be "authorization", "credentials" or "public"') + + requested_scopes = scopes or self.scopes + + if load_token: + # gets a fresh token from the store + token = self.token_backend.get_token() + if token is None: + raise RuntimeError('No auth token found. Authentication Flow needed') + + oauth_client.token = token + if self.auth_flow_type in ('authorization', 'public'): + requested_scopes = None # the scopes are already in the token (Not if type is backend) + session = OAuth2Session(client_id=client_id, + client=oauth_client, + token=token, + scope=requested_scopes) + else: + session = OAuth2Session(client_id=client_id, + client=oauth_client, + state=state, + redirect_uri=redirect_uri, + scope=requested_scopes) + + session.verify = self.verify_ssl + session.proxies = self.proxy + + if self.request_retries: + retry = Retry(total=self.request_retries, read=self.request_retries, + connect=self.request_retries, + backoff_factor=RETRIES_BACKOFF_FACTOR, + status_forcelist=RETRIES_STATUS_LIST) + adapter = HTTPAdapter(max_retries=retry) + session.mount('http://', adapter) + session.mount('https://', adapter) + + return session
+ +
[docs] def get_naive_session(self): + """ Creates and returns a naive session """ + naive_session = Session() # requests Session object + naive_session.proxies = self.proxy + naive_session.verify = self.verify_ssl + + if self.request_retries: + retry = Retry(total=self.request_retries, read=self.request_retries, + connect=self.request_retries, + backoff_factor=RETRIES_BACKOFF_FACTOR, + status_forcelist=RETRIES_STATUS_LIST) + adapter = HTTPAdapter(max_retries=retry) + naive_session.mount('http://', adapter) + naive_session.mount('https://', adapter) + + return naive_session
+ +
[docs] def refresh_token(self): + """ + Refresh the OAuth authorization token. + This will be called automatically when the access token + expires, however, you can manually call this method to + request a new refresh token. + :return bool: Success / Failure + """ + if self.session is None: + self.session = self.get_session(load_token=True) + + token = self.token_backend.token + if not token: + raise RuntimeError('Token not found.') + + if token.is_long_lived or self.auth_flow_type == 'credentials': + log.info('Refreshing token') + if self.auth_flow_type == 'authorization': + client_id, client_secret = self.auth + self.token_backend.token = Token( + self.session.refresh_token( + self._oauth2_token_url, + client_id=client_id, + client_secret=client_secret) + ) + elif self.auth_flow_type == 'public': + client_id = self.auth[0] + self.token_backend.token = Token( + self.session.refresh_token( + self._oauth2_token_url, + client_id=client_id) + ) + elif self.auth_flow_type == 'credentials': + if self.request_token(None, store_token=False) is False: + log.error('Refresh for Client Credentials Grant Flow failed.') + return False + log.info('New oauth token fetched by refresh method') + else: + log.error('You can not refresh an access token that has no "refreh_token" available.' + 'Include "offline_access" scope when authenticating to get a "refresh_token"') + return False + + if self.store_token: + self.token_backend.save_token() + return True
+ + def _check_delay(self): + """ Checks if a delay is needed between requests and sleeps if True """ + if self._previous_request_at: + dif = round(time.time() - self._previous_request_at, + 2) * 1000 # difference in miliseconds + if dif < self.requests_delay: + sleep_for = (self.requests_delay - dif) + log.info('Sleeping for {} miliseconds'.format(sleep_for)) + time.sleep(sleep_for / 1000) # sleep needs seconds + self._previous_request_at = time.time() + + def _internal_request(self, request_obj, url, method, **kwargs): + """ Internal handling of requests. Handles Exceptions. + + :param request_obj: a requests session. + :param str url: url to send request to + :param str method: type of request (get/put/post/patch/delete) + :param kwargs: extra params to send to the request api + :return: Response of the request + :rtype: requests.Response + """ + method = method.lower() + if method not in self._allowed_methods: + raise ValueError('Method must be one of the allowed ones') + if method == 'get': + kwargs.setdefault('allow_redirects', True) + elif method in ['post', 'put', 'patch']: + if 'headers' not in kwargs: + kwargs['headers'] = {} + if kwargs.get('headers') is not None and kwargs['headers'].get( + 'Content-type') is None: + kwargs['headers']['Content-type'] = 'application/json' + if 'data' in kwargs and kwargs['data'] is not None and kwargs['headers'].get( + 'Content-type') == 'application/json': + kwargs['data'] = json.dumps(kwargs['data'], cls=self.json_encoder) # convert to json + + if self.timeout is not None: + kwargs['timeout'] = self.timeout + + request_done = False + token_refreshed = False + + while not request_done: + self._check_delay() # sleeps if needed + try: + log.info('Requesting ({}) URL: {}'.format(method.upper(), url)) + log.info('Request parameters: {}'.format(kwargs)) + # auto_retry will occur inside this function call if enabled + response = request_obj.request(method, url, **kwargs) + response.raise_for_status() # raise 4XX and 5XX error codes. + log.info('Received response ({}) from URL {}'.format( + response.status_code, response.url)) + request_done = True + return response + except TokenExpiredError as e: + # Token has expired, try to refresh the token and try again on the next loop + log.info('Oauth Token is expired') + if self.token_backend.token.is_long_lived is False and self.auth_flow_type == 'authorization': + raise e + if token_refreshed: + # Refresh token done but still TokenExpiredError raise + raise RuntimeError('Token Refresh Operation not working') + should_rt = self.token_backend.should_refresh_token(self) + if should_rt is True: + # The backend has checked that we can refresh the token + if self.refresh_token() is False: + raise RuntimeError('Token Refresh Operation not working') + token_refreshed = True + elif should_rt is False: + # the token was refreshed by another instance and updated into + # this instance, so: update the session token and + # go back to the loop and try the request again. + request_obj.token = self.token_backend.token + else: + # the refresh was performed by the tokend backend. + token_refreshed = True + + except (ConnectionError, ProxyError, SSLError, Timeout) as e: + # We couldn't connect to the target url, raise error + log.debug('Connection Error calling: {}.{}' + ''.format(url, ('Using proxy: {}'.format(self.proxy) + if self.proxy else ''))) + raise e # re-raise exception + except HTTPError as e: + # Server response with 4XX or 5XX error status codes + + # try to extract the error message: + try: + error = response.json() + error_message = error.get('error', {}).get('message', '') + except ValueError: + error_message = '' + + status_code = int(e.response.status_code / 100) + if status_code == 4: + # Client Error + # Logged as error. Could be a library error or Api changes + log.error('Client Error: {} | Error Message: {}'.format(str(e), error_message)) + else: + # Server Error + log.debug('Server Error: {}'.format(str(e))) + if self.raise_http_errors: + if error_message: + raise HTTPError('{} | Error Message: {}'.format(e.args[0], error_message), response=response) from None + else: + raise e + else: + return e.response + except RequestException as e: + # catch any other exception raised by requests + log.debug('Request Exception: {}'.format(str(e))) + raise e + +
[docs] def naive_request(self, url, method, **kwargs): + """ Makes a request to url using an without oauth authorization + session, but through a normal session + + :param str url: url to send request to + :param str method: type of request (get/put/post/patch/delete) + :param kwargs: extra params to send to the request api + :return: Response of the request + :rtype: requests.Response + """ + if self.naive_session is None: + # lazy creation of a naive session + self.naive_session = self.get_naive_session() + return self._internal_request(self.naive_session, url, method, **kwargs)
+ +
[docs] def oauth_request(self, url, method, **kwargs): + """ Makes a request to url using an oauth session + + :param str url: url to send request to + :param str method: type of request (get/put/post/patch/delete) + :param kwargs: extra params to send to the request api + :return: Response of the request + :rtype: requests.Response + """ + # oauth authentication + if self.session is None: + self.session = self.get_session(load_token=True) + + return self._internal_request(self.session, url, method, **kwargs)
+ +
[docs] def get(self, url, params=None, **kwargs): + """ Shorthand for self.oauth_request(url, 'get') + + :param str url: url to send get oauth request to + :param dict params: request parameter to get the service data + :param kwargs: extra params to send to request api + :return: Response of the request + :rtype: requests.Response + """ + return self.oauth_request(url, 'get', params=params, **kwargs)
+ +
[docs] def post(self, url, data=None, **kwargs): + """ Shorthand for self.oauth_request(url, 'post') + + :param str url: url to send post oauth request to + :param dict data: post data to update the service + :param kwargs: extra params to send to request api + :return: Response of the request + :rtype: requests.Response + """ + return self.oauth_request(url, 'post', data=data, **kwargs)
+ +
[docs] def put(self, url, data=None, **kwargs): + """ Shorthand for self.oauth_request(url, 'put') + + :param str url: url to send put oauth request to + :param dict data: put data to update the service + :param kwargs: extra params to send to request api + :return: Response of the request + :rtype: requests.Response + """ + return self.oauth_request(url, 'put', data=data, **kwargs)
+ +
[docs] def patch(self, url, data=None, **kwargs): + """ Shorthand for self.oauth_request(url, 'patch') + + :param str url: url to send patch oauth request to + :param dict data: patch data to update the service + :param kwargs: extra params to send to request api + :return: Response of the request + :rtype: requests.Response + """ + return self.oauth_request(url, 'patch', data=data, **kwargs)
+ +
[docs] def delete(self, url, **kwargs): + """ Shorthand for self.request(url, 'delete') + + :param str url: url to send delete oauth request to + :param kwargs: extra params to send to request api + :return: Response of the request + :rtype: requests.Response + """ + return self.oauth_request(url, 'delete', **kwargs)
+ + def __del__(self): + """ + Clear the session by closing it + This should be called manually by the user "del account.con" + There is no guarantee that this method will be called by the garbage collection + But this is not an issue because this connections will be automatically closed. + """ + if self.session: + self.session.close()
+ + +
[docs]def oauth_authentication_flow(client_id, client_secret, scopes=None, + protocol=None, **kwargs): + """ A helper method to perform the OAuth2 authentication flow. + Authenticate and get the oauth token + + :param str client_id: the client_id + :param str client_secret: the client_secret + :param list[str] scopes: a list of protocol user scopes to be converted + by the protocol or raw scopes + :param Protocol protocol: the protocol to be used. + Defaults to MSGraphProtocol + :param kwargs: other configuration to be passed to the Connection instance, + connection.get_authorization_url or connection.request_token + :return: Success or Failure + :rtype: bool + """ + + credentials = (client_id, client_secret) + + protocol = protocol or MSGraphProtocol() + + con = Connection(credentials, scopes=protocol.get_scopes_for(scopes), + **kwargs) + + consent_url, _ = con.get_authorization_url(**kwargs) + + print('Visit the following url to give consent:') + print(consent_url) + + token_url = input('Paste the authenticated url here:\n') + + if token_url: + result = con.request_token(token_url, **kwargs) # no need to pass state as the session is the same + if result: + print('Authentication Flow Completed. Oauth Access Token Stored. ' + 'You can now use the API.') + else: + print('Something go wrong. Please try again.') + + return bool(result) + else: + print('Authentication Flow aborted.') + return False
+
+ +
+ +
+ + +
+
+ +
+ +
+ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/O365/directory.html b/docs/latest/_modules/O365/directory.html new file mode 100644 index 00000000..d7684f7b --- /dev/null +++ b/docs/latest/_modules/O365/directory.html @@ -0,0 +1,540 @@ + + + + + + + + O365.directory — O365 documentation + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+ +
+
+
+
+ +

Source code for O365.directory

+import logging
+
+from dateutil.parser import parse
+from requests.exceptions import HTTPError
+
+from .message import Message, RecipientType
+from .utils import ME_RESOURCE, NEXT_LINK_KEYWORD, ApiComponent, Pagination
+
+USERS_RESOURCE = 'users'
+
+log = logging.getLogger(__name__)
+
+
+
+[docs] +class User(ApiComponent): + + _endpoints = { + 'photo': '/photo/$value', + 'photo_size': '/photos/{size}/$value' + } + + message_constructor = Message + +
+[docs] + def __init__(self, *, parent=None, con=None, **kwargs): + """ Represents an Azure AD user account + + :param parent: parent object + :type parent: Account + :param Connection con: connection to use if no parent specified + :param Protocol protocol: protocol to use if no parent specified + (kwargs) + :param str main_resource: use this resource instead of parent resource + (kwargs) + """ + + if parent and con: + raise ValueError('Need a parent or a connection but not both') + self.con = parent.con if parent else con + + # Choose the main_resource passed in kwargs over parent main_resource + main_resource = kwargs.pop('main_resource', None) or ( + getattr(parent, 'main_resource', None) if parent else None) + + cloud_data = kwargs.get(self._cloud_data_key, {}) + + self.object_id = cloud_data.get('id') + + if main_resource == USERS_RESOURCE: + main_resource += f'/{self.object_id}' + + super().__init__( + protocol=parent.protocol if parent else kwargs.get('protocol'), + main_resource=main_resource) + + local_tz = self.protocol.timezone + cc = self._cc + + self.type = cloud_data.get('@odata.type') + self.user_principal_name = cloud_data.get(cc('userPrincipalName')) + self.display_name = cloud_data.get(cc('displayName')) + self.given_name = cloud_data.get(cc('givenName'), '') + self.surname = cloud_data.get(cc('surname'), '') + self.mail = cloud_data.get(cc('mail')) # read only + self.business_phones = cloud_data.get(cc('businessPhones'), []) + self.job_title = cloud_data.get(cc('jobTitle')) + self.mobile_phone = cloud_data.get(cc('mobilePhone')) + self.office_location = cloud_data.get(cc('officeLocation')) + self.preferred_language = cloud_data.get(cc('preferredLanguage')) + # End of default properties. Next properties must be selected + + self.about_me = cloud_data.get(cc('aboutMe')) + self.account_enabled = cloud_data.get(cc('accountEnabled')) + self.age_group = cloud_data.get(cc('ageGroup')) + self.assigned_licenses = cloud_data.get(cc('assignedLicenses')) + self.assigned_plans = cloud_data.get(cc('assignedPlans')) # read only + birthday = cloud_data.get(cc('birthday')) + self.birthday = parse(birthday).astimezone(local_tz) if birthday else None + self.city = cloud_data.get(cc('city')) + self.company_name = cloud_data.get(cc('companyName')) + self.consent_provided_for_minor = cloud_data.get(cc('consentProvidedForMinor')) + self.country = cloud_data.get(cc('country')) + created = cloud_data.get(cc('createdDateTime')) + self.created = parse(created).astimezone( + local_tz) if created else None + self.department = cloud_data.get(cc('department')) + self.employee_id = cloud_data.get(cc('employeeId')) + self.fax_number = cloud_data.get(cc('faxNumber')) + hire_date = cloud_data.get(cc('hireDate')) + self.hire_date = parse(hire_date).astimezone( + local_tz) if hire_date else None + self.im_addresses = cloud_data.get(cc('imAddresses')) # read only + self.interests = cloud_data.get(cc('interests')) + self.is_resource_account = cloud_data.get(cc('isResourceAccount')) + last_password_change = cloud_data.get(cc('lastPasswordChangeDateTime')) + self.last_password_change = parse(last_password_change).astimezone( + local_tz) if last_password_change else None + self.legal_age_group_classification = cloud_data.get(cc('legalAgeGroupClassification')) + self.license_assignment_states = cloud_data.get(cc('licenseAssignmentStates')) # read only + self.mailbox_settings = cloud_data.get(cc('mailboxSettings')) + self.mail_nickname = cloud_data.get(cc('mailNickname')) + self.my_site = cloud_data.get(cc('mySite')) + self.other_mails = cloud_data.get(cc('otherMails')) + self.password_policies = cloud_data.get(cc('passwordPolicies')) + self.password_profile = cloud_data.get(cc('passwordProfile')) + self.past_projects = cloud_data.get(cc('pastProjects')) + self.postal_code = cloud_data.get(cc('postalCode')) + self.preferred_data_location = cloud_data.get(cc('preferredDataLocation')) + self.preferred_name = cloud_data.get(cc('preferredName')) + self.provisioned_plans = cloud_data.get(cc('provisionedPlans')) # read only + self.proxy_addresses = cloud_data.get(cc('proxyAddresses')) # read only + self.responsibilities = cloud_data.get(cc('responsibilities')) + self.schools = cloud_data.get(cc('schools')) + self.show_in_address_list = cloud_data.get(cc('showInAddressList'), True) + self.skills = cloud_data.get(cc('skills')) + sign_in_sessions_valid_from = cloud_data.get(cc('signInSessionsValidFromDateTime')) # read only + self.sign_in_sessions_valid_from = parse(sign_in_sessions_valid_from).astimezone( + local_tz) if sign_in_sessions_valid_from else None + self.state = cloud_data.get(cc('state')) + self.street_address = cloud_data.get(cc('streetAddress')) + self.usage_location = cloud_data.get(cc('usageLocation')) + self.user_type = cloud_data.get(cc('userType')) + self.on_premises_sam_account_name = cloud_data.get(cc('onPremisesSamAccountName'))
+ + + def __str__(self): + return self.__repr__() + + def __repr__(self): + return self.display_name or self.full_name or self.user_principal_name or 'Unknown Name' + + def __eq__(self, other): + return self.object_id == other.object_id + + def __hash__(self): + return self.object_id.__hash__() + + @property + def full_name(self): + """ Full Name (Name + Surname) + :rtype: str + """ + return f'{self.given_name} {self.surname}'.strip() + +
+[docs] + def new_message(self, recipient=None, *, recipient_type=RecipientType.TO): + """ This method returns a new draft Message instance with this + user email as a recipient + + :param Recipient recipient: a Recipient instance where to send this + message. If None the email of this contact will be used + :param RecipientType recipient_type: section to add recipient into + :return: newly created message + :rtype: Message or None + """ + + if isinstance(recipient_type, str): + recipient_type = RecipientType(recipient_type) + + recipient = recipient or self.mail + if not recipient: + return None + + new_message = self.message_constructor(parent=self, is_draft=True) + + target_recipients = getattr(new_message, str(recipient_type.value)) + target_recipients.add(recipient) + + return new_message
+ + +
+[docs] + def get_profile_photo(self, size=None): + """Returns the user profile photo + + :param str size: 48x48, 64x64, 96x96, 120x120, 240x240, + 360x360, 432x432, 504x504, and 648x648 + """ + if size is None: + url = self.build_url(self._endpoints.get('photo')) + else: + url = self.build_url(self._endpoints.get('photo_size').format(size=size)) + + try: + response = self.con.get(url) + except HTTPError as e: + log.debug(f'Error while retrieving the user profile photo. Error: {e}') + return None + + if not response: + return None + + return response.content
+ + +
+[docs] + def update_profile_photo(self, photo): + """ Updates this user profile photo + :param bytes photo: the photo data in bytes + """ + + url = self.build_url(self._endpoints.get('photo')) + response = self.con.patch(url, data=photo, headers={'Content-type': 'image/jpeg'}) + + return bool(response)
+
+ + + +
+[docs] +class Directory(ApiComponent): + + _endpoints = { + 'get_user': '/{email}' + } + user_constructor = User + +
+[docs] + def __init__(self, *, parent=None, con=None, **kwargs): + """ Represents the Active Directory + + :param parent: parent object + :type parent: Account + :param Connection con: connection to use if no parent specified + :param Protocol protocol: protocol to use if no parent specified + (kwargs) + :param str main_resource: use this resource instead of parent resource + (kwargs) + """ + + if parent and con: + raise ValueError('Need a parent or a connection but not both') + self.con = parent.con if parent else con + + # Choose the main_resource passed in kwargs over parent main_resource + main_resource = kwargs.pop('main_resource', None) or ( + getattr(parent, 'main_resource', None) if parent else None) + + super().__init__( + protocol=parent.protocol if parent else kwargs.get('protocol'), + main_resource=main_resource)
+ + + def __repr__(self): + return 'Active Directory' + +
+[docs] + def get_users(self, limit=100, *, query=None, order_by=None, batch=None): + """ Gets a list of users from the active directory + + When querying the Active Directory the Users endpoint will be used. + Only a limited set of information will be available unless you have + access to scope 'User.Read.All' which requires App Administration + Consent. + + Also using endpoints has some limitations on the querying capabilities. + + To use query an order_by check the OData specification here: + http://docs.oasis-open.org/odata/odata/v4.0/errata03/os/complete/ + part2-url-conventions/odata-v4.0-errata03-os-part2-url-conventions + -complete.html + + :param limit: max no. of contacts to get. Over 999 uses batch. + :type limit: int or None + :param query: applies a OData filter to the request + :type query: Query or str + :param order_by: orders the result set based on this condition + :type order_by: Query or str + :param int batch: batch size, retrieves items in + batches allowing to retrieve more items than the limit. + :return: list of users + :rtype: list[User] or Pagination + """ + + url = self.build_url('') # target the main_resource + + if limit is None or limit > self.protocol.max_top_value: + batch = self.protocol.max_top_value + + params = {'$top': batch if batch else limit} + + if order_by: + params['$orderby'] = order_by + + if query: + if isinstance(query, str): + params['$filter'] = query + else: + params.update(query.as_params()) + + response = self.con.get(url, params=params) + if not response: + return iter(()) + + data = response.json() + + # Everything received from cloud must be passed as self._cloud_data_key + users = (self.user_constructor(parent=self, **{self._cloud_data_key: user}) + for user in data.get('value', [])) + + next_link = data.get(NEXT_LINK_KEYWORD, None) + + if batch and next_link: + return Pagination(parent=self, data=users, + constructor=self.user_constructor, + next_link=next_link, limit=limit) + else: + return users
+ + + def _get_user(self, url, query=None): + """Helper method so DRY""" + + params = {} + if query: + if isinstance(query, str): + params['$filter'] = query + else: + params.update(query.as_params()) + + response = self.con.get(url, params=params) + if not response: + return None + + data = response.json() + + # Everything received from cloud must be passed as self._cloud_data_key + return self.user_constructor(parent=self, **{self._cloud_data_key: data}) + +
+[docs] + def get_user(self, user, query=None): + """ Returns a User by it's id or user principal name + + :param str user: the user id or user principal name + :return: User for specified email + :rtype: User + """ + url = self.build_url(self._endpoints.get('get_user').format(email=user)) + return self._get_user(url, query=query)
+ + +
+[docs] + def get_current_user(self, query=None): + """ Returns the current logged-in user""" + + if self.main_resource != ME_RESOURCE: + raise ValueError(f"Can't get the current user. The main resource must be set to '{ME_RESOURCE}'") + + url = self.build_url('') # target main_resource + return self._get_user(url, query=query)
+ + +
+[docs] + def get_user_manager(self, user, query=None): + """ Returns a Users' manager by the users id, or user principal name + + :param str user: the user id or user principal name + :return: User for specified email + :rtype: User + """ + url = self.build_url(self._endpoints.get('get_user').format(email=user)) + return self._get_user(url + '/manager', query=query)
+ + +
+[docs] + def get_user_direct_reports(self, user, limit=100, *, query=None, order_by=None, batch=None): + """ Gets a list of direct reports for the user provided from the active directory + + When querying the Active Directory the Users endpoint will be used. + + Also using endpoints has some limitations on the querying capabilities. + + To use query an order_by check the OData specification here: + http://docs.oasis-open.org/odata/odata/v4.0/errata03/os/complete/ + part2-url-conventions/odata-v4.0-errata03-os-part2-url-conventions + -complete.html + + :param limit: max no. of contacts to get. Over 999 uses batch. + :type limit: int or None + :param query: applies a OData filter to the request + :type query: Query or str + :param order_by: orders the result set based on this condition + :type order_by: Query or str + :param int batch: batch size, retrieves items in + batches allowing to retrieve more items than the limit. + :return: list of users + :rtype: list[User] or Pagination + """ + + url = self.build_url(self._endpoints.get('get_user').format(email=user)) + + if limit is None or limit > self.protocol.max_top_value: + batch = self.protocol.max_top_value + + params = {'$top': batch if batch else limit} + + if order_by: + params['$orderby'] = order_by + + if query: + if isinstance(query, str): + params['$filter'] = query + else: + params.update(query.as_params()) + + response = self.con.get(url + '/directReports', params=params) + if not response: + return iter(()) + + data = response.json() + + # Everything received from cloud must be passed as self._cloud_data_key + direct_reports = (self.user_constructor(parent=self, **{self._cloud_data_key: user}) + for user in data.get('value', [])) + + next_link = data.get(NEXT_LINK_KEYWORD, None) + + if batch and next_link: + return Pagination(parent=self, data=direct_reports, + constructor=self.user_constructor, + next_link=next_link, limit=limit) + else: + return direct_reports
+
+ +
+ +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/docs/latest/_modules/O365/drive.html b/docs/latest/_modules/O365/drive.html new file mode 100644 index 00000000..8593e5e6 --- /dev/null +++ b/docs/latest/_modules/O365/drive.html @@ -0,0 +1,2076 @@ + + + + + + + + + + + O365.drive — O365 documentation + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + +
+ +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+
+
+
+ +

Source code for O365.drive

+import logging
+import warnings
+from pathlib import Path
+from time import sleep
+from urllib.parse import urlparse, quote
+
+from dateutil.parser import parse
+
+from .address_book import Contact
+from .utils import ApiComponent, Pagination, NEXT_LINK_KEYWORD, \
+    OneDriveWellKnowFolderNames
+
+log = logging.getLogger(__name__)
+
+SIZE_THERSHOLD = 1024 * 1024 * 2  # 2 MB
+UPLOAD_SIZE_LIMIT_SIMPLE = 1024 * 1024 * 4  # 4 MB
+UPLOAD_SIZE_LIMIT_SESSION = 1024 * 1024 * 60  # 60 MB
+CHUNK_SIZE_BASE = 1024 * 320  # 320 Kb
+
+# 5 MB --> Must be a multiple of CHUNK_SIZE_BASE
+DEFAULT_UPLOAD_CHUNK_SIZE = 1024 * 1024 * 5
+ALLOWED_PDF_EXTENSIONS = {'.csv', '.doc', '.docx', '.odp', '.ods', '.odt',
+                          '.pot', '.potm', '.potx',
+                          '.pps', '.ppsx', '.ppsxm', '.ppt', '.pptm', '.pptx',
+                          '.rtf', '.xls', '.xlsx'}
+
+
+
[docs]class DownloadableMixin: + +
[docs] def download(self, to_path=None, name=None, chunk_size='auto', + convert_to_pdf=False, output=None): + """ Downloads this file to the local drive. Can download the + file in chunks with multiple requests to the server. + + :param to_path: a path to store the downloaded file + :type to_path: str or Path + :param str name: the name you want the stored file to have. + :param int chunk_size: number of bytes to retrieve from + each api call to the server. if auto, files bigger than + SIZE_THERSHOLD will be chunked (into memory, will be + however only 1 request) + :param bool convert_to_pdf: will try to download the converted pdf + if file extension in ALLOWED_PDF_EXTENSIONS + :param RawIOBase output: (optional) an opened io object to write to. + if set, the to_path and name will be ignored + :return: Success / Failure + :rtype: bool + """ + # TODO: Add download with more than one request (chunk_requests) with + # header 'Range'. For example: 'Range': 'bytes=0-1024' + + if not output: + if to_path is None: + to_path = Path() + else: + if not isinstance(to_path, Path): + to_path = Path(to_path) + + if not to_path.exists(): + raise FileNotFoundError('{} does not exist'.format(to_path)) + + if name and not Path(name).suffix and self.name: + name = name + Path(self.name).suffix + + name = name or self.name + to_path = to_path / name + + url = self.build_url( + self._endpoints.get('download').format(id=self.object_id)) + + try: + if chunk_size is None: + stream = False + elif chunk_size == 'auto': + if self.size and self.size > SIZE_THERSHOLD: + stream = True + else: + stream = False + chunk_size = None + elif isinstance(chunk_size, int): + stream = True + else: + raise ValueError("Argument chunk_size must be either 'auto' " + "or any integer number representing bytes") + + params = {} + if convert_to_pdf and Path(name).suffix in ALLOWED_PDF_EXTENSIONS: + params['format'] = 'pdf' + + with self.con.get(url, stream=stream, params=params) as response: + if not response: + log.debug('Downloading driveitem Request failed: {}'.format( + response.reason)) + return False + + def write_output(out): + if stream: + for chunk in response.iter_content( + chunk_size=chunk_size): + if chunk: + out.write(chunk) + else: + out.write(response.content) + + if output: + write_output(output) + else: + with to_path.open(mode='wb') as output: + write_output(output) + + except Exception as e: + log.error( + 'Error downloading driveitem {}. Error: {}'.format(self.name, + str(e))) + return False + + return True
+ + +
[docs]class CopyOperation(ApiComponent): + """ https://github.com/OneDrive/onedrive-api-docs/issues/762 """ + + _endpoints = { + # all prefixed with /drives/{drive_id} on main_resource by default + 'item': '/items/{id}', + } + +
[docs] def __init__(self, *, parent=None, con=None, **kwargs): + """ + + :param parent: parent for this operation + :type parent: Drive + :param Connection con: connection to use if no parent specified + :param Protocol protocol: protocol to use if no parent specified + (kwargs) + :param str main_resource: use this resource instead of parent resource + (kwargs) + :param str monitor_url: + :param str item_id: + """ + if parent and con: + raise ValueError('Need a parent or a connection but not both') + self.con = parent.con if parent else con + self.parent = parent # parent will be always a DriveItem + + # Choose the main_resource passed in kwargs over parent main_resource + main_resource = kwargs.pop('main_resource', None) or ( + getattr(parent, 'main_resource', None) if parent else None) + + super().__init__( + protocol=parent.protocol if parent else kwargs.get('protocol'), + main_resource=main_resource) + + self.monitor_url = kwargs.get('monitor_url', None) + self.item_id = kwargs.get('item_id', None) + if self.monitor_url is None and self.item_id is None: + raise ValueError('Must provide a valid monitor_url or item_id') + if self.monitor_url is not None and self.item_id is not None: + raise ValueError( + 'Must provide a valid monitor_url or item_id, but not both') + + if self.item_id: + self.status = 'completed' + self.completion_percentage = 100.0 + else: + self.status = 'inProgress' + self.completion_percentage = 0.0
+ + def _request_status(self): + """ Checks the api endpoint to check if the async job progress """ + if self.item_id: + return True + + response = self.con.get(self.monitor_url) + if not response: + return False + + data = response.json() + + self.status = data.get('status', 'inProgress') + self.completion_percentage = data.get(self._cc('percentageComplete'), + 0) + self.item_id = data.get(self._cc('resourceId'), None) + + return self.item_id is not None + +
[docs] def check_status(self, delay=0): + """ Checks the api endpoint in a loop + + :param delay: number of seconds to wait between api calls. + Note Connection 'requests_delay' also apply. + :return: tuple of status and percentage complete + :rtype: tuple(str, float) + """ + if not self.item_id: + while not self._request_status(): + # wait until _request_status returns True + yield self.status, self.completion_percentage + if self.item_id is None: + sleep(delay) + else: + yield self.status, self.completion_percentage
+ +
[docs] def get_item(self): + """ Returns the item copied + + :return: Copied Item + :rtype: DriveItem + """ + return self.parent.get_item( + self.item_id) if self.item_id is not None else None
+ + +
[docs]class DriveItemVersion(ApiComponent, DownloadableMixin): + """ A version of a DriveItem """ + + _endpoints = { + 'download': '/versions/{id}/content', + 'restore': '/versions/{id}/restoreVersion' + } + +
[docs] def __init__(self, *, parent=None, con=None, **kwargs): + """ Version of DriveItem + + :param parent: parent for this operation + :type parent: DriveItem + :param Connection con: connection to use if no parent specified + :param Protocol protocol: protocol to use if no parent specified + (kwargs) + :param str main_resource: use this resource instead of parent resource + (kwargs) + """ + if parent and con: + raise ValueError('Need a parent or a connection but not both') + self.con = parent.con if parent else con + self._parent = parent if isinstance(parent, DriveItem) else None + + protocol = parent.protocol if parent else kwargs.get('protocol') + # Choose the main_resource passed in kwargs over parent main_resource + main_resource = kwargs.pop('main_resource', None) or ( + getattr(parent, 'main_resource', None) if parent else None) + + resource_prefix = '/items/{item_id}'.format( + item_id=self._parent.object_id) + main_resource = '{}{}'.format( + main_resource or (protocol.default_resource if protocol else ''), + resource_prefix) + super().__init__(protocol=protocol, main_resource=main_resource) + + cloud_data = kwargs.get(self._cloud_data_key, {}) + + self.driveitem_id = self._parent.object_id + self.object_id = cloud_data.get('id', '1.0') + self.name = self.object_id + modified = cloud_data.get(self._cc('lastModifiedDateTime'), None) + local_tz = self.protocol.timezone + self.modified = parse(modified).astimezone( + local_tz) if modified else None + self.size = cloud_data.get('size', 0) + modified_by = cloud_data.get(self._cc('lastModifiedBy'), {}).get('user', + None) + self.modified_by = Contact(con=self.con, protocol=self.protocol, **{ + self._cloud_data_key: modified_by}) if modified_by else None
+ + def __str__(self): + return self.__repr__() + + def __repr__(self): + return ('Version Id: {} | Modified on: {} | by: {}' + ''.format(self.name, + self.modified, + self.modified_by.display_name + if self.modified_by else None)) + +
[docs] def restore(self): + """ Restores this DriveItem Version. + You can not restore the current version (last one). + + :return: Success / Failure + :rtype: bool + """ + url = self.build_url( + self._endpoints.get('restore').format(id=self.object_id)) + + response = self.con.post(url) + + return bool(response)
+ +
[docs] def download(self, to_path=None, name=None, chunk_size='auto', + convert_to_pdf=False): + """ Downloads this version. + You can not download the current version (last one). + + :return: Success / Failure + :rtype: bool + """ + return super().download(to_path=to_path, name=name, + chunk_size=chunk_size, + convert_to_pdf=convert_to_pdf)
+ + +
[docs]class DriveItemPermission(ApiComponent): + """ A Permission representation for a DriveItem """ + _endpoints = { + 'permission': '/items/{driveitem_id}/permissions/{id}' + } + +
[docs] def __init__(self, *, parent=None, con=None, **kwargs): + """ Permissions for DriveItem + + :param parent: parent for this operation + :type parent: DriveItem + :param Connection con: connection to use if no parent specified + :param Protocol protocol: protocol to use if no parent specified + (kwargs) + :param str main_resource: use this resource instead of parent resource + (kwargs) + """ + if parent and con: + raise ValueError('Need a parent or a connection but not both') + self.con = parent.con if parent else con + self._parent = parent if isinstance(parent, DriveItem) else None + # Choose the main_resource passed in kwargs over parent main_resource + main_resource = kwargs.pop('main_resource', None) or ( + getattr(parent, 'main_resource', None) if parent else None) + + protocol = parent.protocol if parent else kwargs.get('protocol') + super().__init__(protocol=protocol, main_resource=main_resource) + + self.driveitem_id = self._parent.object_id + cloud_data = kwargs.get(self._cloud_data_key, {}) + self.object_id = cloud_data.get(self._cc('id')) + self.inherited_from = cloud_data.get(self._cc('inheritedFrom'), None) + + link = cloud_data.get(self._cc('link'), None) + self.permission_type = 'owner' + if link: + self.permission_type = 'link' + self.share_type = link.get('type', 'view') + self.share_scope = link.get('scope', 'anonymous') + self.share_link = link.get('webUrl', None) + + invitation = cloud_data.get(self._cc('invitation'), None) + if invitation: + self.permission_type = 'invitation' + self.share_email = invitation.get('email', '') + invited_by = invitation.get('invitedBy', {}) + self.invited_by = invited_by.get('user', {}).get( + self._cc('displayName'), None) or invited_by.get('application', + {}).get( + self._cc('displayName'), None) + self.require_sign_in = invitation.get(self._cc('signInRequired'), + True) + + self.roles = cloud_data.get(self._cc('roles'), []) + granted_to = cloud_data.get(self._cc('grantedTo'), {}) + self.granted_to = granted_to.get('user', {}).get( + self._cc('displayName')) or granted_to.get('application', {}).get( + self._cc('displayName')) + self.share_id = cloud_data.get(self._cc('shareId'), None)
+ + def __str__(self): + return self.__repr__() + + def __repr__(self): + return 'Permission for {} of type: {}'.format(self._parent.name, + self.permission_type) + +
[docs] def update_roles(self, roles='view'): + """ Updates the roles of this permission + + :return: Success / Failure + :rtype: bool + """ + if not self.object_id: + return False + + url = self.build_url(self._endpoints.get('permission').format( + driveitem_id=self.driveitem_id, id=self.object_id)) + + if roles in {'view', 'read'}: + data = {'roles': ['read']} + elif roles in {'edit', 'write'}: + data = {'roles': ['write']} + else: + raise ValueError('"{}" is not a valid share_type'.format(roles)) + + response = self.con.patch(url, data=data) + if not response: + return False + + self.roles = data.get('roles', []) + return True
+ +
[docs] def delete(self): + """ Deletes this permission. Only permissions that are not + inherited can be deleted. + + :return: Success / Failure + :rtype: bool + """ + if not self.object_id: + return False + + url = self.build_url(self._endpoints.get('permission').format( + driveitem_id=self.driveitem_id, id=self.object_id)) + + response = self.con.delete(url) + if not response: + return False + + self.object_id = None + return True
+ + +
[docs]class DriveItem(ApiComponent): + """ A DriveItem representation. Groups all functionality """ + + _endpoints = { + # all prefixed with /drives/{drive_id} on main_resource by default + 'list_items': '/items/{id}/children', + 'thumbnails': '/items/{id}/thumbnails', + 'item': '/items/{id}', + 'copy': '/items/{id}/copy', + 'download': '/items/{id}/content', + 'search': "/items/{id}/search(q='{search_text}')", + 'versions': '/items/{id}/versions', + 'version': '/items/{id}/versions/{version_id}', + 'simple_upload': '/items/{id}:/{filename}:/content', + 'create_upload_session': '/items/{id}:/{filename}:/createUploadSession', + 'share_link': '/items/{id}/createLink', + 'share_invite': '/items/{id}/invite', + 'permissions': '/items/{id}/permissions', + } + +
[docs] def __init__(self, *, parent=None, con=None, **kwargs): + """ Create a DriveItem + + :param parent: parent for this operation + :type parent: Drive or drive.Folder + :param Connection con: connection to use if no parent specified + :param Protocol protocol: protocol to use if no parent specified + (kwargs) + :param str main_resource: use this resource instead of parent resource + (kwargs) + """ + if parent and con: + raise ValueError('Need a parent or a connection but not both') + self.con = parent.con if parent else con + self._parent = parent if isinstance(parent, DriveItem) else None + + # Choose the main_resource passed in kwargs over parent main_resource + main_resource = kwargs.pop('main_resource', None) or ( + getattr(parent, 'main_resource', None) if parent else None) + + protocol = parent.protocol if parent else kwargs.get('protocol') + if parent and not isinstance(parent, DriveItem): + # parent is a Drive so append the drive route to the main_resource + drive_id = (None if parent.object_id == 'root' + else parent.object_id) or None + + # prefix with the current known drive or the default one + resource_prefix = '/drives/{drive_id}'.format( + drive_id=drive_id) if drive_id else '/drive' + main_resource = '{}{}'.format(main_resource or ( + protocol.default_resource if protocol else ''), resource_prefix) + + super().__init__(protocol=protocol, main_resource=main_resource) + + cloud_data = kwargs.get(self._cloud_data_key, {}) + + self.object_id = cloud_data.get(self._cc('id')) + + parent_reference = cloud_data.get(self._cc('parentReference'), {}) + self.parent_id = parent_reference.get('id', None) + self.drive_id = parent_reference.get(self._cc('driveId'), None) + self.parent_path = parent_reference.get(self._cc("path"), None) + + remote_item = cloud_data.get(self._cc('remoteItem'), None) + if remote_item is not None: + self.drive = None # drive is unknown? + self.remote_item = self._classifier(remote_item)(parent=self, **{ + self._cloud_data_key: remote_item}) + self.parent_id = self.remote_item.parent_id + self.drive_id = self.remote_item.drive_id + self.set_base_url('drives/{}'.format(self.drive_id)) # changes main_resource and _base_url + else: + self.drive = parent if isinstance(parent, Drive) else ( + parent.drive if isinstance(parent.drive, Drive) else kwargs.get( + 'drive', None)) + self.remote_item = None + + self.name = cloud_data.get(self._cc('name'), '') + self.web_url = cloud_data.get(self._cc('webUrl')) + created_by = cloud_data.get(self._cc('createdBy'), {}).get('user', None) + self.created_by = Contact(con=self.con, protocol=self.protocol, **{ + self._cloud_data_key: created_by}) if created_by else None + modified_by = cloud_data.get(self._cc('lastModifiedBy'), {}).get('user', + None) + self.modified_by = Contact(con=self.con, protocol=self.protocol, **{ + self._cloud_data_key: modified_by}) if modified_by else None + + created = cloud_data.get(self._cc('createdDateTime'), None) + modified = cloud_data.get(self._cc('lastModifiedDateTime'), None) + local_tz = self.protocol.timezone + self.created = parse(created).astimezone(local_tz) if created else None + self.modified = parse(modified).astimezone( + local_tz) if modified else None + + self.description = cloud_data.get(self._cc('description'), '') + self.size = cloud_data.get(self._cc('size'), 0) + self.shared = cloud_data.get(self._cc('shared'), {}).get('scope', None) + + # Thumbnails + self.thumbnails = cloud_data.get(self._cc('thumbnails'), [])
+ + def __str__(self): + return self.__repr__() + + def __repr__(self): + return '{}: {}'.format(self.__class__.__name__, self.name) + + def __eq__(self, other): + obj_id = getattr(other, 'object_id', None) + if obj_id is not None: + return self.object_id == obj_id + return False + + @staticmethod + def _classifier(item): + """ Subclass to change factory classes """ + if 'folder' in item: + return Folder + elif 'image' in item: + return Image + elif 'photo' in item: + return Photo + else: + return File + + @property + def is_folder(self): + """ Returns if this DriveItem is a Folder """ + return isinstance(self, Folder) + + @property + def is_file(self): + """ Returns if this DriveItem is a File """ + return isinstance(self, File) + + @property + def is_image(self): + """ Returns if this DriveItem is a Image """ + return isinstance(self, Image) + + @property + def is_photo(self): + """ Returns if this DriveItem is a Photo """ + return isinstance(self, Photo) + +
[docs] def get_parent(self): + """ the parent of this DriveItem + + :return: Parent of this item + :rtype: Drive or drive.Folder + """ + if self._parent and self._parent.object_id == self.parent_id: + return self._parent + else: + if self.parent_id: + return self.drive.get_item(self.parent_id) + else: + # return the drive + return self.drive
+ +
[docs] def get_drive(self): + """ + Returns this item drive + :return: Drive of this item + :rtype: Drive or None + """ + if not self.drive_id: + return None + + url = self.build_url('') + response = self.con.get(url) + if not response: + return None + + drive = response.json() + + return Drive(parent=self, main_resource='', **{self._cloud_data_key: drive})
+ +
[docs] def get_thumbnails(self, size=None): + """ Returns this Item Thumbnails. Thumbnails are not supported on + SharePoint Server 2016. + + :param size: request only the specified size: ej: "small", + Custom 300x400 px: "c300x400", Crop: "c300x400_Crop" + :return: Thumbnail Data + :rtype: dict + """ + if not self.object_id: + return [] + + url = self.build_url( + self._endpoints.get('thumbnails').format(id=self.object_id)) + + params = {} + if size is not None: + params['select'] = size + + response = self.con.get(url, params=params) + if not response: + return [] + + data = response.json() + + if not self.thumbnails or size is None: + self.thumbnails = data + + return data
+ +
[docs] def update(self, **kwargs): + """ Updates this item + + :param kwargs: all the properties to be updated. + only name and description are allowed at the moment. + :return: Success / Failure + :rtype: bool + """ + if not self.object_id: + return False + + url = self.build_url( + self._endpoints.get('item').format(id=self.object_id)) + + data = {self._cc(key): value for key, value in kwargs.items() if + key in {'name', + 'description'}} # convert keys to protocol casing + if not data: + return False + + response = self.con.patch(url, data=data) + if not response: + return False + + new_data = response.json() + + for key in data: + value = new_data.get(key, None) + if value: + setattr(self, self.protocol.to_api_case(key), value) + + return True
+ +
[docs] def delete(self): + """ Moves this item to the Recycle Bin + + :return: Success / Failure + :rtype: bool + """ + + if not self.object_id: + return False + + url = self.build_url( + self._endpoints.get('item').format(id=self.object_id)) + + response = self.con.delete(url) + if not response: + return False + + self.object_id = None + + return True
+ +
[docs] def move(self, target): + """ Moves this DriveItem to another Folder. + Can't move between different Drives. + + :param target: a Folder, Drive item or Item Id string. + If it's a drive the item will be moved to the root folder. + :type target: drive.Folder or DriveItem or str + :return: Success / Failure + :rtype: bool + """ + + if isinstance(target, Folder): + target_id = target.object_id + elif isinstance(target, Drive): + # we need the root folder id + root_folder = target.get_root_folder() + if not root_folder: + return False + target_id = root_folder.object_id + elif isinstance(target, str): + target_id = target + else: + raise ValueError('Target must be a Folder or Drive') + + if not self.object_id or not target_id: + raise ValueError( + 'Both self, and target must have a valid object_id.') + + if target_id == 'root': + raise ValueError("When moving, target id can't be 'root'") + + url = self.build_url( + self._endpoints.get('item').format(id=self.object_id)) + + data = {'parentReference': {'id': target_id}} + + response = self.con.patch(url, data=data) + if not response: + return False + + self.parent_id = target_id + + return True
+ +
[docs] def copy(self, target=None, name=None): + """ Asynchronously creates a copy of this DriveItem and all it's + child elements. + + :param target: target location to move to. + If it's a drive the item will be moved to the root folder. + :type target: drive.Folder or Drive + :param name: a new name for the copy. + :rtype: CopyOperation + """ + if target is None and name is None: + raise ValueError('Must provide a target or a name (or both)') + + if isinstance(target, Folder): + target_id = target.object_id + drive_id = target.drive_id + elif isinstance(target, Drive): + # we need the root folder + root_folder = target.get_root_folder() + if not root_folder: + return None + target_id = root_folder.object_id + drive_id = root_folder.drive_id + elif target is None: + target_id = None + drive_id = None + else: + raise ValueError('Target, if provided, must be a Folder or Drive') + + if not self.object_id: + return None + + if target_id == 'root': + raise ValueError("When copying, target id can't be 'root'") + + url = self.build_url( + self._endpoints.get('copy').format(id=self.object_id)) + + if target_id and drive_id: + data = {'parentReference': {'id': target_id, 'driveId': drive_id}} + else: + data = {} + if name: + # incorporate the extension if the name provided has none. + if not Path(name).suffix and self.name: + name = name + Path(self.name).suffix + data['name'] = name + + response = self.con.post(url, data=data) + if not response: + return None + + # Find out if the server has run a Sync or Async operation + location = response.headers.get('Location', None) + + if 'monitor' in location: + # Async operation + return CopyOperation(parent=self.drive, monitor_url=location) + else: + # Sync operation. Item is ready to be retrieved + path = urlparse(location).path + item_id = path.split('/')[-1] + return CopyOperation(parent=self.drive, item_id=item_id)
+ +
[docs] def get_versions(self): + """ Returns a list of available versions for this item + + :return: list of versions + :rtype: list[DriveItemVersion] + """ + + if not self.object_id: + return [] + url = self.build_url( + self._endpoints.get('versions').format(id=self.object_id)) + + response = self.con.get(url) + if not response: + return [] + + data = response.json() + + # Everything received from cloud must be passed as self._cloud_data_key + return [DriveItemVersion(parent=self, **{self._cloud_data_key: item}) + for item in data.get('value', [])]
+ +
[docs] def get_version(self, version_id): + """ Returns a version for specified id + + :return: a version object of specified id + :rtype: DriveItemVersion + """ + if not self.object_id: + return None + + url = self.build_url( + self._endpoints.get('version').format(id=self.object_id, + version_id=version_id)) + + response = self.con.get(url) + if not response: + return None + + data = response.json() + + # Everything received from cloud must be passed as self._cloud_data_key + return DriveItemVersion(parent=self, **{self._cloud_data_key: data})
+ + + +
[docs] def share_with_invite(self, recipients, require_sign_in=True, + send_email=True, message=None, share_type='view'): + """ Sends an invitation to access or edit this DriveItem + + :param recipients: a string or Contact or a list of the former + representing recipients of this invitation + :type recipients: list[str] or list[Contact] or str or Contact + :param bool require_sign_in: if True the recipients + invited will need to log in to view the contents + :param bool send_email: if True an email will be send to the recipients + :param str message: the body text of the message emailed + :param str share_type: 'view': will allow to read the contents. + 'edit' will allow to modify the contents + :return: link to share + :rtype: DriveItemPermission + """ + if not self.object_id: + return None + + to = [] + if recipients is None: + raise ValueError('Provide a valid to parameter') + elif isinstance(recipients, (list, tuple)): + for x in recipients: + if isinstance(x, str): + to.append({'email': x}) + elif isinstance(x, Contact): + to.append({'email': x.main_email}) + else: + raise ValueError( + 'All the recipients must be either strings or Contacts') + elif isinstance(recipients, str): + to.append({'email': recipients}) + elif isinstance(recipients, Contact): + to.append({'email': recipients.main_email}) + else: + raise ValueError( + 'All the recipients must be either strings or Contacts') + + url = self.build_url( + self._endpoints.get('share_invite').format(id=self.object_id)) + + data = { + 'recipients': to, + self._cc('requireSignIn'): require_sign_in, + self._cc('sendInvitation'): send_email, + } + if share_type in {'view', 'read'}: + data['roles'] = ['read'] + elif share_type in {'edit', 'write'}: + data['roles'] = ['write'] + else: + raise ValueError( + '"{}" is not a valid share_type'.format(share_type)) + if send_email and message: + data['message'] = message + + response = self.con.post(url, data=data) + if not response: + return None + + data = response.json() + + return DriveItemPermission(parent=self, **{self._cloud_data_key: data})
+ +
[docs] def get_permissions(self): + """ Returns a list of DriveItemPermissions with the + permissions granted for this DriveItem. + + :return: List of Permissions + :rtype: list[DriveItemPermission] + """ + if not self.object_id: + return [] + + url = self.build_url( + self._endpoints.get('permissions').format(id=self.object_id)) + + response = self.con.get(url) + if not response: + return None + + data = response.json() + + # Everything received from cloud must be passed as self._cloud_data_key + return [DriveItemPermission(parent=self, **{self._cloud_data_key: item}) + for item in data.get('value', [])]
+ + +
[docs]class File(DriveItem, DownloadableMixin): + """ A File """ + +
[docs] def __init__(self, **kwargs): + super().__init__(**kwargs) + cloud_data = kwargs.get(self._cloud_data_key, {}) + + self.mime_type = cloud_data.get(self._cc('file'), {}).get( + self._cc('mimeType'), None)
+ + @property + def extension(self): + return Path(self.name).suffix
+ + +
[docs]class Image(File): + """ An Image """ + +
[docs] def __init__(self, **kwargs): + super().__init__(**kwargs) + cloud_data = kwargs.get(self._cloud_data_key, {}) + + image = cloud_data.get(self._cc('image'), {}) + self.height = image.get(self._cc('height'), 0) + self.width = image.get(self._cc('width'), 0)
+ + @property + def dimensions(self): + """ Dimension of the Image + + :return: width x height + :rtype: str + """ + return '{}x{}'.format(self.width, self.height)
+ + +
[docs]class Photo(Image): + """ Photo Object. Inherits from Image but has more attributes """ + +
[docs] def __init__(self, **kwargs): + super().__init__(**kwargs) + cloud_data = kwargs.get(self._cloud_data_key, {}) + + photo = cloud_data.get(self._cc('photo'), {}) + + taken = photo.get(self._cc('takenDateTime'), None) + local_tz = self.protocol.timezone + self.taken_datetime = parse(taken).astimezone( + local_tz) if taken else None + self.camera_make = photo.get(self._cc('cameraMake'), None) + self.camera_model = photo.get(self._cc('cameraModel'), None) + self.exposure_denominator = photo.get(self._cc('exposureDenominator'), + None) + self.exposure_numerator = photo.get(self._cc('exposureNumerator'), None) + self.fnumber = photo.get(self._cc('fNumber'), None) + self.focal_length = photo.get(self._cc('focalLength'), None) + self.iso = photo.get(self._cc('iso'), None)
+ + +
[docs]class Folder(DriveItem): + """ A Folder inside a Drive """ + +
[docs] def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + cloud_data = kwargs.get(self._cloud_data_key, {}) + + self.child_count = cloud_data.get(self._cc('folder'), {}).get( + self._cc('childCount'), 0) + self.special_folder = cloud_data.get(self._cc('specialFolder'), {}).get( + 'name', None)
+ +
[docs] def get_items(self, limit=None, *, query=None, order_by=None, batch=None): + """ Returns generator all the items inside this folder + + :param int limit: max no. of folders to get. Over 999 uses batch. + :param query: applies a OData filter to the request + :type query: Query or str + :param order_by: orders the result set based on this condition + :type order_by: Query or str + :param int batch: batch size, retrieves items in + batches allowing to retrieve more items than the limit. + :return: items in this folder + :rtype: generator of DriveItem or Pagination + """ + + url = self.build_url( + self._endpoints.get('list_items').format(id=self.object_id)) + + if limit is None or limit > self.protocol.max_top_value: + batch = self.protocol.max_top_value + + params = {'$top': batch if batch else limit} + + if order_by: + params['$orderby'] = order_by + + if query: + # if query.has_filters: + # warnings.warn('Filters are not allowed by the ' + # 'Api Provider in this method') + # query.clear_filters() + if isinstance(query, str): + params['$filter'] = query + else: + params.update(query.as_params()) + + response = self.con.get(url, params=params) + if not response: + return iter(()) + + data = response.json() + + # Everything received from cloud must be passed as self._cloud_data_key + items = ( + self._classifier(item)(parent=self, **{self._cloud_data_key: item}) + for item in data.get('value', [])) + next_link = data.get(NEXT_LINK_KEYWORD, None) + if batch and next_link: + return Pagination(parent=self, data=items, + constructor=self._classifier, + next_link=next_link, limit=limit) + else: + return items
+ +
[docs] def get_child_folders(self, limit=None, *, query=None, order_by=None, batch=None): + """ Returns all the folders inside this folder + + :param int limit: max no. of folders to get. Over 999 uses batch. + :param query: applies a OData filter to the request + :type query: Query or str + :param order_by: orders the result set based on this condition + :type order_by: Query or str + :param int batch: batch size, retrieves items in + batches allowing to retrieve more items than the limit. + :return: folder items in this folder + :rtype: generator of DriveItem or Pagination + """ + + if query: + query = query.on_attribute('folder').unequal(None) + else: + query = self.q('folder').unequal(None) + + return self.get_items(limit=limit, query=query, order_by=order_by, batch=batch)
+ +
[docs] def create_child_folder(self, name, description=None): + """ Creates a Child Folder + + :param str name: the name of the new child folder + :param str description: the description of the new child folder + :return: newly created folder + :rtype: drive.Folder + """ + + if not self.object_id: + return None + + url = self.build_url( + self._endpoints.get('list_items').format(id=self.object_id)) + + data = {'name': name, 'folder': {}} + if description: + data['description'] = description + + response = self.con.post(url, data=data) + if not response: + return None + + folder = response.json() + + return self._classifier(folder)(parent=self, + **{self._cloud_data_key: folder})
+ +
[docs] def download_contents(self, to_folder=None): + """ This will download each file and folder sequentially. + Caution when downloading big folder structures + + :param drive.Folder to_folder: folder where to store the contents + """ + if to_folder is None: + try: + to_folder = Path() / self.name + except Exception as e: + log.error('Could not create folder with name: {}. Error: {}'.format(self.name, e)) + to_folder = Path() # fallback to the same folder + + if not to_folder.exists(): + to_folder.mkdir() + + for item in self.get_items(query=self.new_query().select('id', 'size', 'folder', 'name')): + if item.is_folder and item.child_count > 0: + item.download_contents(to_folder=to_folder / item.name) + else: + item.download(to_folder)
+ +
[docs] def search(self, search_text, limit=None, *, query=None, order_by=None, + batch=None): + """ Search for DriveItems under this folder + The search API uses a search service under the covers, + which requires indexing of content. + + As a result, there will be some time between creation of an item + and when it will appear in search results. + + :param str search_text: The query text used to search for items. + Values may be matched across several fields including filename, + metadata, and file content. + :param int limit: max no. of folders to get. Over 999 uses batch. + :param query: applies a OData filter to the request + :type query: Query or str + :param order_by: orders the result set based on this condition + :type order_by: Query or str + :param int batch: batch size, retrieves items in + batches allowing to retrieve more items than the limit. + :return: items in this folder matching search + :rtype: generator of DriveItem or Pagination + """ + if not isinstance(search_text, str) or not search_text: + raise ValueError('Provide a valid search_text') + + url = self.build_url( + self._endpoints.get('search').format(id=self.object_id, + search_text=search_text)) + + if limit is None or limit > self.protocol.max_top_value: + batch = self.protocol.max_top_value + + params = {'$top': batch if batch else limit} + + if order_by: + params['$orderby'] = order_by + + if query: + if query.has_filters: + warnings.warn( + 'Filters are not allowed by the Api ' + 'Provider in this method') + query.clear_filters() + if isinstance(query, str): + params['$filter'] = query + else: + params.update(query.as_params()) + + response = self.con.get(url, params=params) + if not response: + return iter(()) + + data = response.json() + + # Everything received from cloud must be passed as self._cloud_data_key + items = ( + self._classifier(item)(parent=self, **{self._cloud_data_key: item}) + for item in data.get('value', [])) + next_link = data.get(NEXT_LINK_KEYWORD, None) + if batch and next_link: + return Pagination(parent=self, data=items, + constructor=self._classifier, + next_link=next_link, limit=limit) + else: + return items
+ +
[docs] def upload_file(self, item, item_name=None, chunk_size=DEFAULT_UPLOAD_CHUNK_SIZE, + upload_in_chunks=False, stream=None, stream_size=None, + conflict_handling=None): + """ Uploads a file + + :param item: path to the item you want to upload + :type item: str or Path + :param item_name: name of the item on the server. None to use original name + :type item_name: str or Path + :param chunk_size: Only applies if file is bigger than 4MB or upload_in_chunks is True. + Chunk size for uploads. Must be a multiple of 327.680 bytes + :param upload_in_chunks: force the method to upload the file in chunks + :param io.BufferedIOBase stream: (optional) an opened io object to read into. + if set, the to_path and name will be ignored + :param int stream_size: size of stream, required if using stream + :param conflict_handling: How to handle conflicts. + NOTE: works for chunk upload only (>4MB or upload_in_chunks is True) + None to use default (overwrite). Options: fail | replace | rename + :type conflict_handling: str + :return: uploaded file + :rtype: DriveItem + """ + + if not stream: + if item is None: + raise ValueError('Item must be a valid path to file') + item = Path(item) if not isinstance(item, Path) else item + + if not item.exists(): + raise ValueError('Item must exist') + if not item.is_file(): + raise ValueError('Item must be a file') + + file_size = (stream_size if stream_size is not None else item.stat().st_size) + + if not upload_in_chunks and file_size <= UPLOAD_SIZE_LIMIT_SIMPLE: + # Simple Upload + url = self.build_url( + self._endpoints.get('simple_upload').format(id=self.object_id, + filename=quote(item.name if item_name is None else item_name))) + # headers = {'Content-type': 'text/plain'} + headers = {'Content-type': 'application/octet-stream'} + # headers = None + if stream: + data = stream.read() + else: + with item.open(mode='rb') as file: + data = file.read() + + response = self.con.put(url, headers=headers, data=data) + if not response: + return None + + data = response.json() + + return self._classifier(data)(parent=self, + **{self._cloud_data_key: data}) + else: + # Resumable Upload + url = self.build_url( + self._endpoints.get('create_upload_session').format( + id=self.object_id, filename=quote(item.name if item_name is None else item_name))) + + # If not None, add conflict handling to request + file_data = {} + if conflict_handling: + file_data["item"] = {"@microsoft.graph.conflictBehavior": conflict_handling } + + response = self.con.post(url, data=file_data) + if not response: + return None + + data = response.json() + + upload_url = data.get(self._cc('uploadUrl'), None) + log.info('Resumable upload on url: {}'.format(upload_url)) + expiration_date = data.get(self._cc('expirationDateTime'), None) + if expiration_date: + log.info('Expiration Date for this upload url is: {}'.format(expiration_date)) + if upload_url is None: + log.error('Create upload session response without ' + 'upload_url for file {}'.format(item.name)) + return None + + def write_stream(file): + current_bytes = 0 + while True: + data = file.read(chunk_size) + if not data: + break + transfer_bytes = len(data) + headers = { + 'Content-type': 'application/octet-stream', + 'Content-Length': str(len(data)), + 'Content-Range': 'bytes {}-{}/{}' + ''.format(current_bytes, + current_bytes + + transfer_bytes - 1, + file_size) + } + current_bytes += transfer_bytes + + # this request mut NOT send the authorization header. + # so we use a naive simple request. + response = self.con.naive_request(upload_url, 'PUT', + data=data, + headers=headers) + if not response: + return None + + if response.status_code != 202: + # file is completed + data = response.json() + return self._classifier(data)(parent=self, **{ + self._cloud_data_key: data}) + if stream: + return write_stream(stream) + else: + with item.open(mode='rb') as file: + return write_stream(file)
+ + +
[docs]class Drive(ApiComponent): + """ A Drive representation. + A Drive is a Container of Folders and Files and act as a root item """ + + _endpoints = { + 'default_drive': '/drive', + 'get_drive': '/drives/{id}', + 'get_root_item_default': '/drive/root', + 'get_root_item': '/drives/{id}/root', + 'list_items_default': '/drive/root/children', + 'list_items': '/drives/{id}/root/children', + 'get_item_default': '/drive/items/{item_id}', + 'get_item': '/drives/{id}/items/{item_id}', + 'get_item_by_path_default': '/drive/root:{item_path}', + 'get_item_by_path': '/drives/{id}/root:{item_path}', + 'recent_default': '/drive/recent', + 'recent': '/drives/{id}/recent', + 'shared_with_me_default': '/drive/sharedWithMe', + 'shared_with_me': '/drives/{id}/sharedWithMe', + 'get_special_default': '/drive/special/{name}', + 'get_special': '/drives/{id}/special/{name}', + 'search_default': "/drive/search(q='{search_text}')", + 'search': "/drives/{id}/search(q='{search_text}')", + } + +
[docs] def __init__(self, *, parent=None, con=None, **kwargs): + """ Create a drive representation + + :param parent: parent for this operation + :type parent: Drive or Storage + :param Connection con: connection to use if no parent specified + :param Protocol protocol: protocol to use if no parent specified + (kwargs) + :param str main_resource: use this resource instead of parent resource + (kwargs) + """ + if parent and con: + raise ValueError('Need a parent or a connection but not both') + self.con = parent.con if parent else con + self.parent = parent if isinstance(parent, Drive) else None + + # Choose the main_resource passed in kwargs over parent main_resource + main_resource = kwargs.pop('main_resource', None) + if main_resource is None: + main_resource = getattr(parent, 'main_resource', None) if parent else None + super().__init__( + protocol=parent.protocol if parent else kwargs.get('protocol'), + main_resource=main_resource) + + self._update_data(kwargs)
+ + def _update_data(self, data): + cloud_data = data.get(self._cloud_data_key, {}) + + self.object_id = cloud_data.get(self._cc('id')) + # Fallback to manual drive + self.name = cloud_data.get(self._cc('name'), data.get('name', + '')) + self.description = cloud_data.get(self._cc('description')) + self.drive_type = cloud_data.get(self._cc('driveType')) + self.web_url = cloud_data.get(self._cc('webUrl')) + + owner = cloud_data.get(self._cc('owner'), {}).get('user', None) + self.owner = Contact(con=self.con, protocol=self.protocol, + **{self._cloud_data_key: owner}) if owner else None + self.quota = cloud_data.get(self._cc('quota')) # dict + + created = cloud_data.get(self._cc('createdDateTime'), None) + modified = cloud_data.get(self._cc('lastModifiedDateTime'), None) + local_tz = self.protocol.timezone + self.created = parse(created).astimezone(local_tz) if created else None + self.modified = parse(modified).astimezone( + local_tz) if modified else None + + def __str__(self): + return self.__repr__() + + def __repr__(self): + owner = str(self.owner) if self.owner else '' + name = self.name or self.object_id or 'Default Drive' + if owner: + return 'Drive: {} (Owned by: {})'.format(name, owner) + else: + return 'Drive: {}'.format(name) + + def __eq__(self, other): + return self.object_id == other.object_id + +
[docs] def get_root_folder(self): + """ Returns the Root Folder of this drive + + :return: Root Folder + :rtype: DriveItem + """ + if self.object_id: + # reference the current drive_id + url = self.build_url( + self._endpoints.get('get_root_item').format(id=self.object_id)) + else: + # we don't know the drive_id so go to the default drive + url = self.build_url(self._endpoints.get('get_root_item_default')) + + response = self.con.get(url) + if not response: + return None + + data = response.json() + + # Everything received from cloud must be passed as self._cloud_data_key + return self._classifier(data)(parent=self, + **{self._cloud_data_key: data})
+ + def _base_get_list(self, url, limit=None, *, query=None, order_by=None, + batch=None): + """ Returns a collection of drive items """ + + if limit is None or limit > self.protocol.max_top_value: + batch = self.protocol.max_top_value + + params = {'$top': batch if batch else limit} + + if order_by: + params['$orderby'] = order_by + + if query: + # if query.has_filters: + # warnings.warn( + # 'Filters are not allowed by the Api Provider ' + # 'in this method') + # query.clear_filters() + if isinstance(query, str): + params['$filter'] = query + else: + params.update(query.as_params()) + + response = self.con.get(url, params=params) + if not response: + return iter(()) + + data = response.json() + + # Everything received from cloud must be passed as self._cloud_data_key + items = ( + self._classifier(item)(parent=self, **{self._cloud_data_key: item}) + for item in data.get('value', [])) + next_link = data.get(NEXT_LINK_KEYWORD, None) + if batch and next_link: + return Pagination(parent=self, data=items, + constructor=self._classifier, + next_link=next_link, limit=limit) + else: + return items + +
[docs] def get_items(self, limit=None, *, query=None, order_by=None, batch=None): + """ Returns a collection of drive items from the root folder + + :param int limit: max no. of items to get. Over 999 uses batch. + :param query: applies a OData filter to the request + :type query: Query or str + :param order_by: orders the result set based on this condition + :type order_by: Query or str + :param int batch: batch size, retrieves items in + batches allowing to retrieve more items than the limit. + :return: items in this folder + :rtype: generator of DriveItem or Pagination + """ + + if self.object_id: + # reference the current drive_id + url = self.build_url( + self._endpoints.get('list_items').format(id=self.object_id)) + else: + # we don't know the drive_id so go to the default + url = self.build_url(self._endpoints.get('list_items_default')) + + return self._base_get_list(url, limit=limit, query=query, + order_by=order_by, batch=batch)
+ +
[docs] def get_child_folders(self, limit=None, *, query=None, order_by=None, batch=None): + """ Returns all the folders inside this folder + + :param int limit: max no. of folders to get. Over 999 uses batch. + :param query: applies a OData filter to the request + :type query: Query or str + :param order_by: orders the result set based on this condition + :type order_by: Query or str + :param int batch: batch size, retrieves items in + batches allowing to retrieve more items than the limit. + :return: folder items in this folder + :rtype: generator of DriveItem or Pagination + """ + + if query: + query = query.on_attribute('folder').unequal(None) + else: + query = self.q('folder').unequal(None) + + return self.get_items(limit=limit, query=query, order_by=order_by, batch=batch)
+ +
[docs] def get_recent(self, limit=None, *, query=None, order_by=None, batch=None): + """ Returns a collection of recently used DriveItems + + :param int limit: max no. of items to get. Over 999 uses batch. + :param query: applies a OData filter to the request + :type query: Query or str + :param order_by: orders the result set based on this condition + :type order_by: Query or str + :param int batch: batch size, retrieves items in + batches allowing to retrieve more items than the limit. + :return: items in this folder + :rtype: generator of DriveItem or Pagination + """ + if self.object_id: + # reference the current drive_id + url = self.build_url( + self._endpoints.get('recent').format(id=self.object_id)) + else: + # we don't know the drive_id so go to the default + url = self.build_url(self._endpoints.get('recent_default')) + + return self._base_get_list(url, limit=limit, query=query, + order_by=order_by, batch=batch)
+ +
[docs] def get_shared_with_me(self, limit=None, *, query=None, order_by=None, + batch=None): + """ Returns a collection of DriveItems shared with me + + :param int limit: max no. of items to get. Over 999 uses batch. + :param query: applies a OData filter to the request + :type query: Query or str + :param order_by: orders the result set based on this condition + :type order_by: Query or str + :param int batch: batch size, retrieves items in + batches allowing to retrieve more items than the limit. + :return: items in this folder + :rtype: generator of DriveItem or Pagination + """ + + if self.object_id: + # reference the current drive_id + url = self.build_url( + self._endpoints.get('shared_with_me').format(id=self.object_id)) + else: + # we don't know the drive_id so go to the default + url = self.build_url(self._endpoints.get('shared_with_me_default')) + + return self._base_get_list(url, limit=limit, query=query, + order_by=order_by, batch=batch)
+ +
[docs] def get_item(self, item_id): + """ Returns a DriveItem by it's Id + + :return: one item + :rtype: DriveItem + """ + if self.object_id: + # reference the current drive_id + url = self.build_url( + self._endpoints.get('get_item').format(id=self.object_id, + item_id=item_id)) + else: + # we don't know the drive_id so go to the default drive + url = self.build_url( + self._endpoints.get('get_item_default').format(item_id=item_id)) + + response = self.con.get(url) + if not response: + return None + + data = response.json() + + # Everything received from cloud must be passed as self._cloud_data_key + return self._classifier(data)(parent=self, + **{self._cloud_data_key: data})
+ +
[docs] def get_item_by_path(self, item_path): + """ Returns a DriveItem by it's path: /path/to/file + :return: one item + :rtype: DriveItem + """ + if self.object_id: + # reference the current drive_id + url = self.build_url( + self._endpoints.get('get_item_by_path').format(id=self.object_id, + item_path=item_path)) + else: + # we don't know the drive_id so go to the default drive + url = self.build_url( + self._endpoints.get('get_item_by_path_default').format(item_path=item_path)) + + response = self.con.get(url) + if not response: + return None + + data = response.json() + + # Everything received from cloud must be passed as self._cloud_data_key + return self._classifier(data)(parent=self, + **{self._cloud_data_key: data})
+ +
[docs] def get_special_folder(self, name): + """ Returns the specified Special Folder + + :return: a special Folder + :rtype: drive.Folder + """ + + name = name if \ + isinstance(name, OneDriveWellKnowFolderNames) \ + else OneDriveWellKnowFolderNames(name.lower()) + name = name.value + + if self.object_id: + # reference the current drive_id + url = self.build_url( + self._endpoints.get('get_special').format(id=self.object_id, + name=name)) + else: + # we don't know the drive_id so go to the default + url = self.build_url( + self._endpoints.get('get_special_default').format(name=name)) + + response = self.con.get(url) + if not response: + return None + + data = response.json() + + # Everything received from cloud must be passed as self._cloud_data_key + return self._classifier(data)(parent=self, + **{self._cloud_data_key: data})
+ + @staticmethod + def _classifier(item): + """ Subclass to change factory classes """ + if 'folder' in item: + return Folder + elif 'image' in item: + return Image + elif 'photo' in item: + return Photo + else: + return File + +
[docs] def refresh(self): + """ Updates this drive with data from the server + + :return: Success / Failure + :rtype: bool + """ + + if self.object_id is None: + url = self.build_url(self._endpoints.get('default_drive')) + else: + url = self.build_url( + self._endpoints.get('get_drive').format(id=self.object_id)) + + response = self.con.get(url) + if not response: + return False + + drive = response.json() + + self._update_data({self._cloud_data_key: drive}) + return True
+ +
[docs] def search(self, search_text, limit=None, *, query=None, order_by=None, + batch=None): + """ Search for DriveItems under this drive. + Your app can search more broadly to include items shared with the + current user. + + To broaden the search scope, use this search instead the Folder Search. + + The search API uses a search service under the covers, which requires + indexing of content. + + As a result, there will be some time between creation of an + item and when it will appear in search results. + + :param str search_text: The query text used to search for items. + Values may be matched across several fields including filename, + metadata, and file content. + :param int limit: max no. of items to get. Over 999 uses batch. + :param query: applies a OData filter to the request + :type query: Query or str + :param order_by: orders the result set based on this condition + :type order_by: Query or str + :param int batch: batch size, retrieves items in + batches allowing to retrieve more items than the limit. + :return: items in this folder matching search + :rtype: generator of DriveItem or Pagination + """ + if not isinstance(search_text, str) or not search_text: + raise ValueError('Provide a valid search_text') + + if self.object_id is None: + url = self.build_url(self._endpoints.get('search_default').format( + search_text=search_text)) + else: + url = self.build_url( + self._endpoints.get('search').format(id=self.object_id, + search_text=search_text)) + + if limit is None or limit > self.protocol.max_top_value: + batch = self.protocol.max_top_value + + params = {'$top': batch if batch else limit} + + if order_by: + params['$orderby'] = order_by + + if query: + if query.has_filters: + warnings.warn( + 'Filters are not allowed by the Api Provider ' + 'in this method') + query.clear_filters() + if isinstance(query, str): + params['$filter'] = query + else: + params.update(query.as_params()) + + response = self.con.get(url, params=params) + if not response: + return iter(()) + + data = response.json() + + # Everything received from cloud must be passed as self._cloud_data_key + items = ( + self._classifier(item)(parent=self, **{self._cloud_data_key: item}) + for item in data.get('value', [])) + next_link = data.get(NEXT_LINK_KEYWORD, None) + if batch and next_link: + return Pagination(parent=self, data=items, + constructor=self._classifier, + next_link=next_link, limit=limit) + else: + return items
+ + +
[docs]class Storage(ApiComponent): + """ Parent Class that holds drives """ + + _endpoints = { + 'default_drive': '/drive', + 'get_drive': '/drives/{id}', + 'list_drives': '/drives', + } + drive_constructor = Drive + +
[docs] def __init__(self, *, parent=None, con=None, **kwargs): + """ Create a storage representation + + :param parent: parent for this operation + :type parent: Account + :param Connection con: connection to use if no parent specified + :param Protocol protocol: protocol to use if no parent specified + (kwargs) + :param str main_resource: use this resource instead of parent resource + (kwargs) + """ + if parent and con: + raise ValueError('Need a parent or a connection but not both') + self.con = parent.con if parent else con + + # Choose the main_resource passed in kwargs over parent main_resource + main_resource = kwargs.pop('main_resource', None) or ( + getattr(parent, 'main_resource', None) if parent else None) + super().__init__( + protocol=parent.protocol if parent else kwargs.get('protocol'), + main_resource=main_resource)
+ + def __str__(self): + return self.__repr__() + + def __repr__(self): + return 'Storage for resource: {}'.format(self.main_resource) + +
[docs] def get_default_drive(self, request_drive=False): + """ Returns a Drive instance + + :param request_drive: True will make an api call to retrieve the drive + data + :return: default One Drive + :rtype: Drive + """ + if request_drive is False: + return Drive(con=self.con, protocol=self.protocol, + main_resource=self.main_resource, name='Default Drive') + + url = self.build_url(self._endpoints.get('default_drive')) + + response = self.con.get(url) + if not response: + return None + + drive = response.json() + + # Everything received from cloud must be passed as self._cloud_data_key + return self.drive_constructor(con=self.con, protocol=self.protocol, + main_resource=self.main_resource, + **{self._cloud_data_key: drive})
+ +
[docs] def get_drive(self, drive_id): + """ Returns a Drive instance + + :param drive_id: the drive_id to be retrieved + :return: Drive for the id + :rtype: Drive + """ + if not drive_id: + return None + + url = self.build_url( + self._endpoints.get('get_drive').format(id=drive_id)) + + response = self.con.get(url) + if not response: + return None + + drive = response.json() + + # Everything received from cloud must be passed as self._cloud_data_key + return self.drive_constructor(con=self.con, protocol=self.protocol, + main_resource=self.main_resource, + **{self._cloud_data_key: drive})
+ +
[docs] def get_drives(self): + """ Returns a collection of drives""" + + url = self.build_url(self._endpoints.get('list_drives')) + + response = self.con.get(url) + + if not response: + return [] + + data = response.json() + + # Everything received from cloud must be passed as self._cloud_data_key + drives = [self.drive_constructor(parent=self, **{self._cloud_data_key: drive}) for + drive in data.get('value', [])] + + return drives
+
+ +
+ +
+ + +
+
+ +
+ +
+ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/O365/excel.html b/docs/latest/_modules/O365/excel.html new file mode 100644 index 00000000..c4829e04 --- /dev/null +++ b/docs/latest/_modules/O365/excel.html @@ -0,0 +1,2506 @@ + + + + + + + + O365.excel — O365 documentation + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+ +
+
+
+
+ +

Source code for O365.excel

+"""
+2019-04-15
+Note: Support for workbooks stored in OneDrive Consumer platform is still not available.
+At this time, only the files stored in business platform is supported by Excel REST APIs.
+"""
+
+import datetime as dt
+import logging
+import re
+from urllib.parse import quote
+
+from .connection import MSOffice365Protocol
+from .drive import File
+from .utils import ApiComponent, TrackerSet, to_snake_case
+
+log = logging.getLogger(__name__)
+
+PERSISTENT_SESSION_INACTIVITY_MAX_AGE = 60 * 7  # 7 minutes
+NON_PERSISTENT_SESSION_INACTIVITY_MAX_AGE = 60 * 5  # 5 minutes
+EXCEL_XLSX_MIME_TYPE = (
+    "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
+)
+
+
+UnsetSentinel = object()
+
+
+# TODO Excel: WorkbookFormatProtection, WorkbookRangeBorder
+
+
+
+[docs] +class FunctionException(Exception): + pass
+ + + +
+[docs] +class WorkbookSession(ApiComponent): + """ + See https://docs.microsoft.com/en-us/graph/api/resources/excel?view=graph-rest-1.0#sessions-and-persistence + """ + + _endpoints = { + "create_session": "/createSession", + "refresh_session": "/refreshSession", + "close_session": "/closeSession", + } + +
+[docs] + def __init__(self, *, parent=None, con=None, persist=True, **kwargs): + """Create a workbook session object. + + :param parent: parent for this operation + :param Connection con: connection to use if no parent specified + :param Bool persist: Whether or not to persist the session changes + """ + if parent and con: + raise ValueError("Need a parent or a connection but not both") + self.con = parent.con if parent else con + + # Choose the main_resource passed in kwargs over parent main_resource + main_resource = kwargs.pop("main_resource", None) or ( + getattr(parent, "main_resource", None) if parent else None + ) + + super().__init__( + protocol=parent.protocol if parent else kwargs.get("protocol"), + main_resource=main_resource, + ) + + self.persist = persist + + self.inactivity_limit = ( + dt.timedelta(seconds=PERSISTENT_SESSION_INACTIVITY_MAX_AGE) + if persist + else dt.timedelta(seconds=NON_PERSISTENT_SESSION_INACTIVITY_MAX_AGE) + ) + self.session_id = None + self.last_activity = dt.datetime.now()
+ + + def __str__(self): + return self.__repr__() + + def __repr__(self): + return "Workbook Session: {}".format(self.session_id or "Not set") + + def __bool__(self): + return self.session_id is not None + +
+[docs] + def create_session(self): + """Request a new session id""" + + url = self.build_url(self._endpoints.get("create_session")) + response = self.con.post(url, data={"persistChanges": self.persist}) + if not response: + raise RuntimeError("Could not create session as requested by the user.") + data = response.json() + self.session_id = data.get("id") + + return True
+ + +
+[docs] + def refresh_session(self): + """Refresh the current session id""" + + if self.session_id: + url = self.build_url(self._endpoints.get("refresh_session")) + response = self.con.post( + url, headers={"workbook-session-id": self.session_id} + ) + return bool(response) + return False
+ + +
+[docs] + def close_session(self): + """Close the current session""" + + if self.session_id: + url = self.build_url(self._endpoints.get("close_session")) + response = self.con.post( + url, headers={"workbook-session-id": self.session_id} + ) + return bool(response) + return False
+ + +
+[docs] + def prepare_request(self, kwargs): + """If session is in use, prepares the request headers and + checks if the session is expired. + """ + if self.session_id is not None: + actual = dt.datetime.now() + + if (self.last_activity + self.inactivity_limit) < actual: + # session expired + if self.persist: + # request new session + self.create_session() + actual = dt.datetime.now() + else: + # raise error and recommend to manualy refresh session + raise RuntimeError( + "A non Persistent Session is expired. " + "For consistency reasons this exception is raised. " + "Please try again with manual refresh of the session " + ) + self.last_activity = actual + + headers = kwargs.get("headers") + if headers is None: + kwargs["headers"] = headers = {} + headers["workbook-session-id"] = self.session_id
+ + +
+[docs] + def get(self, *args, **kwargs): + self.prepare_request(kwargs) + return self.con.get(*args, **kwargs)
+ + +
+[docs] + def post(self, *args, **kwargs): + self.prepare_request(kwargs) + return self.con.post(*args, **kwargs)
+ + +
+[docs] + def put(self, *args, **kwargs): + self.prepare_request(kwargs) + return self.con.put(*args, **kwargs)
+ + +
+[docs] + def patch(self, *args, **kwargs): + self.prepare_request(kwargs) + return self.con.patch(*args, **kwargs)
+ + +
+[docs] + def delete(self, *args, **kwargs): + self.prepare_request(kwargs) + return self.con.delete(*args, **kwargs)
+
+ + + +
+[docs] +class RangeFormatFont: + """A font format applied to a range""" + +
+[docs] + def __init__(self, parent): + self.parent = parent + self._track_changes = TrackerSet(casing=parent._cc) + self._loaded = False + + self._bold = False + self._color = "#000000" # default black + self._italic = False + self._name = "Calibri" + self._size = 10 + self._underline = "None"
+ + + def _load_data(self): + """Loads the data into this instance""" + url = self.parent.build_url(self.parent._endpoints.get("format")) + response = self.parent.session.get(url) + if not response: + return False + data = response.json() + + self._bold = data.get("bold", False) + self._color = data.get("color", "#000000") # default black + self._italic = data.get("italic", False) + self._name = data.get("name", "Calibri") # default Calibri + self._size = data.get("size", 10) # default 10 + self._underline = data.get("underline", "None") + + self._loaded = True + return True + +
+[docs] + def to_api_data(self, restrict_keys=None): + """Returns a dict to communicate with the server + + :param restrict_keys: a set of keys to restrict the returned data to + :rtype: dict + """ + cc = self.parent._cc # alias + data = { + cc("bold"): self._bold, + cc("color"): self._color, + cc("italic"): self._italic, + cc("name"): self._name, + cc("size"): self._size, + cc("underline"): self._underline, + } + + if restrict_keys: + for key in list(data.keys()): + if key not in restrict_keys: + del data[key] + return data
+ + + @property + def bold(self): + if not self._loaded: + self._load_data() + return self._bold + + @bold.setter + def bold(self, value): + self._bold = value + self._track_changes.add("bold") + + @property + def color(self): + if not self._color: + self._load_data() + return self._color + + @color.setter + def color(self, value): + self._color = value + self._track_changes.add("color") + + @property + def italic(self): + if not self._loaded: + self._load_data() + return self._italic + + @italic.setter + def italic(self, value): + self._italic = value + self._track_changes.add("italic") + + @property + def name(self): + if not self._loaded: + self._load_data() + return self._name + + @name.setter + def name(self, value): + self._name = value + self._track_changes.add("name") + + @property + def size(self): + if not self._loaded: + self._load_data() + return self._size + + @size.setter + def size(self, value): + self._size = value + self._track_changes.add("size") + + @property + def underline(self): + if not self._loaded: + self._load_data() + return self._underline + + @underline.setter + def underline(self, value): + self._underline = value + self._track_changes.add("underline")
+ + + +
+[docs] +class RangeFormat(ApiComponent): + """A format applied to a range""" + + _endpoints = { + "borders": "/borders", + "font": "/font", + "fill": "/fill", + "clear_fill": "/fill/clear", + "auto_fit_columns": "/autofitColumns", + "auto_fit_rows": "/autofitRows", + } + +
+[docs] + def __init__(self, parent=None, session=None, **kwargs): + if parent and session: + raise ValueError("Need a parent or a session but not both") + + self.range = parent + self.session = parent.session if parent else session + + # Choose the main_resource passed in kwargs over parent main_resource + main_resource = kwargs.pop("main_resource", None) or ( + getattr(parent, "main_resource", None) if parent else None + ) + + # append the format path + main_resource = "{}/format".format(main_resource) + + super().__init__( + protocol=parent.protocol if parent else kwargs.get("protocol"), + main_resource=main_resource, + ) + + self._track_changes = TrackerSet(casing=self._cc) + self._track_background_color = False + + cloud_data = kwargs.get(self._cloud_data_key, {}) + + self._column_width = cloud_data.get("columnWidth", 11) + self._horizontal_alignment = cloud_data.get("horizontalAlignment", "General") + self._row_height = cloud_data.get("rowHeight", 15) + self._vertical_alignment = cloud_data.get("verticalAlignment", "Bottom") + self._wrap_text = cloud_data.get("wrapText", None) + + self._font = RangeFormatFont(self) + self._background_color = UnsetSentinel
+ + + def __str__(self): + return self.__repr__() + + def __repr__(self): + return "Format for range address: {}".format( + self.range.address if self.range else "Unkknown" + ) + + @property + def column_width(self): + return self._column_width + + @column_width.setter + def column_width(self, value): + self._column_width = value + self._track_changes.add("column_width") + + @property + def horizontal_alignment(self): + return self._horizontal_alignment + + @horizontal_alignment.setter + def horizontal_alignment(self, value): + self._horizontal_alignment = value + self._track_changes.add("horizontal_alignment") + + @property + def row_height(self): + return self._row_height + + @row_height.setter + def row_height(self, value): + self._row_height = value + self._track_changes.add("row_height") + + @property + def vertical_alignment(self): + return self._vertical_alignment + + @vertical_alignment.setter + def vertical_alignment(self, value): + self._vertical_alignment = value + self._track_changes.add("vertical_alignment") + + @property + def wrap_text(self): + return self._wrap_text + + @wrap_text.setter + def wrap_text(self, value): + self._wrap_text = value + self._track_changes.add("wrap_text") + +
+[docs] + def to_api_data(self, restrict_keys=None): + """Returns a dict to communicate with the server + + :param restrict_keys: a set of keys to restrict the returned data to + :rtype: dict + """ + cc = self._cc # alias + data = { + cc("column_width"): self._column_width, + cc("horizontal_alignment"): self._horizontal_alignment, + cc("row_height"): self._row_height, + cc("vertical_alignment"): self._vertical_alignment, + cc("wrap_text"): self._wrap_text, + } + + if restrict_keys: + for key in list(data.keys()): + if key not in restrict_keys: + del data[key] + return data
+ + +
+[docs] + def update(self): + """Updates this range format""" + if self._track_changes: + data = self.to_api_data(restrict_keys=self._track_changes) + if data: + response = self.session.patch(self.build_url(""), data=data) + if not response: + return False + self._track_changes.clear() + if self._font._track_changes: + data = self._font.to_api_data(restrict_keys=self._font._track_changes) + if data: + response = self.session.patch( + self.build_url(self._endpoints.get("font")), data=data + ) + if not response: + return False + self._font._track_changes.clear() + if self._track_background_color: + if self._background_color is None: + url = self.build_url(self._endpoints.get("clear_fill")) + response = self.session.post(url) + else: + data = {"color": self._background_color} + url = self.build_url(self._endpoints.get("fill")) + response = self.session.patch(url, data=data) + if not response: + return False + self._track_background_color = False + + return True
+ + + @property + def font(self): + return self._font + + @property + def background_color(self): + if self._background_color is UnsetSentinel: + self._load_background_color() + return self._background_color + + @background_color.setter + def background_color(self, value): + self._background_color = value + self._track_background_color = True + + def _load_background_color(self): + """Loads the data related to the fill color""" + url = self.build_url(self._endpoints.get("fill")) + response = self.session.get(url) + if not response: + return None + data = response.json() + self._background_color = data.get("color", None) + +
+[docs] + def auto_fit_columns(self): + """Changes the width of the columns of the current range + to achieve the best fit, based on the current data in the columns + """ + url = self.build_url(self._endpoints.get("auto_fit_columns")) + return bool(self.session.post(url))
+ + +
+[docs] + def auto_fit_rows(self): + """Changes the width of the rows of the current range + to achieve the best fit, based on the current data in the rows + """ + url = self.build_url(self._endpoints.get("auto_fit_rows")) + return bool(self.session.post(url))
+ + +
+[docs] + def set_borders(self, side_style=""): + """Sets the border of this range""" + pass
+
+ + + +
+[docs] +class Range(ApiComponent): + """An Excel Range""" + + _endpoints = { + "get_cell": "/cell(row={},column={})", + "get_column": "/column(column={})", + "get_bounding_rect": "/boundingRect", + "columns_after": "/columnsAfter(count={})", + "columns_before": "/columnsBefore(count={})", + "entire_column": "/entireColumn", + "intersection": "/intersection", + "last_cell": "/lastCell", + "last_column": "/lastColumn", + "last_row": "/lastRow", + "offset_range": "/offsetRange", + "get_row": "/row", + "rows_above": "/rowsAbove(count={})", + "rows_below": "/rowsBelow(count={})", + "get_used_range": "/usedRange(valuesOnly={})", + "clear_range": "/clear", + "delete_range": "/delete", + "insert_range": "/insert", + "merge_range": "/merge", + "unmerge_range": "/unmerge", + "get_resized_range": "/resizedRange(deltaRows={}, deltaColumns={})", + "get_format": "/format", + } + range_format_constructor = RangeFormat + +
+[docs] + def __init__(self, parent=None, session=None, **kwargs): + if parent and session: + raise ValueError("Need a parent or a session but not both") + + self.session = parent.session if parent else session + + cloud_data = kwargs.get(self._cloud_data_key, {}) + + self.object_id = cloud_data.get("address", None) + + # Choose the main_resource passed in kwargs over parent main_resource + main_resource = kwargs.pop("main_resource", None) or ( + getattr(parent, "main_resource", None) if parent else None + ) + + # append the encoded range path + if isinstance(parent, Range): + # strip the main resource + main_resource = main_resource.split("/range")[0] + if isinstance(parent, (WorkSheet, Range)): + if "!" in self.object_id: + # remove the sheet string from the address as it's not needed + self.object_id = self.object_id.split("!")[1] + main_resource = "{}/range(address='{}')".format( + main_resource, quote(self.object_id) + ) + else: + main_resource = "{}/range".format(main_resource) + + super().__init__( + protocol=parent.protocol if parent else kwargs.get("protocol"), + main_resource=main_resource, + ) + + self._track_changes = TrackerSet(casing=self._cc) + + self.address = cloud_data.get("address", "") + self.address_local = cloud_data.get("addressLocal", "") + self.column_count = cloud_data.get("columnCount", 0) + self.row_count = cloud_data.get("rowCount", 0) + self.cell_count = cloud_data.get("cellCount", 0) + self._column_hidden = cloud_data.get("columnHidden", False) + self.column_index = cloud_data.get("columnIndex", 0) # zero indexed + self._row_hidden = cloud_data.get("rowHidden", False) + self.row_index = cloud_data.get("rowIndex", 0) # zero indexed + self._formulas = cloud_data.get("formulas", [[]]) + self._formulas_local = cloud_data.get("formulasLocal", [[]]) + self._formulas_r1_c1 = cloud_data.get("formulasR1C1", [[]]) + self.hidden = cloud_data.get("hidden", False) + self._number_format = cloud_data.get("numberFormat", [[]]) + self.text = cloud_data.get("text", [[]]) + self.value_types = cloud_data.get("valueTypes", [[]]) + self._values = cloud_data.get("values", [[]])
+ + + def __str__(self): + return self.__repr__() + + def __repr__(self): + return "Range address: {}".format(self.address) + + def __eq__(self, other): + return self.object_id == other.object_id + + @property + def column_hidden(self): + return self._column_hidden + + @column_hidden.setter + def column_hidden(self, value): + self._column_hidden = value + self._track_changes.add("column_hidden") + + @property + def row_hidden(self): + return self._row_hidden + + @row_hidden.setter + def row_hidden(self, value): + self._row_hidden = value + self._track_changes.add("row_hidden") + + @property + def formulas(self): + return self._formulas + + @formulas.setter + def formulas(self, value): + self._formulas = value + self._track_changes.add("formulas") + + @property + def formulas_local(self): + return self._formulas_local + + @formulas_local.setter + def formulas_local(self, value): + self._formulas_local = value + self._track_changes.add("formulas_local") + + @property + def formulas_r1_c1(self): + return self._formulas_r1_c1 + + @formulas_r1_c1.setter + def formulas_r1_c1(self, value): + self._formulas_r1_c1 = value + self._track_changes.add("formulas_r1_c1") + + @property + def number_format(self): + return self._number_format + + @number_format.setter + def number_format(self, value): + self._number_format = value + self._track_changes.add("number_format") + + @property + def values(self): + return self._values + + @values.setter + def values(self, value): + if not isinstance(value, list): + value = [[value]] # values is always a 2 dimensional array + self._values = value + self._track_changes.add("values") + +
+[docs] + def to_api_data(self, restrict_keys=None): + """Returns a dict to communicate with the server + + :param restrict_keys: a set of keys to restrict the returned data to + :rtype: dict + """ + cc = self._cc # alias + data = { + cc("column_hidden"): self._column_hidden, + cc("row_hidden"): self._row_hidden, + cc("formulas"): self._formulas, + cc("formulas_local"): self._formulas_local, + cc("formulas_r1_c1"): self._formulas_r1_c1, + cc("number_format"): self._number_format, + cc("values"): self._values, + } + + if restrict_keys: + for key in list(data.keys()): + if key not in restrict_keys: + del data[key] + return data
+ + + def _get_range(self, endpoint, *args, method="GET", **kwargs): + """Helper that returns another range""" + if args: + url = self.build_url(self._endpoints.get(endpoint).format(*args)) + else: + url = self.build_url(self._endpoints.get(endpoint)) + if not kwargs: + kwargs = None + if method == "GET": + response = self.session.get(url, params=kwargs) + elif method == "POST": + response = self.session.post(url, data=kwargs) + if not response: + return None + return self.__class__(parent=self, **{self._cloud_data_key: response.json()}) + +
+[docs] + def get_cell(self, row, column): + """ + Gets the range object containing the single cell based on row and column numbers. + :param int row: the row number + :param int column: the column number + :return: a Range instance + """ + return self._get_range("get_cell", row, column)
+ + +
+[docs] + def get_column(self, index): + """ + Returns a column whitin the range + :param int index: the index of the column. zero indexed + :return: a Range + """ + return self._get_range("get_column", index)
+ + +
+[docs] + def get_bounding_rect(self, address): + """ + Gets the smallest range object that encompasses the given ranges. + For example, the GetBoundingRect of "B2:C5" and "D10:E15" is "B2:E16". + :param str address: another address to retrieve it's bounding rect + """ + return self._get_range("get_bounding_rect", anotherRange=address)
+ + +
+[docs] + def get_columns_after(self, columns=1): + """ + Gets a certain number of columns to the right of the given range. + :param int columns: Optional. The number of columns to include in the resulting range. + """ + return self._get_range("columns_after", columns, method="POST")
+ + +
+[docs] + def get_columns_before(self, columns=1): + """ + Gets a certain number of columns to the left of the given range. + :param int columns: Optional. The number of columns to include in the resulting range. + """ + return self._get_range("columns_before", columns, method="POST")
+ + +
+[docs] + def get_entire_column(self): + """Gets a Range that represents the entire column of the range.""" + return self._get_range("entire_column")
+ + +
+[docs] + def get_intersection(self, address): + """ + Gets the Range that represents the rectangular intersection of the given ranges. + + :param address: the address range you want ot intersect with. + :return: Range + """ + self._get_range("intersection", anotherRange=address)
+ + +
+[docs] + def get_last_cell(self): + """Gets the last cell within the range.""" + return self._get_range("last_cell")
+ + +
+[docs] + def get_last_column(self): + """Gets the last column within the range.""" + return self._get_range("last_column")
+ + +
+[docs] + def get_last_row(self): + """Gets the last row within the range.""" + return self._get_range("last_row")
+ + +
+[docs] + def get_offset_range(self, row_offset, column_offset): + """Gets an object which represents a range that's offset from the specified range. + The dimension of the returned range will match this range. + If the resulting range is forced outside the bounds of the worksheet grid, + an exception will be thrown. + + :param int row_offset: The number of rows (positive, negative, or 0) + by which the range is to be offset. + :param int column_offset: he number of columns (positive, negative, or 0) + by which the range is to be offset. + :return: Range + """ + + return self._get_range( + "offset_range", rowOffset=row_offset, columnOffset=column_offset + )
+ + +
+[docs] + def get_row(self, index): + """ + Gets a row contained in the range. + :param int index: Row number of the range to be retrieved. + :return: Range + """ + return self._get_range("get_row", method="POST", row=index)
+ + +
+[docs] + def get_rows_above(self, rows=1): + """ + Gets a certain number of rows above a given range. + + :param int rows: Optional. The number of rows to include in the resulting range. + :return: Range + """ + return self._get_range("rows_above", rows, method="POST")
+ + +
+[docs] + def get_rows_below(self, rows=1): + """ + Gets a certain number of rows below a given range. + + :param int rows: Optional. The number of rows to include in the resulting range. + :return: Range + """ + return self._get_range("rows_below", rows, method="POST")
+ + +
+[docs] + def get_used_range(self, only_values=True): + """ + Returns the used range of the given range object. + + :param bool only_values: Optional. Defaults to True. + Considers only cells with values as used cells (ignores formatting). + :return: Range + """ + # Format the "only_values" parameter as a lowercase string to work correctly with the Graph API + return self._get_range("get_used_range", str(only_values).lower())
+ + +
+[docs] + def clear(self, apply_to="all"): + """ + Clear range values, format, fill, border, etc. + + :param str apply_to: Optional. Determines the type of clear action. + The possible values are: all, formats, contents. + """ + url = self.build_url(self._endpoints.get("clear_range")) + return bool(self.session.post(url, data={"applyTo": apply_to.capitalize()}))
+ + +
+[docs] + def delete(self, shift="up"): + """ + Deletes the cells associated with the range. + + :param str shift: Optional. Specifies which way to shift the cells. + The possible values are: up, left. + """ + url = self.build_url(self._endpoints.get("delete_range")) + return bool(self.session.post(url, data={"shift": shift.capitalize()}))
+ + +
+[docs] + def insert_range(self, shift): + """ + Inserts a cell or a range of cells into the worksheet in place of this range, + and shifts the other cells to make space. + + :param str shift: Specifies which way to shift the cells. The possible values are: down, right. + :return: new Range instance at the now blank space + """ + return self._get_range("insert_range", method="POST", shift=shift.capitalize())
+ + +
+[docs] + def merge(self, across=False): + """ + Merge the range cells into one region in the worksheet. + + :param bool across: Optional. Set True to merge cells in each row of the + specified range as separate merged cells. + """ + url = self.build_url(self._endpoints.get("merge_range")) + return bool(self.session.post(url, data={"across": across}))
+ + +
+[docs] + def unmerge(self): + """Unmerge the range cells into separate cells.""" + url = self.build_url(self._endpoints.get("unmerge_range")) + return bool(self.session.post(url))
+ + +
+[docs] + def get_resized_range(self, rows, columns): + """ + Gets a range object similar to the current range object, + but with its bottom-right corner expanded (or contracted) + by some number of rows and columns. + + :param int rows: The number of rows by which to expand the + bottom-right corner, relative to the current range. + :param int columns: The number of columns by which to expand the + bottom-right corner, relative to the current range. + :return: Range + """ + return self._get_range("get_resized_range", rows, columns, method="GET")
+ + +
+[docs] + def update(self): + """Update this range""" + + if not self._track_changes: + return True # there's nothing to update + + data = self.to_api_data(restrict_keys=self._track_changes) + response = self.session.patch(self.build_url(""), data=data) + if not response: + return False + + data = response.json() + + for field in self._track_changes: + setattr(self, to_snake_case(field), data.get(field)) + self._track_changes.clear() + + return True
+ + +
+[docs] + def get_worksheet(self): + """Returns this range worksheet""" + url = self.build_url("") + q = self.q().select("address").expand("worksheet") + response = self.session.get(url, params=q.as_params()) + if not response: + return None + data = response.json() + + ws = data.get("worksheet") + if ws is None: + return None + return WorkSheet(session=self.session, **{self._cloud_data_key: ws})
+ + +
+[docs] + def get_format(self): + """Returns a RangeFormat instance with the format of this range""" + url = self.build_url(self._endpoints.get("get_format")) + response = self.session.get(url) + if not response: + return None + return self.range_format_constructor( + parent=self, **{self._cloud_data_key: response.json()} + )
+
+ + + +
+[docs] +class NamedRange(ApiComponent): + """Represents a defined name for a range of cells or value""" + + _endpoints = { + "get_range": "/range", + } + + range_constructor = Range + +
+[docs] + def __init__(self, parent=None, session=None, **kwargs): + if parent and session: + raise ValueError("Need a parent or a session but not both") + + self.session = parent.session if parent else session + + cloud_data = kwargs.get(self._cloud_data_key, {}) + + self.object_id = cloud_data.get("name", None) + + # Choose the main_resource passed in kwargs over parent main_resource + main_resource = kwargs.pop("main_resource", None) or ( + getattr(parent, "main_resource", None) if parent else None + ) + + main_resource = "{}/names/{}".format(main_resource, self.object_id) + + super().__init__( + protocol=parent.protocol if parent else kwargs.get("protocol"), + main_resource=main_resource, + ) + + self.name = cloud_data.get("name", None) + self.comment = cloud_data.get("comment", "") + self.scope = cloud_data.get("scope", "") + self.data_type = cloud_data.get("type", "") + self.value = cloud_data.get("value", "") + self.visible = cloud_data.get("visible", True)
+ + + def __str__(self): + return self.__repr__() + + def __repr__(self): + return "Named Range: {} ({})".format(self.name, self.value) + + def __eq__(self, other): + return self.object_id == other.object_id + +
+[docs] + def get_range(self): + """Returns the Range instance this named range refers to""" + url = self.build_url(self._endpoints.get("get_range")) + response = self.session.get(url) + if not response: + return None + return self.range_constructor( + parent=self, **{self._cloud_data_key: response.json()} + )
+ + +
+[docs] + def update(self, *, visible=None, comment=None): + """ + Updates this named range + :param bool visible: Specifies whether the object is visible or not + :param str comment: Represents the comment associated with this name + :return: Success or Failure + """ + if visible is None and comment is None: + raise ValueError('Provide "visible" or "comment" to update.') + data = {} + if visible is not None: + data["visible"] = visible + if comment is not None: + data["comment"] = comment + data = None if not data else data + response = self.session.patch(self.build_url(""), data=data) + if not response: + return False + data = response.json() + + self.visible = data.get("visible", self.visible) + self.comment = data.get("comment", self.comment) + return True
+
+ + + +
+[docs] +class TableRow(ApiComponent): + """An Excel Table Row""" + + _endpoints = { + "get_range": "/range", + "delete": "/delete", + } + range_constructor = Range + +
+[docs] + def __init__(self, parent=None, session=None, **kwargs): + if parent and session: + raise ValueError("Need a parent or a session but not both") + + self.table = parent + self.session = parent.session if parent else session + + cloud_data = kwargs.get(self._cloud_data_key, {}) + + self.object_id = cloud_data.get("index", None) + + # Choose the main_resource passed in kwargs over parent main_resource + main_resource = kwargs.pop("main_resource", None) or ( + getattr(parent, "main_resource", None) if parent else None + ) + + # append the encoded column path + main_resource = "{}/rows/itemAt(index={})".format(main_resource, self.object_id) + + super().__init__( + protocol=parent.protocol if parent else kwargs.get("protocol"), + main_resource=main_resource, + ) + + self.index = cloud_data.get("index", 0) # zero indexed + self.values = cloud_data.get("values", [[]]) # json string
+ + + def __str__(self): + return self.__repr__() + + def __repr__(self): + return "Row number: {}".format(self.index) + + def __eq__(self, other): + return self.object_id == other.object_id + +
+[docs] + def get_range(self): + """Gets the range object associated with the entire row""" + url = self.build_url(self._endpoints.get("get_range")) + response = self.session.get(url) + if not response: + return None + return self.range_constructor( + parent=self, **{self._cloud_data_key: response.json()} + )
+ + +
+[docs] + def update(self, values): + """Updates this row""" + response = self.session.patch(self.build_url(""), data={"values": values}) + if not response: + return False + data = response.json() + self.values = data.get("values", self.values) + return True
+ + +
+[docs] + def delete(self): + """Deletes this row""" + url = self.build_url(self._endpoints.get("delete")) + return bool(self.session.post(url))
+
+ + + +
+[docs] +class TableColumn(ApiComponent): + """An Excel Table Column""" + + _endpoints = { + "delete": "/delete", + "data_body_range": "/dataBodyRange", + "header_row_range": "/headerRowRange", + "total_row_range": "/totalRowRange", + "entire_range": "/range", + "clear_filter": "/filter/clear", + "apply_filter": "/filter/apply", + } + range_constructor = Range + +
+[docs] + def __init__(self, parent=None, session=None, **kwargs): + if parent and session: + raise ValueError("Need a parent or a session but not both") + + self.table = parent + self.session = parent.session if parent else session + + cloud_data = kwargs.get(self._cloud_data_key, {}) + + self.object_id = cloud_data.get("id", None) + + # Choose the main_resource passed in kwargs over parent main_resource + main_resource = kwargs.pop("main_resource", None) or ( + getattr(parent, "main_resource", None) if parent else None + ) + + # append the encoded column path + main_resource = "{}/columns('{}')".format(main_resource, quote(self.object_id)) + + super().__init__( + protocol=parent.protocol if parent else kwargs.get("protocol"), + main_resource=main_resource, + ) + + self.name = cloud_data.get("name", "") + self.index = cloud_data.get("index", 0) # zero indexed + self.values = cloud_data.get("values", [[]]) # json string
+ + + def __str__(self): + return self.__repr__() + + def __repr__(self): + return "Table Column: {}".format(self.name) + + def __eq__(self, other): + return self.object_id == other.object_id + +
+[docs] + def delete(self): + """Deletes this table Column""" + url = self.build_url(self._endpoints.get("delete")) + return bool(self.session.post(url))
+ + +
+[docs] + def update(self, values): + """ + Updates this column + :param values: values to update + """ + response = self.session.patch(self.build_url(""), data={"values": values}) + if not response: + return False + data = response.json() + + self.values = data.get("values", "") + return True
+ + + def _get_range(self, endpoint_name): + """Returns a Range based on the endpoint name""" + + url = self.build_url(self._endpoints.get(endpoint_name)) + response = self.session.get(url) + if not response: + return None + return self.range_constructor( + parent=self, **{self._cloud_data_key: response.json()} + ) + +
+[docs] + def get_data_body_range(self): + """Gets the range object associated with the data body of the column""" + return self._get_range("data_body_range")
+ + +
+[docs] + def get_header_row_range(self): + """Gets the range object associated with the header row of the column""" + return self._get_range("header_row_range")
+ + +
+[docs] + def get_total_row_range(self): + """Gets the range object associated with the totals row of the column""" + return self._get_range("total_row_range")
+ + +
+[docs] + def get_range(self): + """Gets the range object associated with the entire column""" + return self._get_range("entire_range")
+ + +
+[docs] + def clear_filter(self): + """Clears the filter applied to this column""" + url = self.build_url(self._endpoints.get("clear_filter")) + return bool(self.session.post(url))
+ + +
+[docs] + def apply_filter(self, criteria): + """ + Apply the given filter criteria on the given column. + + :param str criteria: the criteria to apply + + Example: + + .. code-block:: json + + { + "color": "string", + "criterion1": "string", + "criterion2": "string", + "dynamicCriteria": "string", + "filterOn": "string", + "icon": {"@odata.type": "microsoft.graph.workbookIcon"}, + "values": {"@odata.type": "microsoft.graph.Json"} + } + + """ + url = self.build_url(self._endpoints.get("apply_filter")) + return bool(self.session.post(url, data={"criteria": criteria}))
+ + +
+[docs] + def get_filter(self): + """Returns the filter applie to this column""" + q = self.q().select("name").expand("filter") + response = self.session.get(self.build_url(""), params=q.as_params()) + if not response: + return None + data = response.json() + return data.get("criteria", None)
+
+ + + +
+[docs] +class Table(ApiComponent): + """An Excel Table""" + + _endpoints = { + "get_columns": "/columns", + "get_column": "/columns/{id}", + "delete_column": "/columns/{id}/delete", + "get_column_index": "/columns/itemAt", + "add_column": "/columns/add", + "get_rows": "/rows", + "get_row": "/rows/{id}", + "delete_row": "/rows/$/itemAt(index={id})", + "get_row_index": "/rows/itemAt", + "add_rows": "/rows/add", + "delete": "/", + "data_body_range": "/dataBodyRange", + "header_row_range": "/headerRowRange", + "total_row_range": "/totalRowRange", + "entire_range": "/range", + "convert_to_range": "/convertToRange", + "clear_filters": "/clearFilters", + "reapply_filters": "/reapplyFilters", + } + column_constructor = TableColumn + row_constructor = TableRow + range_constructor = Range + +
+[docs] + def __init__(self, parent=None, session=None, **kwargs): + if parent and session: + raise ValueError("Need a parent or a session but not both") + + self.parent = parent + self.session = parent.session if parent else session + + cloud_data = kwargs.get(self._cloud_data_key, {}) + + self.object_id = cloud_data.get("id", None) + + # Choose the main_resource passed in kwargs over parent main_resource + main_resource = kwargs.pop("main_resource", None) or ( + getattr(parent, "main_resource", None) if parent else None + ) + + # append the encoded table path + main_resource = "{}/tables('{}')".format(main_resource, quote(self.object_id)) + + super().__init__( + protocol=parent.protocol if parent else kwargs.get("protocol"), + main_resource=main_resource, + ) + + self.name = cloud_data.get("name", None) + self.show_headers = cloud_data.get("showHeaders", True) + self.show_totals = cloud_data.get("showTotals", True) + self.style = cloud_data.get("style", None) + self.highlight_first_column = cloud_data.get("highlightFirstColumn", False) + self.highlight_last_column = cloud_data.get("highlightLastColumn", False) + self.show_banded_columns = cloud_data.get("showBandedColumns", False) + self.show_banded_rows = cloud_data.get("showBandedRows", False) + self.show_filter_button = cloud_data.get("showFilterButton", False) + self.legacy_id = cloud_data.get("legacyId", False)
+ + + def __str__(self): + return self.__repr__() + + def __repr__(self): + return "Table: {}".format(self.name) + + def __eq__(self, other): + return self.object_id == other.object_id + +
+[docs] + def get_columns(self, *, top=None, skip=None): + """ + Return the columns of this table + :param int top: specify n columns to retrieve + :param int skip: specify n columns to skip + """ + url = self.build_url(self._endpoints.get("get_columns")) + + params = {} + if top is not None: + params["$top"] = top + if skip is not None: + params["$skip"] = skip + params = None if not params else params + response = self.session.get(url, params=params) + + if not response: + return iter(()) + + data = response.json() + + return ( + self.column_constructor(parent=self, **{self._cloud_data_key: column}) + for column in data.get("value", []) + )
+ + +
+[docs] + def get_column(self, id_or_name): + """ + Gets a column from this table by id or name + :param id_or_name: the id or name of the column + :return: WorkBookTableColumn + """ + url = self.build_url( + self._endpoints.get("get_column").format(id=quote(id_or_name)) + ) + response = self.session.get(url) + + if not response: + return None + + data = response.json() + + return self.column_constructor(parent=self, **{self._cloud_data_key: data})
+ + +
+[docs] + def get_column_at_index(self, index): + """ + Returns a table column by it's index + :param int index: the zero-indexed position of the column in the table + """ + if index is None: + return None + + url = self.build_url(self._endpoints.get("get_column_index")) + response = self.session.post(url, data={"index": index}) + + if not response: + return None + + return self.column_constructor( + parent=self, **{self._cloud_data_key: response.json()} + )
+ + +
+[docs] + def delete_column(self, id_or_name): + """ + Deletes a Column by its id or name + :param id_or_name: the id or name of the column + :return bool: Success or Failure + """ + url = self.build_url( + self._endpoints.get("delete_column").format(id=quote(id_or_name)) + ) + return bool(self.session.post(url))
+ + +
+[docs] + def add_column(self, name, *, index=0, values=None): + """ + Adds a column to the table + :param str name: the name of the column + :param int index: the index at which the column should be added. Defaults to 0. + :param list values: a two dimension array of values to add to the column + """ + if name is None: + return None + + params = {"name": name, "index": index} + if values is not None: + params["values"] = values + + url = self.build_url(self._endpoints.get("add_column")) + response = self.session.post(url, data=params) + if not response: + return None + + data = response.json() + + return self.column_constructor(parent=self, **{self._cloud_data_key: data})
+ + +
+[docs] + def get_rows(self, *, top=None, skip=None): + """ + Return the rows of this table + :param int top: specify n rows to retrieve + :param int skip: specify n rows to skip + :rtype: TableRow + """ + url = self.build_url(self._endpoints.get("get_rows")) + + params = {} + if top is not None: + params["$top"] = top + if skip is not None: + params["$skip"] = skip + params = None if not params else params + response = self.session.get(url, params=params) + + if not response: + return iter(()) + + data = response.json() + + return ( + self.row_constructor(parent=self, **{self._cloud_data_key: row}) + for row in data.get("value", []) + )
+ + +
+[docs] + def get_row(self, index): + """Returns a Row instance at an index""" + url = self.build_url(self._endpoints.get("get_row").format(id=index)) + response = self.session.get(url) + if not response: + return None + return self.row_constructor( + parent=self, **{self._cloud_data_key: response.json()} + )
+ + +
+[docs] + def get_row_at_index(self, index): + """ + Returns a table row by it's index + :param int index: the zero-indexed position of the row in the table + """ + if index is None: + return None + + url = self.build_url(self._endpoints.get("get_row_index")) + url = "{}(index={})".format(url, index) + response = self.session.get(url) + + if not response: + return None + + return self.row_constructor( + parent=self, **{self._cloud_data_key: response.json()} + )
+ + +
+[docs] + def delete_row(self, index): + """ + Deletes a Row by it's index + :param int index: the index of the row. zero indexed + :return bool: Success or Failure + """ + url = self.build_url(self._endpoints.get("delete_row").format(id=index)) + return bool(self.session.delete(url))
+ + +
+[docs] + def add_rows(self, values=None, index=None): + """ + Add rows to this table. + + Multiple rows can be added at once. + This request might occasionally receive a 504 HTTP error. + The appropriate response to this error is to repeat the request. + + :param list values: Optional. a 1 or 2 dimensional array of values to add + :param int index: Optional. Specifies the relative position of the new row. + If null, the addition happens at the end. + :return: + """ + params = {} + if values is not None: + if values and not isinstance(values[0], list): + # this is a single row + values = [values] + params["values"] = values + if index is not None: + params["index"] = index + + params = params if params else None + + url = self.build_url(self._endpoints.get("add_rows")) + response = self.session.post(url, data=params) + if not response: + return None + return self.row_constructor( + parent=self, **{self._cloud_data_key: response.json()} + )
+ + +
+[docs] + def update(self, *, name=None, show_headers=None, show_totals=None, style=None): + """ + Updates this table + :param str name: the name of the table + :param bool show_headers: whether or not to show the headers + :param bool show_totals: whether or not to show the totals + :param str style: the style of the table + :return: Success or Failure + """ + if ( + name is None + and show_headers is None + and show_totals is None + and style is None + ): + raise ValueError("Provide at least one parameter to update") + data = {} + if name: + data["name"] = name + if show_headers is not None: + data["showHeaders"] = show_headers + if show_totals is not None: + data["showTotals"] = show_totals + if style: + data["style"] = style + + response = self.session.patch(self.build_url(""), data=data) + if not response: + return False + + data = response.json() + self.name = data.get("name", self.name) + self.show_headers = data.get("showHeaders", self.show_headers) + self.show_totals = data.get("showTotals", self.show_totals) + self.style = data.get("style", self.style) + + return True
+ + +
+[docs] + def delete(self): + """Deletes this table""" + url = self.build_url(self._endpoints.get("delete")) + return bool(self.session.delete(url))
+ + + def _get_range(self, endpoint_name): + """Returns a Range based on the endpoint name""" + + url = self.build_url(self._endpoints.get(endpoint_name)) + response = self.session.get(url) + if not response: + return None + data = response.json() + return self.range_constructor(parent=self, **{self._cloud_data_key: data}) + +
+[docs] + def get_data_body_range(self): + """Gets the range object associated with the data body of the table""" + return self._get_range("data_body_range")
+ + +
+[docs] + def get_header_row_range(self): + """Gets the range object associated with the header row of the table""" + return self._get_range("header_row_range")
+ + +
+[docs] + def get_total_row_range(self): + """Gets the range object associated with the totals row of the table""" + return self._get_range("total_row_range")
+ + +
+[docs] + def get_range(self): + """Gets the range object associated with the entire table""" + return self._get_range("entire_range")
+ + +
+[docs] + def convert_to_range(self): + """Converts the table into a normal range of cells. All data is preserved.""" + return self._get_range("convert_to_range")
+ + +
+[docs] + def clear_filters(self): + """Clears all the filters currently applied on the table.""" + url = self.build_url(self._endpoints.get("clear_filters")) + return bool(self.session.post(url))
+ + +
+[docs] + def reapply_filters(self): + """Reapplies all the filters currently on the table.""" + url = self.build_url(self._endpoints.get("reapply_filters")) + return bool(self.session.post(url))
+ + +
+[docs] + def get_worksheet(self): + """Returns this table worksheet""" + url = self.build_url("") + q = self.q().select("name").expand("worksheet") + response = self.session.get(url, params=q.as_params()) + if not response: + return None + data = response.json() + + ws = data.get("worksheet") + if ws is None: + return None + return WorkSheet(parent=self.parent, **{self._cloud_data_key: ws})
+
+ + + +
+[docs] +class WorkSheet(ApiComponent): + """An Excel WorkSheet""" + + _endpoints = { + "get_tables": "/tables", + "get_table": "/tables/{id}", + "get_range": "/range", + "add_table": "/tables/add", + "get_used_range": "/usedRange(valuesOnly={})", + "get_cell": "/cell(row={row},column={column})", + "add_named_range": "/names/add", + "add_named_range_f": "/names/addFormulaLocal", + "get_named_range": "/names/{name}", + } + + table_constructor = Table + range_constructor = Range + named_range_constructor = NamedRange + +
+[docs] + def __init__(self, parent=None, session=None, **kwargs): + if parent and session: + raise ValueError("Need a parent or a session but not both") + + self.workbook = parent + self.session = parent.session if parent else session + + cloud_data = kwargs.get(self._cloud_data_key, {}) + + self.object_id = cloud_data.get("id", None) + + # Choose the main_resource passed in kwargs over parent main_resource + main_resource = kwargs.pop("main_resource", None) or ( + getattr(parent, "main_resource", None) if parent else None + ) + + # append the encoded worksheet path + main_resource = "{}/worksheets('{}')".format( + main_resource, quote(self.object_id) + ) + + super().__init__( + protocol=parent.protocol if parent else kwargs.get("protocol"), + main_resource=main_resource, + ) + + self.name = cloud_data.get("name", None) + self.position = cloud_data.get("position", None) + self.visibility = cloud_data.get("visibility", None)
+ + + def __str__(self): + return self.__repr__() + + def __repr__(self): + return "Worksheet: {}".format(self.name) + + def __eq__(self, other): + return self.object_id == other.object_id + +
+[docs] + def delete(self): + """Deletes this worksheet""" + return bool(self.session.delete(self.build_url("")))
+ + +
+[docs] + def update(self, *, name=None, position=None, visibility=None): + """Changes the name, position or visibility of this worksheet""" + + if name is None and position is None and visibility is None: + raise ValueError("Provide at least one parameter to update") + data = {} + if name: + data["name"] = name + if position: + data["position"] = position + if visibility: + data["visibility"] = visibility + + response = self.session.patch(self.build_url(""), data=data) + if not response: + return False + + data = response.json() + self.name = data.get("name", self.name) + self.position = data.get("position", self.position) + self.visibility = data.get("visibility", self.visibility) + + return True
+ + +
+[docs] + def get_tables(self): + """Returns a collection of this worksheet tables""" + + url = self.build_url(self._endpoints.get("get_tables")) + response = self.session.get(url) + + if not response: + return [] + + data = response.json() + + return [ + self.table_constructor(parent=self, **{self._cloud_data_key: table}) + for table in data.get("value", []) + ]
+ + +
+[docs] + def get_table(self, id_or_name): + """ + Retrieves a Table by id or name + :param str id_or_name: The id or name of the column + :return: a Table instance + """ + url = self.build_url(self._endpoints.get("get_table").format(id=id_or_name)) + response = self.session.get(url) + if not response: + return None + return self.table_constructor( + parent=self, **{self._cloud_data_key: response.json()} + )
+ + +
+[docs] + def add_table(self, address, has_headers): + """ + Adds a table to this worksheet + :param str address: a range address eg: 'A1:D4' + :param bool has_headers: if the range address includes headers or not + :return: a Table instance + """ + if address is None: + return None + params = {"address": address, "hasHeaders": has_headers} + url = self.build_url(self._endpoints.get("add_table")) + response = self.session.post(url, data=params) + if not response: + return None + return self.table_constructor( + parent=self, **{self._cloud_data_key: response.json()} + )
+ + +
+[docs] + def get_range(self, address=None): + """ + Returns a Range instance from whitin this worksheet + :param str address: Optional, the range address you want + :return: a Range instance + """ + url = self.build_url(self._endpoints.get("get_range")) + if address is not None: + address = self.remove_sheet_name_from_address(address) + url = "{}(address='{}')".format(url, address) + response = self.session.get(url) + if not response: + return None + return self.range_constructor( + parent=self, **{self._cloud_data_key: response.json()} + )
+ + +
+[docs] + def get_used_range(self, only_values=True): + """Returns the smallest range that encompasses any cells that + have a value or formatting assigned to them. + + :param bool only_values: Optional. Defaults to True. + Considers only cells with values as used cells (ignores formatting). + :return: Range + """ + # Format the "only_values" parameter as a lowercase string to work properly with the Graph API + url = self.build_url( + self._endpoints.get("get_used_range").format(str(only_values).lower()) + ) + response = self.session.get(url) + if not response: + return None + return self.range_constructor( + parent=self, **{self._cloud_data_key: response.json()} + )
+ + +
+[docs] + def get_cell(self, row, column): + """Gets the range object containing the single cell based on row and column numbers.""" + url = self.build_url( + self._endpoints.get("get_cell").format(row=row, column=column) + ) + response = self.session.get(url) + if not response: + return None + return self.range_constructor( + parent=self, **{self._cloud_data_key: response.json()} + )
+ + +
+[docs] + def add_named_range(self, name, reference, comment="", is_formula=False): + """ + Adds a new name to the collection of the given scope using the user's locale for the formula + :param str name: the name of this range + :param str reference: the reference for this range or formula + :param str comment: a comment to describe this named range + :param bool is_formula: True if the reference is a formula + :return: NamedRange instance + """ + if is_formula: + url = self.build_url(self._endpoints.get("add_named_range_f")) + else: + url = self.build_url(self._endpoints.get("add_named_range")) + params = {"name": name, "reference": reference, "comment": comment} + response = self.session.post(url, data=params) + if not response: + return None + return self.named_range_constructor( + parent=self, **{self._cloud_data_key: response.json()} + )
+ + +
+[docs] + def get_named_range(self, name): + """Retrieves a Named range by it's name""" + url = self.build_url(self._endpoints.get("get_named_range").format(name=name)) + response = self.session.get(url) + if not response: + return None + return self.named_range_constructor( + parent=self, **{self._cloud_data_key: response.json()} + )
+ + +
+[docs] + @staticmethod + def remove_sheet_name_from_address(address): + """Removes the sheet name from a given address""" + compiled = re.compile("([a-zA-Z]+[0-9]+):.*?([a-zA-Z]+[0-9]+)") + result = compiled.search(address) + if result: + return ":".join(result.groups()) + else: + return address
+
+ + + +
+[docs] +class WorkbookApplication(ApiComponent): + _endpoints = { + "get_details": "/application", + "post_calculation": "/application/calculate", + } + +
+[docs] + def __init__(self, workbook): + """ + Create A WorkbookApplication representation + + :param workbook: A workbook object, of the workboook that you want to interact with + """ + + if not isinstance(workbook, WorkBook): + raise ValueError("workbook was not an accepted type: Workbook") + + self.parent = workbook # Not really needed currently, but saving in case we need it for future functionality + self.con = workbook.session.con + main_resource = getattr(workbook, "main_resource", None) + + super().__init__(protocol=workbook.protocol, main_resource=main_resource)
+ + + def __str__(self): + return self.__repr__() + + def __repr__(self): + return "WorkbookApplication for Workbook: {}".format( + self.workbook_id or "Not set" + ) + + def __bool__(self): + return bool(self.parent) + +
+[docs] + def get_details(self): + """Gets workbookApplication""" + url = self.build_url(self._endpoints.get("get_details")) + response = self.con.get(url) + + if not response: + return None + return response.json()
+ + +
+[docs] + def run_calculations(self, calculation_type): + if calculation_type not in ["Recalculate", "Full", "FullRebuild"]: + raise ValueError( + "calculation type must be one of: Recalculate, Full, FullRebuild" + ) + + url = self.build_url(self._endpoints.get("post_calculation")) + data = {"calculationType": calculation_type} + headers = {"Content-type": "application/json"} + + if self.parent.session.session_id: + headers["workbook-session-id"] = self.parent.session.session_id + + response = self.con.post(url, headers=headers, data=data) + if not response: + return False + + return response.ok
+
+ + + +
+[docs] +class WorkBook(ApiComponent): + _endpoints = { + "get_worksheets": "/worksheets", + "get_tables": "/tables", + "get_table": "/tables/{id}", + "get_worksheet": "/worksheets/{id}", + "function": "/functions/{name}", + "get_names": "/names", + "get_named_range": "/names/{name}", + "add_named_range": "/names/add", + "add_named_range_f": "/names/addFormulaLocal", + } + + application_constructor = WorkbookApplication + worksheet_constructor = WorkSheet + table_constructor = Table + named_range_constructor = NamedRange + +
+[docs] + def __init__(self, file_item, *, use_session=True, persist=True): + """Create a workbook representation + + :param File file_item: the Drive File you want to interact with + :param Bool use_session: Whether or not to use a session to be more efficient + :param Bool persist: Whether or not to persist this info + """ + if ( + file_item is None + or not isinstance(file_item, File) + or file_item.mime_type != EXCEL_XLSX_MIME_TYPE + ): + raise ValueError("This file is not a valid Excel xlsx file.") + + if isinstance(file_item.protocol, MSOffice365Protocol): + raise ValueError( + "Excel capabilities are only allowed on the MSGraph protocol" + ) + + # append the workbook path + main_resource = "{}{}/workbook".format( + file_item.main_resource, + file_item._endpoints.get("item").format(id=file_item.object_id), + ) + + super().__init__(protocol=file_item.protocol, main_resource=main_resource) + + persist = persist if use_session is True else True + self.session = WorkbookSession( + parent=file_item, persist=persist, main_resource=main_resource + ) + + if use_session: + self.session.create_session() + + self.name = file_item.name + self.object_id = "Workbook:{}".format( + file_item.object_id + ) # Mangle the object id
+ + + def __str__(self): + return self.__repr__() + + def __repr__(self): + return "Workbook: {}".format(self.name) + + def __eq__(self, other): + return self.object_id == other.object_id + +
+[docs] + def get_tables(self): + """Returns a collection of this workbook tables""" + + url = self.build_url(self._endpoints.get("get_tables")) + response = self.session.get(url) + + if not response: + return [] + + data = response.json() + + return [ + self.table_constructor(parent=self, **{self._cloud_data_key: table}) + for table in data.get("value", []) + ]
+ + +
+[docs] + def get_table(self, id_or_name): + """ + Retrieves a Table by id or name + :param str id_or_name: The id or name of the column + :return: a Table instance + """ + url = self.build_url(self._endpoints.get("get_table").format(id=id_or_name)) + response = self.session.get(url) + if not response: + return None + return self.table_constructor( + parent=self, **{self._cloud_data_key: response.json()} + )
+ + +
+[docs] + def get_workbookapplication(self): + return self.application_constructor(self)
+ + +
+[docs] + def get_worksheets(self): + """Returns a collection of this workbook worksheets""" + + url = self.build_url(self._endpoints.get("get_worksheets")) + response = self.session.get(url) + + if not response: + return [] + + data = response.json() + + return [ + self.worksheet_constructor(parent=self, **{self._cloud_data_key: ws}) + for ws in data.get("value", []) + ]
+ + +
+[docs] + def get_worksheet(self, id_or_name): + """Gets a specific worksheet by id or name""" + url = self.build_url( + self._endpoints.get("get_worksheet").format(id=quote(id_or_name)) + ) + response = self.session.get(url) + if not response: + return None + return self.worksheet_constructor( + parent=self, **{self._cloud_data_key: response.json()} + )
+ + +
+[docs] + def add_worksheet(self, name=None): + """Adds a new worksheet""" + url = self.build_url(self._endpoints.get("get_worksheets")) + response = self.session.post(url, data={"name": name} if name else None) + if not response: + return None + data = response.json() + return self.worksheet_constructor(parent=self, **{self._cloud_data_key: data})
+ + +
+[docs] + def delete_worksheet(self, worksheet_id): + """Deletes a worksheet by it's id""" + url = self.build_url( + self._endpoints.get("get_worksheet").format(id=quote(worksheet_id)) + ) + return bool(self.session.delete(url))
+ + +
+[docs] + def invoke_function(self, function_name, **function_params): + """Invokes an Excel Function""" + url = self.build_url(self._endpoints.get("function").format(name=function_name)) + response = self.session.post(url, data=function_params) + if not response: + return None + data = response.json() + + error = data.get("error") + if error is None: + return data.get("value") + else: + raise FunctionException(error)
+ + +
+[docs] + def get_named_ranges(self): + """Returns the list of named ranges for this Workbook""" + + url = self.build_url(self._endpoints.get("get_names")) + response = self.session.get(url) + if not response: + return [] + data = response.json() + return [ + self.named_range_constructor(parent=self, **{self._cloud_data_key: nr}) + for nr in data.get("value", []) + ]
+ + +
+[docs] + def get_named_range(self, name): + """Retrieves a Named range by it's name""" + url = self.build_url(self._endpoints.get("get_named_range").format(name=name)) + response = self.session.get(url) + if not response: + return None + return self.named_range_constructor( + parent=self, **{self._cloud_data_key: response.json()} + )
+ + +
+[docs] + def add_named_range(self, name, reference, comment="", is_formula=False): + """ + Adds a new name to the collection of the given scope using the user's locale for the formula + :param str name: the name of this range + :param str reference: the reference for this range or formula + :param str comment: a comment to describe this named range + :param bool is_formula: True if the reference is a formula + :return: NamedRange instance + """ + if is_formula: + url = self.build_url(self._endpoints.get("add_named_range_f")) + else: + url = self.build_url(self._endpoints.get("add_named_range")) + params = {"name": name, "reference": reference, "comment": comment} + response = self.session.post(url, data=params) + if not response: + return None + return self.named_range_constructor( + parent=self, **{self._cloud_data_key: response.json()} + )
+
+ +
+ +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/docs/latest/_modules/O365/groups.html b/docs/latest/_modules/O365/groups.html new file mode 100644 index 00000000..24a86043 --- /dev/null +++ b/docs/latest/_modules/O365/groups.html @@ -0,0 +1,384 @@ + + + + + + + + O365.groups — O365 documentation + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+ +
+
+
+
+ +

Source code for O365.groups

+import logging
+
+from dateutil.parser import parse
+from .utils import ApiComponent
+from .directory import User
+
+log = logging.getLogger(__name__)
+
+
+
+[docs] +class Group(ApiComponent): + """ A Microsoft O365 group """ + + _endpoints = { + 'get_group_owners': '/groups/{group_id}/owners', + 'get_group_members': '/groups/{group_id}/members', + } + + member_constructor = User + +
+[docs] + def __init__(self, *, parent=None, con=None, **kwargs): + """ A Microsoft O365 group + + :param parent: parent object + :type parent: Teams + :param Connection con: connection to use if no parent specified + :param Protocol protocol: protocol to use if no parent specified + (kwargs) + :param str main_resource: use this resource instead of parent resource + (kwargs) + """ + if parent and con: + raise ValueError('Need a parent or a connection but not both') + self.con = parent.con if parent else con + + cloud_data = kwargs.get(self._cloud_data_key, {}) + + self.object_id = cloud_data.get('id') + + # Choose the main_resource passed in kwargs over parent main_resource + main_resource = kwargs.pop('main_resource', None) or ( + getattr(parent, 'main_resource', None) if parent else None) + + main_resource = '{}{}'.format(main_resource, '') + + super().__init__( + protocol=parent.protocol if parent else kwargs.get('protocol'), + main_resource=main_resource) + + self.type = cloud_data.get('@odata.type') + self.display_name = cloud_data.get(self._cc('displayName'), '') + self.description = cloud_data.get(self._cc('description'), '') + self.mail = cloud_data.get(self._cc('mail'), '') + self.mail_nickname = cloud_data.get(self._cc('mailNickname'), '') + self.visibility = cloud_data.get(self._cc('visibility'), '')
+ + + def __str__(self): + return self.__repr__() + + def __repr__(self): + return 'Group: {}'.format(self.display_name) + + def __eq__(self, other): + return self.object_id == other.object_id + + def __hash__(self): + return self.object_id.__hash__() + +
+[docs] + def get_group_members(self, recursive=False): + """ Returns members of given group + :param bool recursive: drill down to users if group has other group as a member + :rtype: list[User] + """ + if recursive: + recursive_data = self._get_group_members_raw() + for member in recursive_data: + if member['@odata.type'] == '#microsoft.graph.group': + recursive_members = Groups(con=self.con, protocol=self.protocol).get_group_by_id(member['id'])._get_group_members_raw() + recursive_data.extend(recursive_members) + return [self.member_constructor(parent=self, **{self._cloud_data_key: lst}) for lst in recursive_data] + else: + return [self.member_constructor(parent=self, **{self._cloud_data_key: lst}) for lst in self._get_group_members_raw()]
+ + + def _get_group_members_raw(self): + url = self.build_url(self._endpoints.get('get_group_members').format(group_id=self.object_id)) + + response = self.con.get(url) + if not response: + return [] + + data = response.json() + return data.get('value', []) + +
+[docs] + def get_group_owners(self): + """ Returns owners of given group + + :rtype: list[User] + """ + url = self.build_url(self._endpoints.get('get_group_owners').format(group_id=self.object_id)) + + response = self.con.get(url) + if not response: + return [] + + data = response.json() + + return [self.member_constructor(parent=self, **{self._cloud_data_key: lst}) for lst in data.get('value', [])]
+
+ + + +
+[docs] +class Groups(ApiComponent): + """ A microsoft groups class + In order to use the API following permissions are required. + Delegated (work or school account) - Group.Read.All, Group.ReadWrite.All + """ + + _endpoints = { + 'get_user_groups': '/users/{user_id}/memberOf', + 'get_group_by_id': '/groups/{group_id}', + 'get_group_by_mail': '/groups/?$search="mail:{group_mail}"&$count=true', + 'list_groups': '/groups', + } + + group_constructor = Group + +
+[docs] + def __init__(self, *, parent=None, con=None, **kwargs): + """ A Teams object + + :param parent: parent object + :type parent: Account + :param Connection con: connection to use if no parent specified + :param Protocol protocol: protocol to use if no parent specified + (kwargs) + :param str main_resource: use this resource instead of parent resource + (kwargs) + """ + if parent and con: + raise ValueError('Need a parent or a connection but not both') + self.con = parent.con if parent else con + + # Choose the main_resource passed in kwargs over the host_name + main_resource = kwargs.pop('main_resource', + '') # defaults to blank resource + super().__init__( + protocol=parent.protocol if parent else kwargs.get('protocol'), + main_resource=main_resource)
+ + + def __str__(self): + return self.__repr__() + + def __repr__(self): + return 'Microsoft O365 Group parent class' + +
+[docs] + def get_group_by_id(self, group_id = None): + """ Returns Microsoft O365/AD group with given id + + :param group_id: group id of group + + :rtype: Group + """ + + if not group_id: + raise RuntimeError('Provide the group_id') + + if group_id: + # get channels by the team id + url = self.build_url( + self._endpoints.get('get_group_by_id').format(group_id=group_id)) + + response = self.con.get(url) + + if not response: + return None + + data = response.json() + + return self.group_constructor(parent=self, + **{self._cloud_data_key: data})
+ + +
+[docs] + def get_group_by_mail(self, group_mail = None): + """ Returns Microsoft O365/AD group by mail field + + :param group_name: mail of group + + :rtype: Group + """ + if not group_mail: + raise RuntimeError('Provide the group mail') + + if group_mail: + # get groups by filter mail + url = self.build_url( + self._endpoints.get('get_group_by_mail').format(group_mail=group_mail)) + + response = self.con.get(url, headers={'ConsistencyLevel': 'eventual'}) + + if not response: + return None + + data = response.json() + + if '@odata.count' in data and data['@odata.count'] < 1: + raise RuntimeError('Not found group with provided filters') + + # mail is unique field so, we expect exact match -> always use first element from list + return self.group_constructor(parent=self, + **{self._cloud_data_key: data.get('value')[0]})
+ + +
+[docs] + def get_user_groups(self, user_id = None): + """ Returns list of groups that given user has membership + + :param user_id: user_id + + :rtype: list[Group] + """ + + if not user_id: + raise RuntimeError('Provide the user_id') + + if user_id: + # get channels by the team id + url = self.build_url( + self._endpoints.get('get_user_groups').format(user_id=user_id)) + + response = self.con.get(url) + + if not response: + return None + + data = response.json() + + return [ + self.group_constructor(parent=self, **{self._cloud_data_key: group}) + for group in data.get('value', [])]
+ + +
+[docs] + def list_groups(self): + """ Returns list of groups + :rtype: list[Group] + """ + + url = self.build_url( + self._endpoints.get('list_groups')) + + response = self.con.get(url) + + if not response: + return None + + data = response.json() + + return [ + self.group_constructor(parent=self, **{self._cloud_data_key: group}) + for group in data.get('value', [])]
+
+ +
+ +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/docs/latest/_modules/O365/mailbox.html b/docs/latest/_modules/O365/mailbox.html new file mode 100644 index 00000000..281c8981 --- /dev/null +++ b/docs/latest/_modules/O365/mailbox.html @@ -0,0 +1,781 @@ + + + + + + + + + + + O365.mailbox — O365 documentation + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + +
+ +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+
+
+
+ +

Source code for O365.mailbox

+import datetime as dt
+import logging
+
+from .message import Message
+from .utils import Pagination, NEXT_LINK_KEYWORD, \
+    OutlookWellKnowFolderNames, ApiComponent
+
+log = logging.getLogger(__name__)
+
+
+
[docs]class Folder(ApiComponent): + """ A Mail Folder representation """ + + _endpoints = { + 'root_folders': '/mailFolders', + 'child_folders': '/mailFolders/{id}/childFolders', + 'get_folder': '/mailFolders/{id}', + 'root_messages': '/messages', + 'folder_messages': '/mailFolders/{id}/messages', + 'copy_folder': '/mailFolders/{id}/copy', + 'move_folder': '/mailFolders/{id}/move', + 'message': '/messages/{id}', + } + message_constructor = Message + +
[docs] def __init__(self, *, parent=None, con=None, **kwargs): + """ Create an instance to represent the specified folder un given + parent folder + + :param parent: parent folder/account for this folder + :type parent: mailbox.Folder or Account + :param Connection con: connection to use if no parent specified + :param Protocol protocol: protocol to use if no parent specified + (kwargs) + :param str main_resource: use this resource instead of parent resource + (kwargs) + :param str name: name of the folder to get under the parent (kwargs) + :param str folder_id: id of the folder to get under the parent (kwargs) + """ + if parent and con: + raise ValueError('Need a parent or a connection but not both') + self.con = parent.con if parent else con + self.parent = parent if isinstance(parent, Folder) else None + + # This folder has no parents if root = True. + self.root = kwargs.pop('root', False) + + # Choose the main_resource passed in kwargs over parent main_resource + main_resource = kwargs.pop('main_resource', None) or ( + getattr(parent, 'main_resource', None) if parent else None) + + super().__init__( + protocol=parent.protocol if parent else kwargs.get('protocol'), + main_resource=main_resource) + + cloud_data = kwargs.get(self._cloud_data_key, {}) + + # Fallback to manual folder if nothing available on cloud data + self.name = cloud_data.get(self._cc('displayName'), + kwargs.get('name', + '')) + if self.root is False: + # Fallback to manual folder if nothing available on cloud data + self.folder_id = cloud_data.get(self._cc('id'), + kwargs.get('folder_id', + None)) + self.parent_id = cloud_data.get(self._cc('parentFolderId'), None) + self.child_folders_count = cloud_data.get( + self._cc('childFolderCount'), 0) + self.unread_items_count = cloud_data.get( + self._cc('unreadItemCount'), 0) + self.total_items_count = cloud_data.get(self._cc('totalItemCount'), + 0) + self.updated_at = dt.datetime.now() + else: + self.folder_id = 'root'
+ + def __str__(self): + return self.__repr__() + + def __repr__(self): + return '{} from resource: {}'.format(self.name, self.main_resource) + + def __eq__(self, other): + return self.folder_id == other.folder_id + +
[docs] def get_folders(self, limit=None, *, query=None, order_by=None, batch=None): + """ Returns a list of child folders matching the query + + :param int limit: max no. of folders to get. Over 999 uses batch. + :param query: applies a filter to the request such as + "displayName eq 'HelloFolder'" + :type query: Query or str + :param order_by: orders the result set based on this condition + :type order_by: Query or str + :param int batch: batch size, retrieves items in + batches allowing to retrieve more items than the limit. + :return: list of folders + :rtype: list[mailbox.Folder] or Pagination + """ + + if self.root: + url = self.build_url(self._endpoints.get('root_folders')) + else: + url = self.build_url( + self._endpoints.get('child_folders').format(id=self.folder_id)) + + if limit is None or limit > self.protocol.max_top_value: + batch = self.protocol.max_top_value + + params = {'$top': batch if batch else limit} + + if order_by: + params['$orderby'] = order_by + + if query: + if isinstance(query, str): + params['$filter'] = query + else: + params.update(query.as_params()) + + response = self.con.get(url, params=params) + if not response: + return [] + + data = response.json() + + # Everything received from cloud must be passed as self._cloud_data_key + self_class = getattr(self, 'folder_constructor', type(self)) + folders = [self_class(parent=self, **{self._cloud_data_key: folder}) for + folder in data.get('value', [])] + next_link = data.get(NEXT_LINK_KEYWORD, None) + if batch and next_link: + return Pagination(parent=self, data=folders, constructor=self_class, + next_link=next_link, limit=limit) + else: + return folders
+ +
[docs] def get_message(self, object_id=None, query=None, *, download_attachments=False): + """ Get one message from the query result. + A shortcut to get_messages with limit=1 + :param object_id: the message id to be retrieved. + :param query: applies a filter to the request such as + "displayName eq 'HelloFolder'" + :type query: Query or str + :param bool download_attachments: whether or not to download attachments + :return: one Message + :rtype: Message or None + """ + if object_id is None and query is None: + raise ValueError('Must provide object id or query.') + + if object_id is not None: + url = self.build_url(self._endpoints.get('message').format(id=object_id)) + params = None + if query and (query.has_selects or query.has_expands): + params = query.as_params() + response = self.con.get(url, params=params) + if not response: + return None + + message = response.json() + + return self.message_constructor(parent=self, + download_attachments=download_attachments, + **{self._cloud_data_key: message}) + + else: + messages = list(self.get_messages(limit=1, query=query, + download_attachments=download_attachments)) + + return messages[0] if messages else None
+ +
[docs] def get_messages(self, limit=25, *, query=None, order_by=None, batch=None, + download_attachments=False): + """ + Downloads messages from this folder + + :param int limit: limits the result set. Over 999 uses batch. + :param query: applies a filter to the request such as + "displayName eq 'HelloFolder'" + :type query: Query or str + :param order_by: orders the result set based on this condition + :type order_by: Query or str + :param int batch: batch size, retrieves items in + batches allowing to retrieve more items than the limit. + :param bool download_attachments: whether or not to download attachments + :return: list of messages + :rtype: list[Message] or Pagination + """ + + if self.root: + url = self.build_url(self._endpoints.get('root_messages')) + else: + url = self.build_url(self._endpoints.get('folder_messages').format( + id=self.folder_id)) + + if limit is None or limit > self.protocol.max_top_value: + batch = self.protocol.max_top_value + + params = {'$top': batch if batch else limit} + + if order_by: + params['$orderby'] = order_by + + if query: + if isinstance(query, str): + params['$filter'] = query + else: + params.update(query.as_params()) + + response = self.con.get(url, params=params) + if not response: + return iter(()) + + data = response.json() + + # Everything received from cloud must be passed as self._cloud_data_key + messages = (self.message_constructor( + parent=self, + download_attachments=download_attachments, + **{self._cloud_data_key: message}) + for message in data.get('value', [])) + + next_link = data.get(NEXT_LINK_KEYWORD, None) + if batch and next_link: + return Pagination(parent=self, data=messages, + constructor=self.message_constructor, + next_link=next_link, limit=limit, + download_attachments=download_attachments) + else: + return messages
+ +
[docs] def create_child_folder(self, folder_name): + """ Creates a new child folder under this folder + + :param str folder_name: name of the folder to add + :return: newly created folder + :rtype: mailbox.Folder or None + """ + if not folder_name: + return None + + if self.root: + url = self.build_url(self._endpoints.get('root_folders')) + else: + url = self.build_url( + self._endpoints.get('child_folders').format(id=self.folder_id)) + + response = self.con.post(url, + data={self._cc('displayName'): folder_name}) + if not response: + return None + + folder = response.json() + + self_class = getattr(self, 'folder_constructor', type(self)) + # Everything received from cloud must be passed as self._cloud_data_key + return self_class(parent=self, **{self._cloud_data_key: folder})
+ +
[docs] def get_folder(self, *, folder_id=None, folder_name=None): + """ Get a folder by it's id or name + + :param str folder_id: the folder_id to be retrieved. + Can be any folder Id (child or not) + :param str folder_name: the folder name to be retrieved. + Must be a child of this folder. + :return: a single folder + :rtype: mailbox.Folder or None + """ + if folder_id and folder_name: + raise RuntimeError('Provide only one of the options') + + if not folder_id and not folder_name: + raise RuntimeError('Provide one of the options') + + if folder_id: + # get folder by it's id, independent of the parent of this folder_id + url = self.build_url( + self._endpoints.get('get_folder').format(id=folder_id)) + params = None + else: + # get folder by name. Only looks up in child folders. + if self.root: + url = self.build_url(self._endpoints.get('root_folders')) + else: + url = self.build_url( + self._endpoints.get('child_folders').format( + id=self.folder_id)) + params = {'$filter': "{} eq '{}'".format(self._cc('displayName'), + folder_name), '$top': 1} + + response = self.con.get(url, params=params) + if not response: + return None + + if folder_id: + folder = response.json() + else: + folder = response.json().get('value') + folder = folder[0] if folder else None + if folder is None: + return None + + self_class = getattr(self, 'folder_constructor', type(self)) + # Everything received from cloud must be passed as self._cloud_data_key + # We don't pass parent, as this folder may not be a child of self. + return self_class(con=self.con, protocol=self.protocol, + main_resource=self.main_resource, + **{self._cloud_data_key: folder})
+ +
[docs] def refresh_folder(self, update_parent_if_changed=False): + """ Re-download folder data + Inbox Folder will be unable to download its own data (no folder_id) + + :param bool update_parent_if_changed: updates self.parent with new + parent Folder if changed + :return: Refreshed or Not + :rtype: bool + """ + folder_id = getattr(self, 'folder_id', None) + if self.root or folder_id is None: + return False + + folder = self.get_folder(folder_id=folder_id) + if folder is None: + return False + + self.name = folder.name + if folder.parent_id and self.parent_id: + if folder.parent_id != self.parent_id: + self.parent_id = folder.parent_id + self.parent = (self.get_parent_folder() + if update_parent_if_changed else None) + self.child_folders_count = folder.child_folders_count + self.unread_items_count = folder.unread_items_count + self.total_items_count = folder.total_items_count + self.updated_at = folder.updated_at + + return True
+ +
[docs] def get_parent_folder(self): + """ Get the parent folder from attribute self.parent or + getting it from the cloud + + :return: Parent Folder + :rtype: mailbox.Folder or None + """ + if self.root: + return None + if self.parent: + return self.parent + + if self.parent_id: + self.parent = self.get_folder(folder_id=self.parent_id) + return self.parent
+ +
[docs] def update_folder_name(self, name, update_folder_data=True): + """ Change this folder name + + :param str name: new name to change to + :param bool update_folder_data: whether or not to re-fetch the data + :return: Updated or Not + :rtype: bool + """ + if self.root: + return False + if not name: + return False + + url = self.build_url( + self._endpoints.get('get_folder').format(id=self.folder_id)) + + response = self.con.patch(url, data={self._cc('displayName'): name}) + if not response: + return False + + self.name = name + if not update_folder_data: + return True + + folder = response.json() + + self.name = folder.get(self._cc('displayName'), '') + self.parent_id = folder.get(self._cc('parentFolderId'), None) + self.child_folders_count = folder.get(self._cc('childFolderCount'), 0) + self.unread_items_count = folder.get(self._cc('unreadItemCount'), 0) + self.total_items_count = folder.get(self._cc('totalItemCount'), 0) + self.updated_at = dt.datetime.now() + + return True
+ +
[docs] def delete(self): + """ Deletes this folder + + :return: Deleted or Not + :rtype: bool + """ + + if self.root or not self.folder_id: + return False + + url = self.build_url( + self._endpoints.get('get_folder').format(id=self.folder_id)) + + response = self.con.delete(url) + if not response: + return False + + self.folder_id = None + return True
+ +
[docs] def copy_folder(self, to_folder): + """ Copy this folder and it's contents to into another folder + + :param to_folder: the destination Folder/folder_id to copy into + :type to_folder: mailbox.Folder or str + :return: The new folder after copying + :rtype: mailbox.Folder or None + """ + to_folder_id = to_folder.folder_id if isinstance(to_folder, + Folder) else to_folder + + if self.root or not self.folder_id or not to_folder_id: + return None + + url = self.build_url( + self._endpoints.get('copy_folder').format(id=self.folder_id)) + + response = self.con.post(url, + data={self._cc('destinationId'): to_folder_id}) + if not response: + return None + + folder = response.json() + + self_class = getattr(self, 'folder_constructor', type(self)) + # Everything received from cloud must be passed as self._cloud_data_key + return self_class(con=self.con, main_resource=self.main_resource, + **{self._cloud_data_key: folder})
+ +
[docs] def move_folder(self, to_folder, *, update_parent_if_changed=True): + """ Move this folder to another folder + + :param to_folder: the destination Folder/folder_id to move into + :type to_folder: mailbox.Folder or str + :param bool update_parent_if_changed: updates self.parent with the + new parent Folder if changed + :return: The new folder after copying + :rtype: mailbox.Folder or None + """ + to_folder_id = to_folder.folder_id if isinstance(to_folder, + Folder) else to_folder + + if self.root or not self.folder_id or not to_folder_id: + return False + + url = self.build_url( + self._endpoints.get('move_folder').format(id=self.folder_id)) + + response = self.con.post(url, + data={self._cc('destinationId'): to_folder_id}) + if not response: + return False + + folder = response.json() + + parent_id = folder.get(self._cc('parentFolderId'), None) + + if parent_id and self.parent_id: + if parent_id != self.parent_id: + self.parent_id = parent_id + self.parent = (self.get_parent_folder() + if update_parent_if_changed else None) + + return True
+ +
[docs] def new_message(self): + """ Creates a new draft message under this folder + + :return: new Message + :rtype: Message + """ + + draft_message = self.message_constructor(parent=self, is_draft=True) + + if self.root: + draft_message.folder_id = OutlookWellKnowFolderNames.DRAFTS.value + else: + draft_message.folder_id = self.folder_id + + return draft_message
+ +
[docs] def delete_message(self, message): + """ Deletes a stored message + + :param message: message/message_id to delete + :type message: Message or str + :return: Success / Failure + :rtype: bool + """ + + message_id = message.object_id if isinstance(message, + Message) else message + + if message_id is None: + raise RuntimeError('Provide a valid Message or a message id') + + url = self.build_url( + self._endpoints.get('message').format(id=message_id)) + + response = self.con.delete(url) + + return bool(response)
+ + +
[docs]class MailBox(Folder): + folder_constructor = Folder + +
[docs] def __init__(self, *, parent=None, con=None, **kwargs): + super().__init__(parent=parent, con=con, root=True, **kwargs)
+ +
[docs] def inbox_folder(self): + """ Shortcut to get Inbox Folder instance + + :rtype: mailbox.Folder + """ + return self.folder_constructor(parent=self, name='Inbox', + folder_id=OutlookWellKnowFolderNames + .INBOX.value)
+ +
[docs] def junk_folder(self): + """ Shortcut to get Junk Folder instance + + :rtype: mailbox.Folder + """ + return self.folder_constructor(parent=self, name='Junk', + folder_id=OutlookWellKnowFolderNames + .JUNK.value)
+ +
[docs] def deleted_folder(self): + """ Shortcut to get DeletedItems Folder instance + + :rtype: mailbox.Folder + """ + return self.folder_constructor(parent=self, name='DeletedItems', + folder_id=OutlookWellKnowFolderNames + .DELETED.value)
+ +
[docs] def drafts_folder(self): + """ Shortcut to get Drafts Folder instance + + :rtype: mailbox.Folder + """ + return self.folder_constructor(parent=self, name='Drafts', + folder_id=OutlookWellKnowFolderNames + .DRAFTS.value)
+ +
[docs] def sent_folder(self): + """ Shortcut to get SentItems Folder instance + + :rtype: mailbox.Folder + """ + return self.folder_constructor(parent=self, name='SentItems', + folder_id=OutlookWellKnowFolderNames + .SENT.value)
+ +
[docs] def outbox_folder(self): + """ Shortcut to get Outbox Folder instance + + :rtype: mailbox.Folder + """ + return self.folder_constructor(parent=self, name='Outbox', + folder_id=OutlookWellKnowFolderNames + .OUTBOX.value)
+ +
[docs] def archive_folder(self): + """ Shortcut to get Archive Folder instance + + :rtype: mailbox.Folder + """ + return self.folder_constructor(parent=self, name='Archive', + folder_id=OutlookWellKnowFolderNames + .ARCHIVE.value)
+
+ +
+ +
+ + +
+
+ +
+ +
+ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/O365/message.html b/docs/latest/_modules/O365/message.html new file mode 100644 index 00000000..cf5d3b93 --- /dev/null +++ b/docs/latest/_modules/O365/message.html @@ -0,0 +1,1278 @@ + + + + + + + + + + + O365.message — O365 documentation + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + +
+ +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+
+
+
+ +

Source code for O365.message

+import datetime as dt
+import logging
+from enum import Enum
+
+import pytz
+# noinspection PyPep8Naming
+from bs4 import BeautifulSoup as bs
+from dateutil.parser import parse
+from pathlib import Path
+
+from .utils import OutlookWellKnowFolderNames, ApiComponent, \
+    BaseAttachments, BaseAttachment, AttachableMixin, ImportanceLevel, \
+    TrackerSet, Recipient, HandleRecipientsMixin, CaseEnum
+from .calendar import Event
+from .category import Category
+
+log = logging.getLogger(__name__)
+
+
+
[docs]class RecipientType(Enum): + TO = 'to' + CC = 'cc' + BCC = 'bcc'
+ + +
[docs]class MeetingMessageType(CaseEnum): + MeetingRequest = 'meetingRequest' + MeetingCancelled = 'meetingCancelled' + MeetingAccepted = 'meetingAccepted' + MeetingTentativelyAccepted = 'meetingTentativelyAccepted' + MeetingDeclined = 'meetingDeclined'
+ + +
[docs]class Flag(CaseEnum): + NotFlagged = 'notFlagged' + Complete = 'complete' + Flagged = 'flagged'
+ + +
[docs]class MessageAttachment(BaseAttachment): + _endpoints = { + 'attach': '/messages/{id}/attachments', + 'attachment': '/messages/{id}/attachments/{ida}', + }
+ + +
[docs]class MessageAttachments(BaseAttachments): + _endpoints = { + 'attachments': '/messages/{id}/attachments', + 'attachment': '/messages/{id}/attachments/{ida}', + 'get_mime': '/messages/{id}/attachments/{ida}/$value', + } + _attachment_constructor = MessageAttachment + +
[docs] def save_as_eml(self, attachment, to_path=None): + """ Saves this message as and EML to the file system + :param MessageAttachment attachment: the MessageAttachment to store as eml. + :param Path or str to_path: the path where to store this file + """ + if not attachment or not isinstance(attachment, MessageAttachment) \ + or attachment.attachment_id is None or attachment.attachment_type != 'item': + raise ValueError('Must provide a saved "item" attachment of type MessageAttachment') + + if to_path is None: + to_path = Path('message_eml.eml') + else: + if not isinstance(to_path, Path): + to_path = Path(to_path) + + if not to_path.suffix: + to_path = to_path.with_suffix('.eml') + + msg_id = self._parent.object_id + if msg_id is None: + raise RuntimeError('Attempting to get the mime contents of an unsaved message') + + url = self.build_url(self._endpoints.get('get_mime').format(id=msg_id, ida=attachment.attachment_id)) + + response = self._parent.con.get(url) + + if not response: + return False + + mime_content = response.content + + if mime_content: + with to_path.open('wb') as file_obj: + file_obj.write(mime_content) + return True + return False
+ + +
[docs]class MessageFlag(ApiComponent): + """ A flag on a message """ + +
[docs] def __init__(self, parent, flag_data): + """ An flag on a message + Not available on Outlook Rest Api v2 (only in beta) + + :param parent: parent of this + :type parent: Message + :param dict flag_data: flag data from cloud + """ + super().__init__(protocol=parent.protocol, + main_resource=parent.main_resource) + + self.__message = parent + + self.__status = Flag.from_value(flag_data.get(self._cc('flagStatus'), 'notFlagged')) + + start_obj = flag_data.get(self._cc('startDateTime'), {}) + self.__start = self._parse_date_time_time_zone(start_obj) + + due_date_obj = flag_data.get(self._cc('dueDateTime'), {}) + self.__due_date = self._parse_date_time_time_zone(due_date_obj) + + completed_date_obj = flag_data.get(self._cc('completedDateTime'), {}) + self.__completed = self._parse_date_time_time_zone(completed_date_obj)
+ + def __repr__(self): + return str(self.__status) + + def __str__(self): + return self.__repr__() + + def __bool__(self): + return self.is_flagged + + def _track_changes(self): + """ Update the track_changes on the message to reflect a + needed update on this field """ + self.__message._track_changes.add('flag') + + @property + def status(self): + return self.__status + +
[docs] def set_flagged(self, *, start_date=None, due_date=None): + """ Sets this message as flagged + :param start_date: the start datetime of the followUp + :param due_date: the due datetime of the followUp + """ + self.__status = Flag.Flagged + start_date = start_date or dt.datetime.now() + due_date = due_date or dt.datetime.now() + if start_date.tzinfo is None: + start_date = self.protocol.timezone.localize(start_date) + if due_date.tzinfo is None: + due_date = self.protocol.timezone.localize(due_date) + self.__start = start_date + self.__due_date = due_date + self._track_changes()
+ +
[docs] def set_completed(self, *, completition_date=None): + """ Sets this message flag as completed + :param completition_date: the datetime this followUp was completed + """ + self.__status = Flag.Complete + completition_date = completition_date or dt.datetime.now() + if completition_date.tzinfo is None: + completition_date = self.protocol.timezone.localize(completition_date) + self.__completed = completition_date + self._track_changes()
+ +
[docs] def delete_flag(self): + """ Sets this message as un flagged """ + self.__status = Flag.NotFlagged + self.__start = None + self.__due_date = None + self.__completed = None + self._track_changes()
+ + @property + def start_date(self): + return self.__start + + @property + def due_date(self): + return self.__due_date + + @property + def completition_date(self): + return self.__completed + + @property + def is_completed(self): + return self.__status is Flag.Complete + + @property + def is_flagged(self): + return self.__status is Flag.Flagged or self.__status is Flag.Complete + +
[docs] def to_api_data(self): + """ Returns this data as a dict to be sent to the server """ + data = { + self._cc('flagStatus'): self._cc(self.__status.value) + } + if self.__status is Flag.Flagged: + data[self._cc('startDateTime')] = self._build_date_time_time_zone(self.__start) if self.__start is not None else None + data[self._cc('dueDateTime')] = self._build_date_time_time_zone(self.__due_date) if self.__due_date is not None else None + + if self.__status is Flag.Complete: + data[self._cc('completedDateTime')] = self._build_date_time_time_zone(self.__completed) + + return data
+ + +
[docs]class Message(ApiComponent, AttachableMixin, HandleRecipientsMixin): + """ Management of the process of sending, receiving, reading, and + editing emails. """ + + _endpoints = { + 'create_draft': '/messages', + 'create_draft_folder': '/mailFolders/{id}/messages', + 'send_mail': '/sendMail', + 'send_draft': '/messages/{id}/send', + 'get_message': '/messages/{id}', + 'move_message': '/messages/{id}/move', + 'copy_message': '/messages/{id}/copy', + 'create_reply': '/messages/{id}/createReply', + 'create_reply_all': '/messages/{id}/createReplyAll', + 'forward_message': '/messages/{id}/createForward', + 'get_mime': '/messages/{id}/$value', + } + +
[docs] def __init__(self, *, parent=None, con=None, **kwargs): + """ Makes a new message wrapper for sending and receiving messages. + + :param parent: parent folder/account to create the message in + :type parent: mailbox.Folder or Account + :param Connection con: connection to use if no parent specified + :param Protocol protocol: protocol to use if no parent specified + (kwargs) + :param str main_resource: use this resource instead of parent resource + (kwargs) + :param bool download_attachments: whether or not to + download attachments (kwargs) + """ + if parent and con: + raise ValueError('Need a parent or a connection but not both') + self.con = parent.con if parent else con + + # Choose the main_resource passed in kwargs over parent main_resource + main_resource = kwargs.pop('main_resource', None) or ( + getattr(parent, 'main_resource', None) if parent else None) + + super().__init__( + protocol=parent.protocol if parent else kwargs.get('protocol'), + main_resource=main_resource, + attachment_name_property='subject', attachment_type='message_type') + + download_attachments = kwargs.get('download_attachments') + + cloud_data = kwargs.get(self._cloud_data_key, {}) + cc = self._cc # alias to shorten the code + + # internal to know which properties need to be updated on the server + self._track_changes = TrackerSet(casing=cc) + self.object_id = cloud_data.get(cc('id'), kwargs.get('object_id', None)) + + self.__created = cloud_data.get(cc('createdDateTime'), None) + self.__modified = cloud_data.get(cc('lastModifiedDateTime'), None) + self.__received = cloud_data.get(cc('receivedDateTime'), None) + self.__sent = cloud_data.get(cc('sentDateTime'), None) + + local_tz = self.protocol.timezone + self.__created = parse(self.__created).astimezone( + local_tz) if self.__created else None + self.__modified = parse(self.__modified).astimezone( + local_tz) if self.__modified else None + self.__received = parse(self.__received).astimezone( + local_tz) if self.__received else None + self.__sent = parse(self.__sent).astimezone( + local_tz) if self.__sent else None + + self.__attachments = MessageAttachments(parent=self, attachments=[]) + self.__attachments.add({self._cloud_data_key: cloud_data.get(cc('attachments'), [])}) + self.has_attachments = cloud_data.get(cc('hasAttachments'), False) + self.__subject = cloud_data.get(cc('subject'), '') + self.__body_preview = cloud_data.get(cc('bodyPreview'), '') + body = cloud_data.get(cc('body'), {}) + self.__body = body.get(cc('content'), '') + self.body_type = body.get(cc('contentType'), 'HTML') # default to HTML for new messages + + unique_body = cloud_data.get(cc('uniqueBody'), {}) + self.__unique_body = unique_body.get(cc('content'), '') + self.unique_body_type = unique_body.get(cc('contentType'), 'HTML') # default to HTML for new messages + + if self.has_attachments is False and self.body_type.upper() == 'HTML': + # test for inline attachments (Azure responds with hasAttachments=False when there are only inline attachments): + if any(img.get('src', '').startswith('cid:') for img in self.get_body_soup().find_all('img')): + self.has_attachments = True + + if self.has_attachments and download_attachments: + self.attachments.download_attachments() + + self.__sender = self._recipient_from_cloud( + cloud_data.get(cc('from'), None), field=cc('from')) + self.__to = self._recipients_from_cloud( + cloud_data.get(cc('toRecipients'), []), field=cc('toRecipients')) + self.__cc = self._recipients_from_cloud( + cloud_data.get(cc('ccRecipients'), []), field=cc('ccRecipients')) + self.__bcc = self._recipients_from_cloud( + cloud_data.get(cc('bccRecipients'), []), field=cc('bccRecipients')) + self.__reply_to = self._recipients_from_cloud( + cloud_data.get(cc('replyTo'), []), field=cc('replyTo')) + self.__categories = cloud_data.get(cc('categories'), []) + + self.__importance = ImportanceLevel.from_value(cloud_data.get(cc('importance'), 'normal') or 'normal') + self.__is_read = cloud_data.get(cc('isRead'), None) + + self.__is_read_receipt_requested = cloud_data.get(cc('isReadReceiptRequested'), False) + self.__is_delivery_receipt_requested = cloud_data.get(cc('isDeliveryReceiptRequested'), False) + + # if this message is an EventMessage: + meeting_mt = cloud_data.get(cc('meetingMessageType'), 'none') + + # hack to avoid typo in EventMessage between Api v1.0 and beta: + meeting_mt = meeting_mt.replace('Tenatively', 'Tentatively') + + self.__meeting_message_type = MeetingMessageType.from_value(meeting_mt) if meeting_mt != 'none' else None + + # a message is a draft by default + self.__is_draft = cloud_data.get(cc('isDraft'), kwargs.get('is_draft', + True)) + self.conversation_id = cloud_data.get(cc('conversationId'), None) + self.conversation_index = cloud_data.get(cc('conversationIndex'), None) + self.folder_id = cloud_data.get(cc('parentFolderId'), None) + + flag_data = cloud_data.get(cc('flag'), {}) + self.__flag = MessageFlag(parent=self, flag_data=flag_data) + + self.internet_message_id = cloud_data.get(cc('internetMessageId'), '') + self.web_link = cloud_data.get(cc('webLink'), '') + + # Headers only retrieved when selecting 'internetMessageHeaders' + self.message_headers = cloud_data.get(cc('internetMessageHeaders'), [])
+ + def __str__(self): + return self.__repr__() + + def __repr__(self): + return 'Subject: {}'.format(self.subject) + + def __eq__(self, other): + return self.object_id == other.object_id + + @property + def is_read(self): + """ Check if the message is read or not + + :getter: Get the status of message read + :setter: Mark the message as read + :type: bool + """ + return self.__is_read + + @is_read.setter + def is_read(self, value): + self.__is_read = value + self._track_changes.add('isRead') + + @property + def is_draft(self): + """ Check if the message is marked as draft + + :type: bool + """ + return self.__is_draft + + @property + def subject(self): + """ Subject of the email message + + :getter: Get the current subject + :setter: Assign a new subject + :type: str + """ + return self.__subject + + @subject.setter + def subject(self, value): + self.__subject = value + self._track_changes.add('subject') + + @property + def body_preview(self): + """ Returns the body preview """ + return self.__body_preview + + @property + def body(self): + """ Body of the email message + + :getter: Get body text of current message + :setter: set html body of the message + :type: str + """ + return self.__body + + @body.setter + def body(self, value): + if self.__body: + if not value: + self.__body = '' + elif self.body_type == 'html': + soup = bs(self.__body, 'html.parser') + soup.body.insert(0, bs(value, 'html.parser')) + self.__body = str(soup) + else: + self.__body = ''.join((value, '\n', self.__body)) + else: + self.__body = value + self._track_changes.add('body') + + @property + def unique_body(self): + """ The unique body of this message + Requires a select to retrieve it. + :rtype: str + """ + return self.__unique_body + + @property + def created(self): + """ Created time of the message """ + return self.__created + + @property + def modified(self): + """ Message last modified time """ + return self.__modified + + @property + def received(self): + """ Message received time""" + return self.__received + + @property + def sent(self): + """ Message sent time""" + return self.__sent + + @property + def attachments(self): + """ List of attachments """ + return self.__attachments + + @property + def sender(self): + """ Sender of the message + + :getter: Get the current sender + :setter: Update the from address with new value + :type: str or Recipient + """ + return self.__sender + + @sender.setter + def sender(self, value): + """ sender is a property to force to be always a Recipient class """ + if isinstance(value, Recipient): + if value._parent is None: + value._parent = self + value._field = 'from' + self.__sender = value + elif isinstance(value, str): + self.__sender.address = value + self.__sender.name = '' + else: + raise ValueError( + 'sender must be an address string or a Recipient object') + self._track_changes.add('from') + + @property + def to(self): + """ 'TO' list of recipients """ + return self.__to + + @property + def cc(self): + """ 'CC' list of recipients """ + return self.__cc + + @property + def bcc(self): + """ 'BCC' list of recipients """ + return self.__bcc + + @property + def reply_to(self): + """ Reply to address """ + return self.__reply_to + + @property + def categories(self): + """ Categories of this message + + :getter: Current list of categories + :setter: Set new categories for the message + :type: list[str] or str + """ + return self.__categories + + @categories.setter + def categories(self, value): + if isinstance(value, list): + self.__categories = [] + for val in value: + if isinstance(val, Category): + self.__categories.append(val.name) + else: + self.__categories.append(val) + elif isinstance(value, str): + self.__categories = [value] + elif isinstance(value, Category): + self.__categories = [value.name] + else: + raise ValueError('categories must be a list') + self._track_changes.add('categories') + +
[docs] def add_category(self, category): + """ Adds a category to this message current categories list """ + + if isinstance(category, Category): + self.__categories.append(category.name) + else: + self.__categories.append(category) + self._track_changes.add('categories')
+ + @property + def importance(self): + """ Importance of the message + + :getter: Get the current priority of the message + :setter: Set a different importance level + :type: str or ImportanceLevel + """ + return self.__importance + + @importance.setter + def importance(self, value): + self.__importance = (value if isinstance(value, ImportanceLevel) + else ImportanceLevel.from_value(value)) + self._track_changes.add('importance') + + @property + def is_read_receipt_requested(self): + """ if the read receipt is requested for this message + + :getter: Current state of isReadReceiptRequested + :setter: Set isReadReceiptRequested for the message + :type: bool + """ + return self.__is_read_receipt_requested + + @is_read_receipt_requested.setter + def is_read_receipt_requested(self, value): + self.__is_read_receipt_requested = bool(value) + self._track_changes.add('isReadReceiptRequested') + + @property + def is_delivery_receipt_requested(self): + """ if the delivery receipt is requested for this message + + :getter: Current state of isDeliveryReceiptRequested + :setter: Set isDeliveryReceiptRequested for the message + :type: bool + """ + return self.__is_delivery_receipt_requested + + @is_delivery_receipt_requested.setter + def is_delivery_receipt_requested(self, value): + self.__is_delivery_receipt_requested = bool(value) + self._track_changes.add('isDeliveryReceiptRequested') + + @property + def meeting_message_type(self): + """ If this message is a EventMessage, returns the + meeting type: meetingRequest, meetingCancelled, meetingAccepted, + meetingTentativelyAccepted, meetingDeclined + """ + return self.__meeting_message_type + + @property + def is_event_message(self): + """ Returns if this message is of type EventMessage + and therefore can return the related event. + """ + return self.__meeting_message_type is not None + + @property + def flag(self): + """ The Message Flag instance """ + return self.__flag + +
[docs] def to_api_data(self, restrict_keys=None): + """ Returns a dict representation of this message prepared to be send + to the cloud + + :param restrict_keys: a set of keys to restrict the returned + data to + :type restrict_keys: dict or set + :return: converted to cloud based keys + :rtype: dict + """ + + cc = self._cc # alias to shorten the code + + message = { + cc('subject'): self.subject, + cc('body'): { + cc('contentType'): self.body_type, + cc('content'): self.body}, + cc('importance'): cc(self.importance.value), + cc('flag'): self.flag.to_api_data(), + cc('isReadReceiptRequested'): self.is_read_receipt_requested, + cc('isDeliveryReceiptRequested'): self.is_delivery_receipt_requested, + } + + if self.to: + message[cc('toRecipients')] = [self._recipient_to_cloud(recipient) + for recipient in self.to] + if self.cc: + message[cc('ccRecipients')] = [self._recipient_to_cloud(recipient) + for recipient in self.cc] + if self.bcc: + message[cc('bccRecipients')] = [self._recipient_to_cloud(recipient) + for recipient in self.bcc] + if self.reply_to: + message[cc('replyTo')] = [self._recipient_to_cloud(recipient) for + recipient in self.reply_to] + if self.attachments: + message[cc('attachments')] = self.attachments.to_api_data() + if self.sender and self.sender.address: + message[cc('from')] = self._recipient_to_cloud(self.sender) + + if self.categories or 'categories' in (restrict_keys or {}): + message[cc('categories')] = self.categories + + if self.object_id and not self.__is_draft: + # return the whole signature of this message + + message[cc('id')] = self.object_id + if self.created: + message[cc('createdDateTime')] = self.created.astimezone( + pytz.utc).isoformat() + if self.received: + message[cc('receivedDateTime')] = self.received.astimezone( + pytz.utc).isoformat() + if self.sent: + message[cc('sentDateTime')] = self.sent.astimezone( + pytz.utc).isoformat() + message[cc('hasAttachments')] = bool(self.attachments) + message[cc('isRead')] = self.is_read + message[cc('isDraft')] = self.__is_draft + message[cc('conversationId')] = self.conversation_id + # this property does not form part of the message itself + message[cc('parentFolderId')] = self.folder_id + + if restrict_keys: + for key in list(message.keys()): + if key not in restrict_keys: + del message[key] + + return message
+ +
[docs] def send(self, save_to_sent_folder=True): + """ Sends this message + + :param bool save_to_sent_folder: whether or not to save it to + sent folder + :return: Success / Failure + :rtype: bool + """ + + if self.object_id and not self.__is_draft: + return RuntimeError('Not possible to send a message that is not ' + 'new or a draft. Use Reply or Forward instead.') + + if self.__is_draft and self.object_id: + url = self.build_url( + self._endpoints.get('send_draft').format(id=self.object_id)) + if self._track_changes: + # there are pending changes to be committed + self.save_draft() + data = None + + else: + url = self.build_url(self._endpoints.get('send_mail')) + data = {self._cc('message'): self.to_api_data()} + if save_to_sent_folder is False: + data[self._cc('saveToSentItems')] = False + + response = self.con.post(url, data=data) + # response evaluates to false if 4XX or 5XX status codes are returned + if not response: + return False + + self.object_id = 'sent_message' if not self.object_id \ + else self.object_id + self.__is_draft = False + + return True
+ +
[docs] def reply(self, to_all=True): + """ Creates a new message that is a reply to this message + + :param bool to_all: whether or not to replies to all the recipients + instead to just the sender + :return: new message + :rtype: Message + """ + if not self.object_id or self.__is_draft: + raise RuntimeError("Can't reply to this message") + + if to_all: + url = self.build_url(self._endpoints.get('create_reply_all').format( + id=self.object_id)) + else: + url = self.build_url( + self._endpoints.get('create_reply').format(id=self.object_id)) + + response = self.con.post(url) + if not response: + return None + + message = response.json() + + # Everything received from cloud must be passed as self._cloud_data_key + return self.__class__(parent=self, **{self._cloud_data_key: message})
+ +
[docs] def forward(self): + """ Creates a new message that is a forward this message + + :return: new message + :rtype: Message + """ + if not self.object_id or self.__is_draft: + raise RuntimeError("Can't forward this message") + + url = self.build_url( + self._endpoints.get('forward_message').format(id=self.object_id)) + + response = self.con.post(url) + if not response: + return None + + message = response.json() + + # Everything received from cloud must be passed as self._cloud_data_key + return self.__class__(parent=self, **{self._cloud_data_key: message})
+ +
[docs] def delete(self): + """ Deletes a stored message + + :return: Success / Failure + :rtype: bool + """ + if self.object_id is None: + raise RuntimeError('Attempting to delete an unsaved Message') + + url = self.build_url( + self._endpoints.get('get_message').format(id=self.object_id)) + + response = self.con.delete(url) + + return bool(response)
+ +
[docs] def mark_as_read(self): + """ Marks this message as read in the cloud + + :return: Success / Failure + :rtype: bool + """ + if self.object_id is None or self.__is_draft: + raise RuntimeError('Attempting to mark as read an unsaved Message') + + data = {self._cc('isRead'): True} + + url = self.build_url( + self._endpoints.get('get_message').format(id=self.object_id)) + + response = self.con.patch(url, data=data) + if not response: + return False + + self.__is_read = True + + return True
+ +
[docs] def mark_as_unread(self): + """ Marks this message as unread in the cloud + + :return: Success / Failure + :rtype: bool + """ + if self.object_id is None or self.__is_draft: + raise RuntimeError('Attempting to mark as unread an unsaved Message') + + data = {self._cc('isRead'): False} + + url = self.build_url( + self._endpoints.get('get_message').format(id=self.object_id)) + + response = self.con.patch(url, data=data) + if not response: + return False + + self.__is_read = False + + return True
+ +
[docs] def move(self, folder): + """ Move the message to a given folder + + :param folder: Folder object or Folder id or Well-known name to + move this message to + :type folder: str or mailbox.Folder + :return: Success / Failure + :rtype: bool + """ + if self.object_id is None: + raise RuntimeError('Attempting to move an unsaved Message') + + url = self.build_url( + self._endpoints.get('move_message').format(id=self.object_id)) + + if isinstance(folder, str): + folder_id = folder + else: + folder_id = getattr(folder, 'folder_id', None) + + if not folder_id: + raise RuntimeError('Must Provide a valid folder_id') + + data = {self._cc('destinationId'): folder_id} + + response = self.con.post(url, data=data) + if not response: + return False + + message = response.json() + + self.folder_id = folder_id + self.object_id = message.get('id') + + return True
+ +
[docs] def copy(self, folder): + """ Copy the message to a given folder + + :param folder: Folder object or Folder id or Well-known name to + copy this message to + :type folder: str or mailbox.Folder + :returns: the copied message + :rtype: Message + """ + if self.object_id is None: + raise RuntimeError('Attempting to move an unsaved Message') + + url = self.build_url( + self._endpoints.get('copy_message').format(id=self.object_id)) + + if isinstance(folder, str): + folder_id = folder + else: + folder_id = getattr(folder, 'folder_id', None) + + if not folder_id: + raise RuntimeError('Must Provide a valid folder_id') + + data = {self._cc('destinationId'): folder_id} + + response = self.con.post(url, data=data) + if not response: + return None + + message = response.json() + + # Everything received from cloud must be passed as self._cloud_data_key + return self.__class__(parent=self, **{self._cloud_data_key: message})
+ +
[docs] def save_message(self): + """ Saves changes to a message. + If the message is a new or saved draft it will call 'save_draft' otherwise + this will save only properties of a message that are draft-independent such as: + - is_read + - category + - flag + :return: Success / Failure + :rtype: bool + """ + if self.object_id and not self.__is_draft: + # we are only allowed to save some properties: + allowed_changes = {self._cc('isRead'), self._cc('categories'), self._cc('flag')} # allowed changes to be saved by this method + changes = {tc for tc in self._track_changes if tc in allowed_changes} + + if not changes: + return True # there's nothing to update + + url = self.build_url(self._endpoints.get('get_message').format(id=self.object_id)) + + data = self.to_api_data(restrict_keys=changes) + + response = self.con.patch(url, data=data) + + if not response: + return False + + self._track_changes.clear() # reset the tracked changes as they are all saved + self.__modified = self.protocol.timezone.localize(dt.datetime.now()) + + return True + else: + # fallback to save_draft + return self.save_draft()
+ +
[docs] def save_draft(self, target_folder=OutlookWellKnowFolderNames.DRAFTS): + """ Save this message as a draft on the cloud + + :param target_folder: name of the drafts folder + :return: Success / Failure + :rtype: bool + """ + + if self.object_id: + # update message. Attachments are NOT included nor saved. + if not self.__is_draft: + raise RuntimeError('Only draft messages can be updated') + if not self._track_changes: + return True # there's nothing to update + url = self.build_url( + self._endpoints.get('get_message').format(id=self.object_id)) + method = self.con.patch + data = self.to_api_data(restrict_keys=self._track_changes) + + data.pop(self._cc('attachments'), + None) # attachments are handled by the next method call + # noinspection PyProtectedMember + self.attachments._update_attachments_to_cloud() + else: + # new message. Attachments are included and saved. + if not self.__is_draft: + raise RuntimeError('Only draft messages can be saved as drafts') + + target_folder = target_folder or OutlookWellKnowFolderNames.DRAFTS + if isinstance(target_folder, OutlookWellKnowFolderNames): + target_folder = target_folder.value + elif not isinstance(target_folder, str): + # a Folder instance + target_folder = getattr(target_folder, 'folder_id', + OutlookWellKnowFolderNames.DRAFTS.value) + + url = self.build_url( + self._endpoints.get('create_draft_folder').format( + id=target_folder)) + method = self.con.post + data = self.to_api_data() + + if not data: + return True + + response = method(url, data=data) + if not response: + return False + + self._track_changes.clear() # reset the tracked changes as they are all saved + + if not self.object_id: + # new message + message = response.json() + + self.object_id = message.get(self._cc('id'), None) + self.folder_id = message.get(self._cc('parentFolderId'), None) + + # fallback to office365 v1.0 + self.__created = message.get(self._cc('createdDateTime'), + message.get( + self._cc('dateTimeCreated'), + None)) + # fallback to office365 v1.0 + self.__modified = message.get(self._cc('lastModifiedDateTime'), + message.get( + self._cc('dateTimeModified'), + None)) + + self.__created = parse(self.__created).astimezone( + self.protocol.timezone) if self.__created else None + self.__modified = parse(self.__modified).astimezone( + self.protocol.timezone) if self.__modified else None + + self.web_link = message.get(self._cc('webLink'), '') + else: + self.__modified = self.protocol.timezone.localize(dt.datetime.now()) + + return True
+ +
[docs] def get_body_text(self): + """ Parse the body html and returns the body text using bs4 + + :return: body as text + :rtype: str + """ + if self.body_type.upper() != 'HTML': + return self.body + + try: + soup = bs(self.body, 'html.parser') + except RuntimeError: + return self.body + else: + return soup.body.text
+ +
[docs] def get_body_soup(self): + """ Returns the beautifulsoup4 of the html body + + :return: BeautifulSoup object of body + :rtype: BeautifulSoup + """ + if self.body_type.upper() != 'HTML': + return None + else: + return bs(self.body, 'html.parser')
+ +
[docs] def get_event(self): + """ If this is a EventMessage it should return the related Event""" + + if not self.is_event_message: + return None + + # select a dummy field (eg. subject) to avoid pull unneccesary data + query = self.q().select('subject').expand('event') + + url = self.build_url(self._endpoints.get('get_message').format(id=self.object_id)) + + response = self.con.get(url, params=query.as_params()) + + if not response: + return None + + data = response.json() + event_data = data.get(self._cc('event')) + + return Event(parent=self, **{self._cloud_data_key: event_data})
+ +
[docs] def get_mime_content(self): + """ Returns the MIME contents of this message """ + if self.object_id is None: + raise RuntimeError('Attempting to get the mime contents of an unsaved message') + + url = self.build_url(self._endpoints.get('get_mime').format(id=self.object_id)) + + response = self.con.get(url) + + if not response: + return None + + return response.content
+ +
[docs] def save_as_eml(self, to_path=None): + """ Saves this message as and EML to the file system + :param Path or str to_path: the path where to store this file + """ + + if to_path is None: + to_path = Path('message_eml.eml') + else: + if not isinstance(to_path, Path): + to_path = Path(to_path) + + if not to_path.suffix: + to_path = to_path.with_suffix('.eml') + + mime_content = self.get_mime_content() + + if mime_content: + with to_path.open('wb') as file_obj: + file_obj.write(mime_content) + return True + return False
+
+ +
+ +
+ + +
+
+ +
+ +
+ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/O365/planner.html b/docs/latest/_modules/O365/planner.html new file mode 100644 index 00000000..3c2f19fb --- /dev/null +++ b/docs/latest/_modules/O365/planner.html @@ -0,0 +1,1317 @@ + + + + + + + + O365.planner — O365 documentation + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+ +
+
+
+
+ +

Source code for O365.planner

+import logging
+from datetime import date, datetime
+
+from dateutil.parser import parse
+
+from .utils import NEXT_LINK_KEYWORD, ApiComponent, Pagination
+
+log = logging.getLogger(__name__)
+
+
+
+[docs] +class TaskDetails(ApiComponent): + _endpoints = {'task_detail': '/planner/tasks/{id}/details'} + +
+[docs] + def __init__(self, *, parent=None, con=None, **kwargs): + """ A Microsoft O365 plan details + + :param parent: parent object + :type parent: Task + :param Connection con: connection to use if no parent specified + :param Protocol protocol: protocol to use if no parent specified + (kwargs) + :param str main_resource: use this resource instead of parent resource + (kwargs) + """ + + if parent and con: + raise ValueError('Need a parent or a connection but not both') + self.con = parent.con if parent else con + + cloud_data = kwargs.get(self._cloud_data_key, {}) + + self.object_id = cloud_data.get('id') + + # Choose the main_resource passed in kwargs over parent main_resource + main_resource = kwargs.pop('main_resource', None) or ( + getattr(parent, 'main_resource', None) if parent else None) + + main_resource = '{}{}'.format(main_resource, '') + + super().__init__( + protocol=parent.protocol if parent else kwargs.get('protocol'), + main_resource=main_resource) + + self.description = cloud_data.get(self._cc('description'), '') + self.references = cloud_data.get(self._cc('references'), '') + self.checklist = cloud_data.get(self._cc('checklist'), '') + self.preview_type = cloud_data.get(self._cc('previewType'), '') + self._etag = cloud_data.get('@odata.etag', '')
+ + + def __str__(self): + return self.__repr__() + + def __repr__(self): + return 'Task Details' + + def __eq__(self, other): + return self.object_id == other.object_id + +
+[docs] + def update(self, **kwargs): + """Updates this task detail + + :param kwargs: all the properties to be updated. + :param dict checklist: the collection of checklist items on the task. + + .. code-block:: + + e.g. checklist = { + "string GUID": { + "isChecked": bool, + "orderHint": string, + "title": string + } + } (kwargs) + + :param str description: description of the task + :param str preview_type: this sets the type of preview that shows up on the task. + + The possible values are: automatic, noPreview, checklist, description, reference. + + :param dict references: the collection of references on the task. + + .. code-block:: + + e.g. references = { + "URL of the resource" : { + "alias": string, + "previewPriority": string, #same as orderHint + "type": string, #e.g. PowerPoint, Excel, Word, Pdf... + } + } + + :return: Success / Failure + :rtype: bool + """ + if not self.object_id: + return False + + _unsafe = ".:@#" + + url = self.build_url( + self._endpoints.get("task_detail").format(id=self.object_id) + ) + + data = { + self._cc(key): value + for key, value in kwargs.items() + if key + in ( + "checklist", + "description", + "preview_type", + "references", + ) + } + if not data: + return False + + if "references" in data and isinstance(data["references"], dict): + for key in list(data["references"].keys()): + if ( + isinstance(data["references"][key], dict) + and not "@odata.type" in data["references"][key] + ): + data["references"][key]["@odata.type"] = ( + "#microsoft.graph.plannerExternalReference" + ) + + if any(u in key for u in _unsafe): + sanitized_key = "".join( + [ + chr(b) + if b not in _unsafe.encode("utf-8", "strict") + else "%{:02X}".format(b) + for b in key.encode("utf-8", "strict") + ] + ) + data["references"][sanitized_key] = data["references"].pop(key) + + if "checklist" in data: + for key in data["checklist"].keys(): + if ( + isinstance(data["checklist"][key], dict) + and not "@odata.type" in data["checklist"][key] + ): + data["checklist"][key]["@odata.type"] = ( + "#microsoft.graph.plannerChecklistItem" + ) + + response = self.con.patch( + url, + data=data, + headers={"If-Match": self._etag, "Prefer": "return=representation"}, + ) + if not response: + return False + + new_data = response.json() + + for key in data: + value = new_data.get(key, None) + if value is not None: + setattr(self, self.protocol.to_api_case(key), value) + + self._etag = new_data.get("@odata.etag") + + return True
+
+ + + +
+[docs] +class PlanDetails(ApiComponent): + _endpoints = {"plan_detail": "/planner/plans/{id}/details"} + +
+[docs] + def __init__(self, *, parent=None, con=None, **kwargs): + """A Microsoft O365 plan details + + :param parent: parent object + :type parent: Plan + :param Connection con: connection to use if no parent specified + :param Protocol protocol: protocol to use if no parent specified + (kwargs) + :param str main_resource: use this resource instead of parent resource + (kwargs) + """ + + if parent and con: + raise ValueError("Need a parent or a connection but not both") + self.con = parent.con if parent else con + + cloud_data = kwargs.get(self._cloud_data_key, {}) + + self.object_id = cloud_data.get("id") + + # Choose the main_resource passed in kwargs over parent main_resource + main_resource = kwargs.pop("main_resource", None) or ( + getattr(parent, "main_resource", None) if parent else None + ) + + main_resource = "{}{}".format(main_resource, "") + + super().__init__( + protocol=parent.protocol if parent else kwargs.get("protocol"), + main_resource=main_resource, + ) + + self.shared_with = cloud_data.get(self._cc("sharedWith"), "") + self.category_descriptions = cloud_data.get( + self._cc("categoryDescriptions"), "" + ) + self._etag = cloud_data.get("@odata.etag", "")
+ + + def __str__(self): + return self.__repr__() + + def __repr__(self): + return "Plan Details" + + def __eq__(self, other): + return self.object_id == other.object_id + +
+[docs] + def update(self, **kwargs): + """Updates this plan detail + + :param kwargs: all the properties to be updated. + :param dict shared_with: dict where keys are user_ids and values are boolean (kwargs) + :param dict category_descriptions: dict where keys are category1, category2, ..., category25 and values are the label associated with (kwargs) + :return: Success / Failure + :rtype: bool + """ + if not self.object_id: + return False + + url = self.build_url( + self._endpoints.get("plan_detail").format(id=self.object_id) + ) + + data = { + self._cc(key): value + for key, value in kwargs.items() + if key in ("shared_with", "category_descriptions") + } + if not data: + return False + + response = self.con.patch( + url, + data=data, + headers={"If-Match": self._etag, "Prefer": "return=representation"}, + ) + if not response: + return False + + new_data = response.json() + + for key in data: + value = new_data.get(key, None) + if value is not None: + setattr(self, self.protocol.to_api_case(key), value) + + self._etag = new_data.get("@odata.etag") + + return True
+
+ + + +
+[docs] +class Task(ApiComponent): + """A Microsoft Planner task""" + + _endpoints = { + "get_details": "/planner/tasks/{id}/details", + "task": "/planner/tasks/{id}", + } + + task_details_constructor = TaskDetails + +
+[docs] + def __init__(self, *, parent=None, con=None, **kwargs): + """A Microsoft planner task + + :param parent: parent object + :type parent: Planner or Plan or Bucket + :param Connection con: connection to use if no parent specified + :param Protocol protocol: protocol to use if no parent specified + (kwargs) + :param str main_resource: use this resource instead of parent resource + (kwargs) + """ + if parent and con: + raise ValueError("Need a parent or a connection but not both") + self.con = parent.con if parent else con + + cloud_data = kwargs.get(self._cloud_data_key, {}) + + self.object_id = cloud_data.get("id") + + # Choose the main_resource passed in kwargs over parent main_resource + main_resource = kwargs.pop("main_resource", None) or ( + getattr(parent, "main_resource", None) if parent else None + ) + + main_resource = "{}{}".format(main_resource, "") + + super().__init__( + protocol=parent.protocol if parent else kwargs.get("protocol"), + main_resource=main_resource, + ) + + self.plan_id = cloud_data.get("planId") + self.bucket_id = cloud_data.get("bucketId") + self.title = cloud_data.get(self._cc("title"), "") + self.priority = cloud_data.get(self._cc("priority"), "") + self.assignments = cloud_data.get(self._cc("assignments"), "") + self.order_hint = cloud_data.get(self._cc("orderHint"), "") + self.assignee_priority = cloud_data.get(self._cc("assigneePriority"), "") + self.percent_complete = cloud_data.get(self._cc("percentComplete"), "") + self.has_description = cloud_data.get(self._cc("hasDescription"), "") + created = cloud_data.get(self._cc("createdDateTime"), None) + due_date_time = cloud_data.get(self._cc("dueDateTime"), None) + start_date_time = cloud_data.get(self._cc("startDateTime"), None) + completed_date = cloud_data.get(self._cc("completedDateTime"), None) + local_tz = self.protocol.timezone + self.start_date_time = ( + parse(start_date_time).astimezone(local_tz) if start_date_time else None + ) + self.created_date = parse(created).astimezone(local_tz) if created else None + self.due_date_time = ( + parse(due_date_time).astimezone(local_tz) if due_date_time else None + ) + self.completed_date = ( + parse(completed_date).astimezone(local_tz) if completed_date else None + ) + self.preview_type = cloud_data.get(self._cc("previewType"), None) + self.reference_count = cloud_data.get(self._cc("referenceCount"), None) + self.checklist_item_count = cloud_data.get(self._cc("checklistItemCount"), None) + self.active_checklist_item_count = cloud_data.get( + self._cc("activeChecklistItemCount"), None + ) + self.conversation_thread_id = cloud_data.get( + self._cc("conversationThreadId"), None + ) + self.applied_categories = cloud_data.get(self._cc("appliedCategories"), None) + self._etag = cloud_data.get("@odata.etag", "")
+ + + def __str__(self): + return self.__repr__() + + def __repr__(self): + return "Task: {}".format(self.title) + + def __eq__(self, other): + return self.object_id == other.object_id + +
+[docs] + def get_details(self): + """Returns Microsoft O365/AD plan with given id + + :rtype: PlanDetails + """ + + if not self.object_id: + raise RuntimeError("Plan is not initialized correctly. Id is missing...") + + url = self.build_url( + self._endpoints.get("get_details").format(id=self.object_id) + ) + + response = self.con.get(url) + + if not response: + return None + + data = response.json() + + return self.task_details_constructor( + parent=self, + **{self._cloud_data_key: data}, + )
+ + +
+[docs] + def update(self, **kwargs): + """Updates this task + + :param kwargs: all the properties to be updated. + :return: Success / Failure + :rtype: bool + """ + if not self.object_id: + return False + + url = self.build_url(self._endpoints.get("task").format(id=self.object_id)) + + for k, v in kwargs.items(): + if k in ("start_date_time", "due_date_time"): + kwargs[k] = ( + v.strftime("%Y-%m-%dT%H:%M:%SZ") + if isinstance(v, (datetime, date)) + else v + ) + + data = { + self._cc(key): value + for key, value in kwargs.items() + if key + in ( + "title", + "priority", + "assignments", + "order_hint", + "assignee_priority", + "percent_complete", + "has_description", + "start_date_time", + "created_date", + "due_date_time", + "completed_date", + "preview_type", + "reference_count", + "checklist_item_count", + "active_checklist_item_count", + "conversation_thread_id", + "applied_categories", + "bucket_id", + ) + } + if not data: + return False + + response = self.con.patch( + url, + data=data, + headers={"If-Match": self._etag, "Prefer": "return=representation"}, + ) + if not response: + return False + + new_data = response.json() + + for key in data: + value = new_data.get(key, None) + if value is not None: + setattr(self, self.protocol.to_api_case(key), value) + + self._etag = new_data.get("@odata.etag") + + return True
+ + +
+[docs] + def delete(self): + """Deletes this task + + :return: Success / Failure + :rtype: bool + """ + + if not self.object_id: + return False + + url = self.build_url(self._endpoints.get("task").format(id=self.object_id)) + + response = self.con.delete(url, headers={"If-Match": self._etag}) + if not response: + return False + + self.object_id = None + + return True
+
+ + + +
+[docs] +class Bucket(ApiComponent): + _endpoints = { + "list_tasks": "/planner/buckets/{id}/tasks", + "create_task": "/planner/tasks", + "bucket": "/planner/buckets/{id}", + } + task_constructor = Task + +
+[docs] + def __init__(self, *, parent=None, con=None, **kwargs): + """A Microsoft O365 bucket + + :param parent: parent object + :type parent: Planner or Plan + :param Connection con: connection to use if no parent specified + :param Protocol protocol: protocol to use if no parent specified + (kwargs) + :param str main_resource: use this resource instead of parent resource + (kwargs) + """ + + if parent and con: + raise ValueError("Need a parent or a connection but not both") + self.con = parent.con if parent else con + + cloud_data = kwargs.get(self._cloud_data_key, {}) + + self.object_id = cloud_data.get("id") + + # Choose the main_resource passed in kwargs over parent main_resource + main_resource = kwargs.pop("main_resource", None) or ( + getattr(parent, "main_resource", None) if parent else None + ) + + main_resource = "{}{}".format(main_resource, "") + + super().__init__( + protocol=parent.protocol if parent else kwargs.get("protocol"), + main_resource=main_resource, + ) + + self.name = cloud_data.get(self._cc("name"), "") + self.order_hint = cloud_data.get(self._cc("orderHint"), "") + self.plan_id = cloud_data.get(self._cc("planId"), "") + self._etag = cloud_data.get("@odata.etag", "")
+ + + def __str__(self): + return self.__repr__() + + def __repr__(self): + return "Bucket: {}".format(self.name) + + def __eq__(self, other): + return self.object_id == other.object_id + +
+[docs] + def list_tasks(self): + """Returns list of tasks that given plan has + :rtype: list[Task] + """ + + if not self.object_id: + raise RuntimeError("Bucket is not initialized correctly. Id is missing...") + + url = self.build_url( + self._endpoints.get("list_tasks").format(id=self.object_id) + ) + + response = self.con.get(url) + + if not response: + return None + + data = response.json() + + return [ + self.task_constructor(parent=self, **{self._cloud_data_key: task}) + for task in data.get("value", []) + ]
+ + +
+[docs] + def create_task(self, title, assignments=None, **kwargs): + """Creates a Task + + :param str title: the title of the task + :param dict assignments: the dict of users to which tasks are to be assigned. + + .. code-block:: python + + e.g. assignments = { + "ca2a1df2-e36b-4987-9f6b-0ea462f4eb47": null, + "4e98f8f1-bb03-4015-b8e0-19bb370949d8": { + "@odata.type": "microsoft.graph.plannerAssignment", + "orderHint": "String" + } + } + if "user_id": null -> task is unassigned to user. + if "user_id": dict -> task is assigned to user + + :param dict kwargs: optional extra parameters to include in the task + :param int priority: priority of the task. The valid range of values is between 0 and 10. + + 1 -> "urgent", 3 -> "important", 5 -> "medium", 9 -> "low" (kwargs) + + :param str order_hint: the order of the bucket. Default is on top (kwargs) + :param datetime or str start_date_time: the starting date of the task. If str format should be: "%Y-%m-%dT%H:%M:%SZ" (kwargs) + :param datetime or str due_date_time: the due date of the task. If str format should be: "%Y-%m-%dT%H:%M:%SZ" (kwargs) + :param str conversation_thread_id: thread ID of the conversation on the task. + + This is the ID of the conversation thread object created in the group (kwargs) + + :param str assignee_priority: hint used to order items of this type in a list view (kwargs) + :param int percent_complete: percentage of task completion. When set to 100, the task is considered completed (kwargs) + :param dict applied_categories: The categories (labels) to which the task has been applied. + + Format should be e.g. {"category1": true, "category3": true, "category5": true } should (kwargs) + + :return: newly created task + :rtype: Task + """ + if not title: + raise RuntimeError('Provide a title for the Task') + + if not self.object_id and not self.plan_id: + return None + + url = self.build_url( + self._endpoints.get('create_task')) + + if not assignments: + assignments = {'@odata.type': 'microsoft.graph.plannerAssignments'} + + for k, v in kwargs.items(): + if k in ('start_date_time', 'due_date_time'): + kwargs[k] = v.strftime('%Y-%m-%dT%H:%M:%SZ') if isinstance(v, (datetime, date)) else v + + kwargs = {self._cc(key): value for key, value in kwargs.items() if + key in ( + 'priority' + 'order_hint' + 'assignee_priority' + 'percent_complete' + 'has_description' + 'start_date_time' + 'created_date' + 'due_date_time' + 'completed_date' + 'preview_type' + 'reference_count' + 'checklist_item_count' + 'active_checklist_item_count' + 'conversation_thread_id' + 'applied_categories' + )} + + data = { + 'title': title, + 'assignments': assignments, + 'bucketId': self.object_id, + 'planId': self.plan_id, + **kwargs + } + + response = self.con.post(url, data=data) + if not response: + return None + + task = response.json() + + return self.task_constructor(parent=self, + **{self._cloud_data_key: task})
+ + +
+[docs] + def update(self, **kwargs): + """ Updates this bucket + + :param kwargs: all the properties to be updated. + :return: Success / Failure + :rtype: bool + """ + if not self.object_id: + return False + + url = self.build_url( + self._endpoints.get('bucket').format(id=self.object_id)) + + data = {self._cc(key): value for key, value in kwargs.items() if + key in ('name', 'order_hint')} + if not data: + return False + + response = self.con.patch(url, data=data, headers={'If-Match': self._etag, 'Prefer': 'return=representation'}) + if not response: + return False + + new_data = response.json() + + for key in data: + value = new_data.get(key, None) + if value is not None: + setattr(self, self.protocol.to_api_case(key), value) + + self._etag = new_data.get('@odata.etag') + + return True
+ + +
+[docs] + def delete(self): + """ Deletes this bucket + + :return: Success / Failure + :rtype: bool + """ + + if not self.object_id: + return False + + url = self.build_url( + self._endpoints.get('bucket').format(id=self.object_id)) + + response = self.con.delete(url, headers={'If-Match': self._etag}) + if not response: + return False + + self.object_id = None + + return True
+
+ + + +
+[docs] +class Plan(ApiComponent): + _endpoints = { + 'list_buckets': '/planner/plans/{id}/buckets', + 'list_tasks': '/planner/plans/{id}/tasks', + 'get_details': '/planner/plans/{id}/details', + 'plan': '/planner/plans/{id}', + 'create_bucket': '/planner/buckets' + } + + bucket_constructor = Bucket + task_constructor = Task + plan_details_constructor = PlanDetails + +
+[docs] + def __init__(self, *, parent=None, con=None, **kwargs): + """ A Microsoft O365 plan + + :param parent: parent object + :type parent: Planner + :param Connection con: connection to use if no parent specified + :param Protocol protocol: protocol to use if no parent specified + (kwargs) + :param str main_resource: use this resource instead of parent resource + (kwargs) + """ + + if parent and con: + raise ValueError('Need a parent or a connection but not both') + self.con = parent.con if parent else con + + cloud_data = kwargs.get(self._cloud_data_key, {}) + + self.object_id = cloud_data.get('id') + + # Choose the main_resource passed in kwargs over parent main_resource + main_resource = kwargs.pop('main_resource', None) or ( + getattr(parent, 'main_resource', None) if parent else None) + + main_resource = '{}{}'.format(main_resource, '') + + super().__init__( + protocol=parent.protocol if parent else kwargs.get('protocol'), + main_resource=main_resource) + + self.created_date_time = cloud_data.get(self._cc('createdDateTime'), '') + container = cloud_data.get(self._cc('container'), {}) + self.group_id = container.get(self._cc('containerId'), '') + self.title = cloud_data.get(self._cc('title'), '') + self._etag = cloud_data.get('@odata.etag', '')
+ + + def __str__(self): + return self.__repr__() + + def __repr__(self): + return 'Plan: {}'.format(self.title) + + def __eq__(self, other): + return self.object_id == other.object_id + +
+[docs] + def list_buckets(self): + """ Returns list of buckets that given plan has + :rtype: list[Bucket] + """ + + if not self.object_id: + raise RuntimeError('Plan is not initialized correctly. Id is missing...') + + url = self.build_url( + self._endpoints.get('list_buckets').format(id=self.object_id)) + + response = self.con.get(url) + + if not response: + return None + + data = response.json() + + return [ + self.bucket_constructor(parent=self, **{self._cloud_data_key: bucket}) + for bucket in data.get('value', [])]
+ + +
+[docs] + def list_tasks(self): + """ Returns list of tasks that given plan has + :rtype: list[Task] or Pagination of Task + """ + + if not self.object_id: + raise RuntimeError('Plan is not initialized correctly. Id is missing...') + + url = self.build_url( + self._endpoints.get('list_tasks').format(id=self.object_id)) + + response = self.con.get(url) + + if not response: + return [] + + data = response.json() + next_link = data.get(NEXT_LINK_KEYWORD, None) + + tasks = [ + self.task_constructor(parent=self, **{self._cloud_data_key: task}) + for task in data.get('value', [])] + + if next_link: + return Pagination(parent=self, data=tasks, + constructor=self.task_constructor, + next_link=next_link) + else: + return tasks
+ + +
+[docs] + def get_details(self): + """ Returns Microsoft O365/AD plan with given id + + :rtype: PlanDetails + """ + + if not self.object_id: + raise RuntimeError('Plan is not initialized correctly. Id is missing...') + + url = self.build_url( + self._endpoints.get('get_details').format(id=self.object_id)) + + response = self.con.get(url) + + if not response: + return None + + data = response.json() + + return self.plan_details_constructor(parent=self, + **{self._cloud_data_key: data}, )
+ + +
+[docs] + def create_bucket(self, name, order_hint=' !'): + """ Creates a Bucket + + :param str name: the name of the bucket + :param str order_hint: the order of the bucket. Default is on top. + How to use order hints here: https://docs.microsoft.com/en-us/graph/api/resources/planner-order-hint-format?view=graph-rest-1.0 + :return: newly created bucket + :rtype: Bucket + """ + + if not name: + raise RuntimeError('Provide a name for the Bucket') + + if not self.object_id: + return None + + url = self.build_url( + self._endpoints.get('create_bucket')) + + data = {'name': name, 'orderHint': order_hint, 'planId': self.object_id} + + response = self.con.post(url, data=data) + if not response: + return None + + bucket = response.json() + + return self.bucket_constructor(parent=self, + **{self._cloud_data_key: bucket})
+ + +
+[docs] + def update(self, **kwargs): + """ Updates this plan + + :param kwargs: all the properties to be updated. + :return: Success / Failure + :rtype: bool + """ + if not self.object_id: + return False + + url = self.build_url( + self._endpoints.get('plan').format(id=self.object_id)) + + data = {self._cc(key): value for key, value in kwargs.items() if + key in ('title')} + if not data: + return False + + response = self.con.patch(url, data=data, headers={'If-Match': self._etag, 'Prefer': 'return=representation'}) + if not response: + return False + + new_data = response.json() + + for key in data: + value = new_data.get(key, None) + if value is not None: + setattr(self, self.protocol.to_api_case(key), value) + + self._etag = new_data.get('@odata.etag') + + return True
+ + +
+[docs] + def delete(self): + """ Deletes this plan + + :return: Success / Failure + :rtype: bool + """ + + if not self.object_id: + return False + + url = self.build_url( + self._endpoints.get('plan').format(id=self.object_id)) + + response = self.con.delete(url, headers={'If-Match': self._etag}) + if not response: + return False + + self.object_id = None + + return True
+
+ + + +
+[docs] +class Planner(ApiComponent): + """ A microsoft planner class + In order to use the API following permissions are required. + Delegated (work or school account) - Group.Read.All, Group.ReadWrite.All + """ + + _endpoints = { + 'get_my_tasks': '/me/planner/tasks', + 'get_plan_by_id': '/planner/plans/{plan_id}', + 'get_bucket_by_id': '/planner/buckets/{bucket_id}', + 'get_task_by_id': '/planner/tasks/{task_id}', + 'list_user_tasks': '/users/{user_id}/planner/tasks', + 'list_group_plans': '/groups/{group_id}/planner/plans', + 'create_plan': '/planner/plans', + } + plan_constructor = Plan + bucket_constructor = Bucket + task_constructor = Task + +
+[docs] + def __init__(self, *, parent=None, con=None, **kwargs): + """ A Planner object + + :param parent: parent object + :type parent: Account + :param Connection con: connection to use if no parent specified + :param Protocol protocol: protocol to use if no parent specified + (kwargs) + :param str main_resource: use this resource instead of parent resource + (kwargs) + """ + if parent and con: + raise ValueError('Need a parent or a connection but not both') + self.con = parent.con if parent else con + + # Choose the main_resource passed in kwargs over the host_name + main_resource = kwargs.pop('main_resource', + '') # defaults to blank resource + super().__init__( + protocol=parent.protocol if parent else kwargs.get('protocol'), + main_resource=main_resource)
+ + + def __str__(self): + return self.__repr__() + + def __repr__(self): + return 'Microsoft Planner' + +
+[docs] + def get_my_tasks(self, *args): + """ Returns a list of open planner tasks assigned to me + + :rtype: tasks + """ + + url = self.build_url(self._endpoints.get('get_my_tasks')) + + response = self.con.get(url) + + if not response: + return None + + data = response.json() + + return [ + self.task_constructor(parent=self, **{self._cloud_data_key: site}) + for site in data.get('value', [])]
+ + +
+[docs] + def get_plan_by_id(self, plan_id=None): + """ Returns Microsoft O365/AD plan with given id + + :param plan_id: plan id of plan + + :rtype: Plan + """ + + if not plan_id: + raise RuntimeError('Provide the plan_id') + + url = self.build_url( + self._endpoints.get('get_plan_by_id').format(plan_id=plan_id)) + + response = self.con.get(url) + + if not response: + return None + + data = response.json() + + return self.plan_constructor(parent=self, + **{self._cloud_data_key: data}, )
+ + +
+[docs] + def get_bucket_by_id(self, bucket_id=None): + """ Returns Microsoft O365/AD plan with given id + + :param bucket_id: bucket id of buckets + + :rtype: Bucket + """ + + if not bucket_id: + raise RuntimeError('Provide the bucket_id') + + url = self.build_url( + self._endpoints.get('get_bucket_by_id').format(bucket_id=bucket_id)) + + response = self.con.get(url) + + if not response: + return None + + data = response.json() + + return self.bucket_constructor(parent=self, + **{self._cloud_data_key: data})
+ + +
+[docs] + def get_task_by_id(self, task_id=None): + """ Returns Microsoft O365/AD plan with given id + + :param task_id: task id of tasks + + :rtype: Task + """ + + if not task_id: + raise RuntimeError('Provide the task_id') + + url = self.build_url( + self._endpoints.get('get_task_by_id').format(task_id=task_id)) + + response = self.con.get(url) + + if not response: + return None + + data = response.json() + + return self.task_constructor(parent=self, + **{self._cloud_data_key: data})
+ + +
+[docs] + def list_user_tasks(self, user_id=None): + """ Returns Microsoft O365/AD plan with given id + + :param user_id: user id + + :rtype: list[Task] + """ + + if not user_id: + raise RuntimeError('Provide the user_id') + + url = self.build_url( + self._endpoints.get('list_user_tasks').format(user_id=user_id)) + + response = self.con.get(url) + + if not response: + return None + + data = response.json() + + return [ + self.task_constructor(parent=self, **{self._cloud_data_key: task}) + for task in data.get('value', [])]
+ + +
+[docs] + def list_group_plans(self, group_id=None): + """ Returns list of plans that given group has + :param group_id: group id + :rtype: list[Plan] + """ + + if not group_id: + raise RuntimeError('Provide the group_id') + + url = self.build_url( + self._endpoints.get('list_group_plans').format(group_id=group_id)) + + response = self.con.get(url) + + if not response: + return None + + data = response.json() + + return [ + self.plan_constructor(parent=self, **{self._cloud_data_key: plan}) + for plan in data.get('value', [])]
+ + +
+[docs] + def create_plan(self, owner, title='Tasks'): + """ Creates a Plan + + :param str owner: the id of the group that will own the plan + :param str title: the title of the new plan. Default set to "Tasks" + :return: newly created plan + :rtype: Plan + """ + if not owner: + raise RuntimeError('Provide the owner (group_id)') + + url = self.build_url( + self._endpoints.get('create_plan')) + + data = {'owner': owner, 'title': title} + + response = self.con.post(url, data=data) + if not response: + return None + + plan = response.json() + + return self.plan_constructor(parent=self, + **{self._cloud_data_key: plan})
+
+ +
+ +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/docs/latest/_modules/O365/sharepoint.html b/docs/latest/_modules/O365/sharepoint.html new file mode 100644 index 00000000..3b241862 --- /dev/null +++ b/docs/latest/_modules/O365/sharepoint.html @@ -0,0 +1,837 @@ + + + + + + + + + + + O365.sharepoint — O365 documentation + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + +
+ +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+
+
+
+ +

Source code for O365.sharepoint

+import logging
+
+from dateutil.parser import parse
+
+from .utils import ApiComponent, TrackerSet, NEXT_LINK_KEYWORD, Pagination
+from .address_book import Contact
+from .drive import Storage
+
+log = logging.getLogger(__name__)
+
+
+
[docs]class SharepointListColumn(ApiComponent): + """ A Sharepoint List column within a SharepointList """ + + _endpoints = {} + +
[docs] def __init__(self, *, parent=None, con=None, **kwargs): + if parent and con: + raise ValueError('Need a parent or a connection but not both') + self.con = parent.con if parent else con + + # Choose the main_resource passed in kwargs over the parent main_resource + main_resource = kwargs.pop('main_resource', None) or ( + getattr(parent, 'main_resource', None) if parent else None) + + super().__init__(protocol=parent.protocol if parent else kwargs.get('protocol'), main_resource=main_resource) + + cloud_data = kwargs.get(self._cloud_data_key, {}) + + self.object_id = cloud_data.get('id') + self.column_group = cloud_data.get(self._cc('columnGroup'), None) + self.description = cloud_data.get(self._cc('description'), None) + self.display_name = cloud_data.get(self._cc('displayName'), None) + self.enforce_unique_values = cloud_data.get(self._cc('enforceUniqueValues'), None) + self.hidden = cloud_data.get(self._cc('hidden'), None) + self.indexed = cloud_data.get(self._cc('indexed'), None) + self.internal_name = cloud_data.get(self._cc('name'), None) + self.read_only = cloud_data.get(self._cc('readOnly'), None) + self.required = cloud_data.get(self._cc('required'), None) + + # identify the sharepoint column type and set it + # Graph api doesn't return the type for managed metadata and link column + if cloud_data.get(self._cc('text'), None) is not None: + self.field_type = 'text' + elif cloud_data.get(self._cc('choice'), None) is not None: + self.field_type = 'choice' + elif cloud_data.get(self._cc('number'), None) is not None: + self.field_type = 'number' + elif cloud_data.get(self._cc('currency'), None) is not None: + self.field_type = 'currency' + elif cloud_data.get(self._cc('dateTime'), None) is not None: + self.field_type = 'dateTime' + elif cloud_data.get(self._cc('lookup'), None) is not None: + self.field_type = 'lookup' + elif cloud_data.get(self._cc('boolean'), None) is not None: + self.field_type = 'boolean' + elif cloud_data.get(self._cc('calculated'), None) is not None: + self.field_type = 'calculated' + elif cloud_data.get(self._cc('personOrGroup'), None) is not None: + self.field_type = 'personOrGroup' + else: + self.field_type = None
+ + def __repr__(self): + return 'List Column: {0}-{1}'.format(self.display_name, self.field_type) + + def __eq__(self, other): + return self.object_id == other.object_id
+ + +
[docs]class SharepointListItem(ApiComponent): + _endpoints = {'update_list_item': '/items/{item_id}/fields', + 'delete_list_item': '/items/{item_id}'} + +
[docs] def __init__(self, *, parent=None, con=None, **kwargs): + """ A Sharepoint ListItem within a SharepointList + + :param parent: parent object + :type parent: SharepointList + :param Connection con: connection to use if no parent specified + :param Protocol protocol: protocol to use if no parent specified + (kwargs) + :param str main_resource: use this resource instead of parent resource + (kwargs) + """ + if parent and con: + raise ValueError('Need a parent or a connection but not both') + self.con = parent.con if parent else con + self._parent = parent + + # Choose the main_resource passed in kwargs over parent main_resource + main_resource = kwargs.pop('main_resource', None) or ( + getattr(parent, 'main_resource', None) if parent else None) + + super().__init__( + protocol=parent.protocol if parent else kwargs.get('protocol'), + main_resource=main_resource) + + cloud_data = kwargs.get(self._cloud_data_key, {}) + + self._track_changes = TrackerSet(casing=self._cc) + self.object_id = cloud_data.get('id') + created = cloud_data.get(self._cc('createdDateTime'), None) + modified = cloud_data.get(self._cc('lastModifiedDateTime'), None) + local_tz = self.protocol.timezone + self.created = parse(created).astimezone(local_tz) if created else None + self.modified = parse(modified).astimezone(local_tz) if modified else None + + created_by = cloud_data.get(self._cc('createdBy'), {}).get('user', None) + self.created_by = Contact(con=self.con, protocol=self.protocol, + **{self._cloud_data_key: created_by}) if created_by else None + modified_by = cloud_data.get(self._cc('lastModifiedBy'), {}).get('user', None) + self.modified_by = Contact(con=self.con, protocol=self.protocol, + **{self._cloud_data_key: modified_by}) if modified_by else None + + self.web_url = cloud_data.get(self._cc('webUrl'), None) + + self.content_type_id = cloud_data.get(self._cc('contentType'), {}).get('id', None) + + self.fields = cloud_data.get(self._cc('fields'), None)
+ + def __repr__(self): + return 'List Item: {}'.format(self.web_url) + + def __eq__(self, other): + return self.object_id == other.object_id + + def _clear_tracker(self): + self._track_changes = TrackerSet(casing=self._cc) + + def _valid_field(self, field): + # Verify the used field names are valid internal field names + valid_field_names = self.fields if self.fields \ + else self._parent.column_name_cw.values() \ + if self._parent \ + else None + if valid_field_names: + return field in valid_field_names + + # If no parent is given, and no internal fields are defined assume correct, API will check + return True + +
[docs] def update_fields(self, updates): + """ + Update the value for a field(s) in the listitem + + :param update: A dict of {'field name': newvalue} + """ + + for field in updates: + if self._valid_field(field): + self._track_changes.add(field) + else: + raise ValueError('"{}" is not a valid internal field name'.format(field)) + + # Update existing instance of fields, or create a fields instance if needed + if self.fields: + self.fields.update(updates) + else: + self.fields = updates
+ +
[docs] def save_updates(self): + """Save the updated fields to the cloud""" + + if not self._track_changes: + return True # there's nothing to update + + url = self.build_url(self._endpoints.get('update_list_item').format(item_id=self.object_id)) + update = {field: value for field, value in self.fields.items() + if self._cc(field) in self._track_changes} + + response = self.con.patch(url, update) + if not response: + return False + self._clear_tracker() + return True
+ +
[docs] def delete(self): + url = self.build_url(self._endpoints.get('delete_list_item').format(item_id=self.object_id)) + response = self.con.delete(url) + return bool(response)
+ + +
[docs]class SharepointList(ApiComponent): + _endpoints = { + 'get_items': '/items', + 'get_item_by_id': '/items/{item_id}', + 'get_list_columns': '/columns' + } + list_item_constructor = SharepointListItem + list_column_constructor = SharepointListColumn + +
[docs] def __init__(self, *, parent=None, con=None, **kwargs): + """ A Sharepoint site List + + :param parent: parent object + :type parent: Site + :param Connection con: connection to use if no parent specified + :param Protocol protocol: protocol to use if no parent specified + (kwargs) + :param str main_resource: use this resource instead of parent resource + (kwargs) + """ + if parent and con: + raise ValueError('Need a parent or a connection but not both') + self.con = parent.con if parent else con + + cloud_data = kwargs.get(self._cloud_data_key, {}) + + self.object_id = cloud_data.get('id') + + # Choose the main_resource passed in kwargs over parent main_resource + main_resource = kwargs.pop('main_resource', None) or ( + getattr(parent, 'main_resource', None) if parent else None) + + # prefix with the current known list + resource_prefix = '/lists/{list_id}'.format(list_id=self.object_id) + main_resource = '{}{}'.format(main_resource, resource_prefix) + + super().__init__( + protocol=parent.protocol if parent else kwargs.get('protocol'), + main_resource=main_resource) + + self.name = cloud_data.get(self._cc('name'), '') + self.display_name = cloud_data.get(self._cc('displayName'), '') + if not self.name: + self.name = self.display_name + self.description = cloud_data.get(self._cc('description'), '') + self.web_url = cloud_data.get(self._cc('webUrl')) + + created = cloud_data.get(self._cc('createdDateTime'), None) + modified = cloud_data.get(self._cc('lastModifiedDateTime'), None) + local_tz = self.protocol.timezone + self.created = parse(created).astimezone(local_tz) if created else None + self.modified = parse(modified).astimezone( + local_tz) if modified else None + + created_by = cloud_data.get(self._cc('createdBy'), {}).get('user', None) + self.created_by = (Contact(con=self.con, protocol=self.protocol, + **{self._cloud_data_key: created_by}) + if created_by else None) + modified_by = cloud_data.get(self._cc('lastModifiedBy'), {}).get('user', + None) + self.modified_by = (Contact(con=self.con, protocol=self.protocol, + **{self._cloud_data_key: modified_by}) + if modified_by else None) + + # list info + lst_info = cloud_data.get('list', {}) + self.content_types_enabled = lst_info.get( + self._cc('contentTypesEnabled'), False) + self.hidden = lst_info.get(self._cc('hidden'), False) + self.template = lst_info.get(self._cc('template'), False) + + # Crosswalk between display name of user defined columns to internal name + self.column_name_cw = {col.display_name: col.internal_name for + col in self.get_list_columns() if not col.read_only}
+ + def __eq__(self, other): + return self.object_id == other.object_id + +
[docs] def get_items(self, limit=None, *, query=None, order_by=None, batch=None): + """ Returns a collection of Sharepoint Items + :param int limit: max no. of items to get. Over 999 uses batch. + :param query: applies a filter to the request. + :type query: Query or str + :param order_by: orders the result set based on this condition + :type order_by: Query or str + :param int batch: batch size, retrieves items in + batches allowing to retrieve more items than the limit. + :return: list of Sharepoint Items + :rtype: list[SharepointListItem] or Pagination + """ + + url = self.build_url(self._endpoints.get('get_items')) + + if limit is None or limit > self.protocol.max_top_value: + batch = self.protocol.max_top_value + + params = {'$top': batch if batch else limit} + + if order_by: + params['$orderby'] = order_by + + if query: + if isinstance(query, str): + params['$filter'] = query + else: + params.update(query.as_params()) + + response = self.con.get(url, params=params) + + if not response: + return [] + + data = response.json() + next_link = data.get(NEXT_LINK_KEYWORD, None) + + items = [self.list_item_constructor(parent=self, **{self._cloud_data_key: item}) + for item in data.get('value', [])] + + if batch and next_link: + return Pagination(parent=self, data=items, constructor=self.list_item_constructor, + next_link=next_link, limit=limit) + else: + return items
+ +
[docs] def get_item_by_id(self, item_id): + """ Returns a sharepoint list item based on id""" + + url = self.build_url(self._endpoints.get('get_item_by_id').format(item_id=item_id)) + + response = self.con.get(url) + + if not response: + return [] + + data = response.json() + + return self.list_item_constructor(parent=self, **{self._cloud_data_key: data})
+ +
[docs] def get_list_columns(self): + """ Returns the sharepoint list columns """ + + url = self.build_url(self._endpoints.get('get_list_columns')) + + response = self.con.get(url) + + if not response: + return [] + + data = response.json() + + return [self.list_column_constructor(parent=self, **{self._cloud_data_key: column}) + for column in data.get('value', [])]
+ +
[docs] def create_list_item(self, new_data): + """Create new list item + + :param new_data: dictionary of {'col_name': col_value} + + :rtype: SharepointListItem + """ + + url = self.build_url(self._endpoints.get('get_items')) + + response = self.con.post(url, {'fields': new_data}) + if not response: + return False + + data = response.json() + + return self.list_item_constructor(parent=self, **{self._cloud_data_key: data})
+ +
[docs] def delete_list_item(self, item_id): + """ Delete an existing list item + + :param item_id: Id of the item to be delted + """ + + url = self.build_url(self._endpoints.get('get_item_by_id').format(item_id=item_id)) + + response = self.con.delete(url) + + return bool(response)
+ + +
[docs]class Site(ApiComponent): + """ A Sharepoint Site """ + + _endpoints = { + 'get_subsites': '/sites', + 'get_lists': '/lists', + 'get_list_by_name': '/lists/{display_name}' + } + list_constructor = SharepointList + +
[docs] def __init__(self, *, parent=None, con=None, **kwargs): + """ A Sharepoint site List + + :param parent: parent object + :type parent: Sharepoint + :param Connection con: connection to use if no parent specified + :param Protocol protocol: protocol to use if no parent specified + (kwargs) + :param str main_resource: use this resource instead of parent resource + (kwargs) + """ + if parent and con: + raise ValueError('Need a parent or a connection but not both') + self.con = parent.con if parent else con + + cloud_data = kwargs.get(self._cloud_data_key, {}) + + self.object_id = cloud_data.get('id') + + # Choose the main_resource passed in kwargs over parent main_resource + main_resource = kwargs.pop('main_resource', None) or ( + getattr(parent, 'main_resource', None) if parent else None) + + # prefix with the current known site + resource_prefix = 'sites/{site_id}'.format(site_id=self.object_id) + main_resource = (resource_prefix if isinstance(parent, Site) + else '{}{}'.format(main_resource, resource_prefix)) + + super().__init__( + protocol=parent.protocol if parent else kwargs.get('protocol'), + main_resource=main_resource) + + self.root = 'root' in cloud_data # True or False + # Fallback to manual site + self.name = cloud_data.get(self._cc('name'), kwargs.get('name', '')) + self.display_name = cloud_data.get(self._cc('displayName'), '') + if not self.name: + self.name = self.display_name + self.description = cloud_data.get(self._cc('description'), '') + self.web_url = cloud_data.get(self._cc('webUrl')) + + created = cloud_data.get(self._cc('createdDateTime'), None) + modified = cloud_data.get(self._cc('lastModifiedDateTime'), None) + local_tz = self.protocol.timezone + self.created = parse(created).astimezone(local_tz) if created else None + self.modified = parse(modified).astimezone( + local_tz) if modified else None + + # site storage to access Drives and DriveItems + self.site_storage = Storage(parent=self, + main_resource='/sites/{id}'.format( + id=self.object_id))
+ + def __str__(self): + return self.__repr__() + + def __repr__(self): + return 'Site: {}'.format(self.name) + + def __eq__(self, other): + return self.object_id == other.object_id + +
[docs] def get_default_document_library(self, request_drive=False): + """ Returns the default document library of this site (Drive instance) + + :param request_drive: True will make an api call to retrieve + the drive data + :rtype: Drive + """ + return self.site_storage.get_default_drive(request_drive=request_drive)
+ +
[docs] def get_document_library(self, drive_id): + """ Returns a Document Library (a Drive instance) + + :param drive_id: the drive_id to be retrieved. + :rtype: Drive + """ + return self.site_storage.get_drive(drive_id=drive_id)
+ +
[docs] def list_document_libraries(self): + """ Returns a collection of document libraries for this site + (a collection of Drive instances) + :return: list of items in this folder + :rtype: list[Drive] or Pagination + """ + return self.site_storage.get_drives()
+ +
[docs] def get_subsites(self): + """ Returns a list of subsites defined for this site + + :rtype: list[Site] + """ + url = self.build_url( + self._endpoints.get('get_subsites').format(id=self.object_id)) + + response = self.con.get(url) + if not response: + return [] + + data = response.json() + + # Everything received from cloud must be passed as self._cloud_data_key + return [self.__class__(parent=self, **{self._cloud_data_key: site}) for + site in data.get('value', [])]
+ +
[docs] def get_lists(self): + """ Returns a collection of lists within this site + + :rtype: list[SharepointList] + """ + url = self.build_url(self._endpoints.get('get_lists')) + + response = self.con.get(url) + if not response: + return [] + + data = response.json() + + return [self.list_constructor(parent=self, **{self._cloud_data_key: lst}) for lst in data.get('value', [])]
+ +
[docs] def get_list_by_name(self, display_name): + """ + Returns a sharepoint list based on the display name of the list + """ + + if not display_name: + raise ValueError('Must provide a valid list display name') + + url = self.build_url(self._endpoints.get('get_list_by_name').format(display_name=display_name)) + + response = self.con.get(url) + if not response: + return [] + + data = response.json() + + return self.list_constructor(parent=self, **{self._cloud_data_key: data})
+ +
[docs] def create_list(self, list_data): + """ + Creates a SharePoint list. + :param list_data: Dict representation of list. + :type list_data: Dict + :rtype: list[SharepointList] + """ + url = self.build_url(self._endpoints.get('get_lists')) + response = self.con.post(url, data=list_data) + + if not response: + return None + + data = response.json() + return self.list_constructor(parent=self, **{self._cloud_data_key: data})
+ + +
[docs]class Sharepoint(ApiComponent): + """ A Sharepoint parent class to group functionality """ + + _endpoints = { + 'get_site': '/sites/{id}', + 'search': '/sites?search={keyword}' + } + site_constructor = Site + +
[docs] def __init__(self, *, parent=None, con=None, **kwargs): + """ A Sharepoint site List + + :param parent: parent object + :type parent: Account + :param Connection con: connection to use if no parent specified + :param Protocol protocol: protocol to use if no parent specified + (kwargs) + :param str main_resource: use this resource instead of parent resource + (kwargs) + """ + if parent and con: + raise ValueError('Need a parent or a connection but not both') + self.con = parent.con if parent else con + + # Choose the main_resource passed in kwargs over the host_name + main_resource = kwargs.pop('main_resource', + '') # defaults to blank resource + super().__init__( + protocol=parent.protocol if parent else kwargs.get('protocol'), + main_resource=main_resource)
+ + def __str__(self): + return self.__repr__() + + def __repr__(self): + return 'Sharepoint' + +
[docs] def search_site(self, keyword): + """ Search a sharepoint host for sites with the provided keyword + + :param keyword: a keyword to search sites + :rtype: list[Site] + """ + if not keyword: + raise ValueError('Must provide a valid keyword') + + url = self.build_url( + self._endpoints.get('search').format(keyword=keyword)) + + response = self.con.get(url) + if not response: + return [] + + data = response.json() + + # Everything received from cloud must be passed as self._cloud_data_key + return [ + self.site_constructor(parent=self, **{self._cloud_data_key: site}) + for site in data.get('value', [])]
+ +
[docs] def get_root_site(self): + """ Returns the root site + + :rtype: Site + """ + return self.get_site('root')
+ +
[docs] def get_site(self, *args): + """ Returns a sharepoint site + + :param args: It accepts multiple ways of retrieving a site: + + get_site(host_name): the host_name: host_name ej. + 'contoso.sharepoint.com' or 'root' + + get_site(site_id): the site_id: a comma separated string of + (host_name, site_collection_id, site_id) + + get_site(host_name, path_to_site): host_name ej. 'contoso. + sharepoint.com', path_to_site: a url path (with a leading slash) + + get_site(host_name, site_collection_id, site_id): + host_name ej. 'contoso.sharepoint.com' + :rtype: Site + """ + num_args = len(args) + if num_args == 1: + site = args[0] + elif num_args == 2: + host_name, path_to_site = args + path_to_site = '/' + path_to_site if not path_to_site.startswith( + '/') else path_to_site + site = '{}:{}:'.format(host_name, path_to_site) + elif num_args == 3: + site = ','.join(args) + else: + raise ValueError('Incorrect number of arguments') + + url = self.build_url(self._endpoints.get('get_site').format(id=site)) + + response = self.con.get(url) + if not response: + return None + + data = response.json() + + return self.site_constructor(parent=self, + **{self._cloud_data_key: data})
+
+ +
+ +
+ + +
+
+ +
+ +
+ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/O365/subscriptions.html b/docs/latest/_modules/O365/subscriptions.html new file mode 100644 index 00000000..e79d9fef --- /dev/null +++ b/docs/latest/_modules/O365/subscriptions.html @@ -0,0 +1,410 @@ + + + + + + + + O365.subscriptions — O365 documentation + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+ +
+
+
+
+ +

Source code for O365.subscriptions

+import datetime as dt
+from typing import Iterable, Mapping, Optional, Union
+
+from .utils import ApiComponent, NEXT_LINK_KEYWORD, Pagination
+
+
+
+[docs] +class Subscriptions(ApiComponent): + """Subscription operations for Microsoft Graph webhooks.""" + + _endpoints = { + "subscriptions": "/subscriptions", + } + +
+[docs] + def __init__(self, *, parent=None, con=None, **kwargs): + if parent and con: + raise ValueError("Need a parent or a connection but not both") + self.con = parent.con if parent else con + + main_resource = kwargs.pop("main_resource", None) or ( + getattr(parent, "main_resource", None) if parent else None + ) + + super().__init__( + protocol=parent.protocol if parent else kwargs.get("protocol"), + main_resource=main_resource, + )
+ + + def _build_subscription_url(self, subscription_id: Optional[str] = None) -> str: + """Build the Microsoft Graph subscriptions endpoint.""" + endpoint = self._endpoints.get("subscriptions") + if endpoint is None: + raise ValueError("Subscriptions endpoint is not configured.") + base_url = self.protocol.service_url.rstrip("/") + if subscription_id: + return f"{base_url}{endpoint}/{subscription_id}" + return f"{base_url}{endpoint}" + + @staticmethod + def _format_subscription_expiration( + expiration_datetime: Optional[dt.datetime] = None, + expiration_minutes: Optional[int] = None, + ) -> str: + """Return an ISO 8601 UTC expiration string as required by Graph webhooks.""" + if expiration_datetime and expiration_minutes is not None: + raise ValueError( + "Provide either expiration_datetime or expiration_minutes, not both." + ) + if expiration_datetime is None: + minutes = expiration_minutes if expiration_minutes is not None else 60 + if minutes <= 0: + raise ValueError("expiration_minutes must be a positive integer.") + expiration_datetime = dt.datetime.now(dt.timezone.utc) + dt.timedelta( + minutes=minutes + ) + else: + if expiration_datetime.tzinfo is None: + expiration_datetime = expiration_datetime.replace(tzinfo=dt.timezone.utc) + else: + expiration_datetime = expiration_datetime.astimezone(dt.timezone.utc) + return expiration_datetime.isoformat(timespec="microseconds").replace("+00:00", "Z") + + @staticmethod + def _stringify_change_type(change_type: Union[str, Iterable[str]]) -> str: + """Normalize changeType into the comma-separated string Graph expects.""" + if isinstance(change_type, str): + value = change_type.strip() + else: + try: + parts = [str(part).strip() for part in change_type] + except TypeError as exc: + raise ValueError( + "change_type must be a string or an iterable of strings." + ) from exc + value = ",".join(part for part in parts if part) + if not value: + raise ValueError("change_type must contain at least one value.") + return value + +
+[docs] + def get_subscription( + self, + subscription_id: str, + *, + params: Optional[Mapping[str, object]] = None, + **request_kwargs, + ) -> Optional[dict]: + """Retrieve a single webhook subscription by id.""" + if not subscription_id: + raise ValueError("subscription_id must be provided.") + if params is not None and not isinstance(params, Mapping): + raise ValueError("params must be a mapping if provided.") + + url = self._build_subscription_url(subscription_id) + response = self.con.get(url, params=params, **request_kwargs) + + if not response: + return None + + return response.json()
+ + +
+[docs] + def create_subscription( + self, + notification_url: str, + resource: Optional[str] = None, + change_type: Union[str, Iterable[str]] = "created", + *, + expiration_datetime: Optional[dt.datetime] = None, + expiration_minutes: Optional[int] = None, + client_state: Optional[str] = None, + include_resource_data: Optional[bool] = None, + encryption_certificate: Optional[str] = None, + encryption_certificate_id: Optional[str] = None, + lifecycle_notification_url: Optional[str] = None, + latest_supported_tls_version: Optional[str] = None, + additional_data: Optional[Mapping[str, object]] = None, + **request_kwargs, + ) -> Optional[dict]: + """Create a Microsoft Graph webhook subscription. + + See subscriptions usage documentation for webhook setup requirements. + """ + if not notification_url: + raise ValueError("notification_url must be provided.") + + resource = resource or self.main_resource + if not resource: + raise ValueError("resource must be provided.") + if not resource.startswith("/"): + resource = f"/{resource}" + + expiration_value = self._format_subscription_expiration( + expiration_datetime=expiration_datetime, + expiration_minutes=expiration_minutes, + ) + change_type_value = self._stringify_change_type(change_type) + + payload = { + self._cc("change_type"): change_type_value, + self._cc("notification_url"): notification_url, + self._cc("resource"): resource, + self._cc("expiration_date_time"): expiration_value, + } + + if client_state is not None: + payload[self._cc("client_state")] = client_state + if include_resource_data is not None: + payload[self._cc("include_resource_data")] = include_resource_data + if encryption_certificate is not None: + payload[self._cc("encryption_certificate")] = encryption_certificate + if encryption_certificate_id is not None: + payload[self._cc("encryption_certificate_id")] = encryption_certificate_id + if lifecycle_notification_url is not None: + payload[self._cc("lifecycle_notification_url")] = lifecycle_notification_url + if latest_supported_tls_version is not None: + payload[ + self._cc("latest_supported_tls_version") + ] = latest_supported_tls_version + if additional_data: + if not isinstance(additional_data, Mapping): + raise ValueError("additional_data must be a mapping if provided.") + payload.update({str(key): value for key, value in additional_data.items()}) + + url = self._build_subscription_url() + response = self.con.post(url, data=payload, **request_kwargs) + + if not response: + return None + + return response.json()
+ + +
+[docs] + def list_subscriptions( + self, + *, + limit: Optional[int] = None, + **request_kwargs, + ) -> Union[Iterable[dict], Pagination]: + """List webhook subscriptions visible to the current app/context.""" + if limit is not None and limit <= 0: + raise ValueError("limit must be a positive integer.") + + url = self._build_subscription_url() + response = self.con.get(url, **request_kwargs) + if not response: + return iter(()) + + data = response.json() + subscriptions = data.get("value", []) + next_link = data.get(NEXT_LINK_KEYWORD) + + if next_link: + return Pagination( + parent=self, + data=subscriptions, + next_link=next_link, + limit=limit, + ) + + if limit is not None: + return subscriptions[:limit] + + return subscriptions
+ + +
+[docs] + def renew_subscription( + self, + subscription_id: str, + *, + expiration_datetime: Optional[dt.datetime] = None, + expiration_minutes: Optional[int] = None, + **request_kwargs, + ) -> Optional[dict]: + """Renew an existing webhook subscription.""" + if not subscription_id: + raise ValueError("subscription_id must be provided.") + + expiration_value = self._format_subscription_expiration( + expiration_datetime=expiration_datetime, + expiration_minutes=expiration_minutes, + ) + + payload = { + self._cc("expiration_date_time"): expiration_value, + } + + url = self._build_subscription_url(subscription_id) + response = self.con.patch(url, data=payload, **request_kwargs) + + if not response: + return None + + return response.json()
+ + +
+[docs] + def update_subscription( + self, + subscription_id: str, + *, + notification_url: Optional[str] = None, + expiration_datetime: Optional[dt.datetime] = None, + expiration_minutes: Optional[int] = None, + **request_kwargs, + ) -> Optional[dict]: + """Update subscription fields (expiration and/or notification URL).""" + if not subscription_id: + raise ValueError("subscription_id must be provided.") + + payload = {} + + if expiration_datetime is not None or expiration_minutes is not None: + payload[self._cc("expiration_date_time")] = self._format_subscription_expiration( + expiration_datetime=expiration_datetime, + expiration_minutes=expiration_minutes, + ) + + if notification_url is not None: + if not notification_url: + raise ValueError("notification_url, if provided, cannot be empty.") + payload[self._cc("notification_url")] = notification_url + + if not payload: + raise ValueError("At least one of expiration or notification_url must be provided.") + + url = self._build_subscription_url(subscription_id) + response = self.con.patch(url, data=payload, **request_kwargs) + + if not response: + return None + + return response.json()
+ + +
+[docs] + def delete_subscription( + self, + subscription_id: str, + **request_kwargs, + ) -> bool: + """Delete an existing webhook subscription.""" + if not subscription_id: + raise ValueError("subscription_id must be provided.") + + url = self._build_subscription_url(subscription_id) + response = self.con.delete(url, **request_kwargs) + + return bool(response)
+
+ +
+ +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/docs/latest/_modules/O365/tasks.html b/docs/latest/_modules/O365/tasks.html new file mode 100644 index 00000000..0b04c0d8 --- /dev/null +++ b/docs/latest/_modules/O365/tasks.html @@ -0,0 +1,984 @@ + + + + + + + + O365.tasks — O365 documentation + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+ +
+
+
+
+ +

Source code for O365.tasks

+"""Methods for accessing MS Tasks/Todos via the MS Graph api."""
+
+import datetime as dt
+import logging
+
+# noinspection PyPep8Naming
+from bs4 import BeautifulSoup as bs
+from dateutil.parser import parse
+
+from .utils import ApiComponent, TrackerSet
+
+log = logging.getLogger(__name__)
+
+CONST_FOLDER = "folder"
+CONST_GET_FOLDER = "get_folder"
+CONST_GET_TASK = "get_task"
+CONST_GET_TASKS = "get_tasks"
+CONST_ROOT_FOLDERS = "root_folders"
+CONST_TASK = "task"
+CONST_TASK_FOLDER = "task_folder"
+
+
+
+[docs] +class Task(ApiComponent): + """A Microsoft To-Do task.""" + + _endpoints = { + CONST_TASK: "/todo/lists/{folder_id}/tasks/{id}", + CONST_TASK_FOLDER: "/todo/lists/{folder_id}/tasks", + } + +
+[docs] + def __init__(self, *, parent=None, con=None, **kwargs): + """Representation of a Microsoft To-Do task. + + :param parent: parent object + :type parent: Folder + :param Connection con: connection to use if no parent specified + :param Protocol protocol: protocol to use if no parent specified + (kwargs) + :param str main_resource: use this resource instead of parent resource + (kwargs) + :param str folder_id: id of the calender to add this task in + (kwargs) + :param str subject: subject of the task (kwargs) + """ + if parent and con: + raise ValueError("Need a parent or a connection but not both") + self.con = parent.con if parent else con + + cloud_data = kwargs.get(self._cloud_data_key, {}) + + self.task_id = cloud_data.get("id") + + # Choose the main_resource passed in kwargs over parent main_resource + main_resource = kwargs.pop("main_resource", None) or ( + getattr(parent, "main_resource", None) if parent else None + ) + + super().__init__( + protocol=parent.protocol if parent else kwargs.get("protocol"), + main_resource=main_resource, + ) + + cc = self._cc # pylint: disable=invalid-name + # internal to know which properties need to be updated on the server + self._track_changes = TrackerSet(casing=cc) + self.folder_id = kwargs.get("folder_id") + cloud_data = kwargs.get(self._cloud_data_key, {}) + + self.task_id = cloud_data.get(cc("id"), None) + self.__subject = cloud_data.get(cc("title"), kwargs.get("subject", "") or "") + body = cloud_data.get(cc("body"), {}) + self.__body = body.get(cc("content"), "") + self.body_type = body.get( + cc("contentType"), "html" + ) # default to HTML for new messages + + self.__created = cloud_data.get(cc("createdDateTime"), None) + self.__modified = cloud_data.get(cc("lastModifiedDateTime"), None) + self.__status = cloud_data.get(cc("status"), None) + self.__is_completed = self.__status == "completed" + self.__importance = cloud_data.get(cc("importance"), None) + + local_tz = self.protocol.timezone + self.__created = ( + parse(self.__created).astimezone(local_tz) if self.__created else None + ) + self.__modified = ( + parse(self.__modified).astimezone(local_tz) if self.__modified else None + ) + + due_obj = cloud_data.get(cc("dueDateTime"), {}) + self.__due = self._parse_date_time_time_zone(due_obj) + + reminder_obj = cloud_data.get(cc("reminderDateTime"), {}) + self.__reminder = self._parse_date_time_time_zone(reminder_obj) + self.__is_reminder_on = cloud_data.get(cc("isReminderOn"), False) + + completed_obj = cloud_data.get(cc("completedDateTime"), {}) + self.__completed = self._parse_date_time_time_zone(completed_obj)
+ + + def __str__(self): + """Representation of the Task via the Graph api as a string.""" + return self.__repr__() + + def __repr__(self): + """Representation of the Task via the Graph api.""" + marker = "x" if self.__is_completed else "o" + if self.__due: + due_str = f"(due: {self.__due.date()} at {self.__due.time()}) " + else: + due_str = "" + + if self.__completed: + compl_str = ( + f"(completed: {self.__completed.date()} at {self.__completed.time()}) " + ) + + else: + compl_str = "" + + return f"Task: ({marker}) {self.__subject} {due_str} {compl_str}" + + def __eq__(self, other): + """Comparison of tasks.""" + return self.task_id == other.task_id + +
+[docs] + def to_api_data(self, restrict_keys=None): + """Return a dict to communicate with the server. + + :param restrict_keys: a set of keys to restrict the returned data to + :rtype: dict + """ + cc = self._cc # pylint: disable=invalid-name + + data = { + cc("title"): self.__subject, + cc("status"): "completed" if self.__is_completed else "notStarted", + } + + if self.__body: + data[cc("body")] = { + cc("contentType"): self.body_type, + cc("content"): self.__body, + } + else: + data[cc("body")] = None + + if self.__due: + data[cc("dueDateTime")] = self._build_date_time_time_zone(self.__due) + else: + data[cc("dueDateTime")] = None + + if self.__reminder: + data[cc("reminderDateTime")] = self._build_date_time_time_zone( + self.__reminder + ) + else: + data[cc("reminderDateTime")] = None + + if self.__completed: + data[cc("completedDateTime")] = self._build_date_time_time_zone( + self.__completed + ) + + if restrict_keys: + for key in list(data.keys()): + if key not in restrict_keys: + del data[key] + return data
+ + + @property + def created(self): + """Return Created time of the task. + + :rtype: datetime + """ + return self.__created + + @property + def modified(self): + """Return Last modified time of the task. + + :rtype: datetime + """ + return self.__modified + + @property + def body(self): + """Return Body of the task. + + :getter: Get body text + :setter: Set body of task + :type: str + """ + return self.__body + + @body.setter + def body(self, value): + self.__body = value + self._track_changes.add(self._cc("body")) + + @property + def importance(self): + """Return Task importance. + + :getter: Get importance level (Low, Normal, High) + :type: str + """ + return self.__importance + + @property + def is_starred(self): + """Is the task starred (high importance). + + :getter: Check if importance is high + :type: bool + """ + return self.__importance.casefold() == "high".casefold() + + @property + def subject(self): + """Subject of the task. + + :getter: Get subject + :setter: Set subject of task + :type: str + """ + return self.__subject + + @subject.setter + def subject(self, value): + self.__subject = value + self._track_changes.add(self._cc("title")) + + @property + def due(self): + """Due Time of task. + + :getter: get the due time + :setter: set the due time + :type: datetime + """ + return self.__due + + @due.setter + def due(self, value): + if value: + if not isinstance(value, dt.date): + raise ValueError("'due' must be a valid datetime object") + if not isinstance(value, dt.datetime): + # force datetime + value = dt.datetime(value.year, value.month, value.day) + if value.tzinfo is None: + # localize datetime + value = value.replace(tzinfo=self.protocol.timezone) + elif value.tzinfo != self.protocol.timezone: + value = value.astimezone(self.protocol.timezone) + self.__due = value + self._track_changes.add(self._cc("dueDateTime")) + + @property + def reminder(self): + """Reminder Time of task. + + :getter: get the reminder time + :setter: set the reminder time + :type: datetime + """ + return self.__reminder + + @reminder.setter + def reminder(self, value): + if value: + if not isinstance(value, dt.date): + raise ValueError("'reminder' must be a valid datetime object") + if not isinstance(value, dt.datetime): + # force datetime + value = dt.datetime(value.year, value.month, value.day) + if value.tzinfo is None: + # localize datetime + value = value.replace(tzinfo=self.protocol.timezone) + elif value.tzinfo != self.protocol.timezone: + value = value.astimezone(self.protocol.timezone) + self.__reminder = value + self._track_changes.add(self._cc("reminderDateTime")) + + @property + def is_reminder_on(self): + """Return isReminderOn of the task. + + :getter: Get isReminderOn + :type: bool + """ + return self.__is_reminder_on + + @property + def status(self): + """Status of task + + :getter: get status + :type: string + """ + return self.__status + + @property + def completed(self): + """Completed Time of task. + + :getter: get the completed time + :setter: set the completed time + :type: datetime + """ + return self.__completed + + @completed.setter + def completed(self, value): + if value is None: + self.mark_uncompleted() + else: + if not isinstance(value, dt.date): + raise ValueError("'completed' must be a valid datetime object") + if not isinstance(value, dt.datetime): + # force datetime + value = dt.datetime(value.year, value.month, value.day) + if value.tzinfo is None: + # localize datetime + value = value.replace(tzinfo=self.protocol.timezone) + elif value.tzinfo != self.protocol.timezone: + value = value.astimezone(self.protocol.timezone) + self.mark_completed() + + self.__completed = value + self._track_changes.add(self._cc("completedDateTime")) + + @property + def is_completed(self): + """Is task completed or not. + + :getter: Is completed + :setter: set the task to completted + :type: bool + """ + return self.__is_completed + +
+[docs] + def mark_completed(self): + """Mark the ask as completed.""" + self.__is_completed = True + self._track_changes.add(self._cc("status"))
+ + +
+[docs] + def mark_uncompleted(self): + """Mark the task as uncompleted.""" + self.__is_completed = False + self._track_changes.add(self._cc("status"))
+ + +
+[docs] + def delete(self): + """Delete a stored task. + + :return: Success / Failure + :rtype: bool + """ + if self.task_id is None: + raise RuntimeError("Attempting to delete an unsaved task") + + url = self.build_url( + self._endpoints.get(CONST_TASK).format( + folder_id=self.folder_id, id=self.task_id + ) + ) + + response = self.con.delete(url) + + return bool(response)
+ + +
+[docs] + def save(self): + """Create a new task or update an existing one. + + Does update by checking what values have changed and update them on the server + :return: Success / Failure + :rtype: bool + """ + if self.task_id: + # update task + if not self._track_changes: + return True # there's nothing to update + url = self.build_url( + self._endpoints.get(CONST_TASK).format( + folder_id=self.folder_id, id=self.task_id + ) + ) + method = self.con.patch + data = self.to_api_data(restrict_keys=self._track_changes) + else: + # new task + url = self.build_url( + self._endpoints.get(CONST_TASK_FOLDER).format(folder_id=self.folder_id) + ) + + method = self.con.post + data = self.to_api_data() + + response = method(url, data=data) + if not response: + return False + + self._track_changes.clear() # clear the tracked changes + + if not self.task_id: + # new task + task = response.json() + + self.task_id = task.get(self._cc("id"), None) + + self.__created = task.get(self._cc("createdDateTime"), None) + self.__modified = task.get(self._cc("lastModifiedDateTime"), None) + self.__completed = task.get(self._cc("completed"), None) + + self.__created = ( + parse(self.__created).astimezone(self.protocol.timezone) + if self.__created + else None + ) + self.__modified = ( + parse(self.__modified).astimezone(self.protocol.timezone) + if self.__modified + else None + ) + self.__is_completed = task.get(self._cc("status"), None) == "completed" + else: + self.__modified = dt.datetime.now().replace(tzinfo=self.protocol.timezone) + + return True
+ + +
+[docs] + def get_body_text(self): + """Parse the body html and returns the body text using bs4. + + :return: body text + :rtype: str + """ + if self.body_type != "html": + return self.body + + try: + soup = bs(self.body, "html.parser") + except RuntimeError: + return self.body + else: + return soup.body.text
+ + +
+[docs] + def get_body_soup(self): + """Return the beautifulsoup4 of the html body. + + :return: Html body + :rtype: BeautifulSoup + """ + return bs(self.body, "html.parser") if self.body_type == "html" else None
+
+ + + +
+[docs] +class Folder(ApiComponent): + """A Microsoft To-Do folder.""" + + _endpoints = { + CONST_FOLDER: "/todo/lists/{id}", + CONST_GET_TASKS: "/todo/lists/{id}/tasks", + CONST_GET_TASK: "/todo/lists/{id}/tasks/{ide}", + } + task_constructor = Task + +
+[docs] + def __init__(self, *, parent=None, con=None, **kwargs): + """Representation of a Microsoft To-Do Folder. + + :param parent: parent object + :type parent: ToDo + :param Connection con: connection to use if no parent specified + :param Protocol protocol: protocol to use if no parent specified + (kwargs) + :param str main_resource: use this resource instead of parent resource + (kwargs) + """ + if parent and con: + raise ValueError("Need a parent or a connection but not both") + self.con = parent.con if parent else con + + # Choose the main_resource passed in kwargs over parent main_resource + main_resource = kwargs.pop("main_resource", None) or ( + getattr(parent, "main_resource", None) if parent else None + ) + + super().__init__( + protocol=parent.protocol if parent else kwargs.get("protocol"), + main_resource=main_resource, + ) + + cloud_data = kwargs.get(self._cloud_data_key, {}) + + self.name = cloud_data.get(self._cc("displayName"), "") + self.folder_id = cloud_data.get(self._cc("id"), None) + self.is_default = False + if cloud_data.get(self._cc("wellknownListName"), "") == "defaultList": + self.is_default = True
+ + + def __str__(self): + """Representation of the Folder via the Graph api as a string.""" + return self.__repr__() + + def __repr__(self): + """Representation of the folder via the Graph api.""" + suffix = " (default)" if self.is_default else "" + return f"Folder: {self.name}{suffix}" + + def __eq__(self, other): + """Comparison of folders.""" + return self.folder_id == other.folder_id + +
+[docs] + def update(self): + """Update this folder. Only name can be changed. + + :return: Success / Failure + :rtype: bool + """ + if not self.folder_id: + return False + + url = self.build_url( + self._endpoints.get(CONST_FOLDER).format(id=self.folder_id) + ) + + data = { + self._cc("displayName"): self.name, + } + + response = self.con.patch(url, data=data) + + return bool(response)
+ + +
+[docs] + def delete(self): + """Delete this folder. + + :return: Success / Failure + :rtype: bool + """ + if not self.folder_id: + return False + + url = self.build_url( + self._endpoints.get(CONST_FOLDER).format(id=self.folder_id) + ) + + response = self.con.delete(url) + if not response: + return False + + self.folder_id = None + + return True
+ + +
+[docs] + def get_tasks(self, query=None, batch=None, order_by=None): + """Return list of tasks of a specified folder. + + :param query: the query string or object to query tasks + :param batch: the batch on to retrieve tasks. + :param order_by: the order clause to apply to returned tasks. + + :rtype: tasks + """ + url = self.build_url( + self._endpoints.get(CONST_GET_TASKS).format(id=self.folder_id) + ) + + # get tasks by the folder id + params = {} + if batch: + params["$top"] = batch + + if order_by: + params["$orderby"] = order_by + + if query: + if isinstance(query, str): + params["$filter"] = query + else: + params |= query.as_params() + + response = self.con.get(url, params=params) + + if not response: + return iter(()) + + data = response.json() + + return ( + self.task_constructor(parent=self, **{self._cloud_data_key: task}) + for task in data.get("value", []) + )
+ + +
+[docs] + def new_task(self, subject=None): + """Create a task within a specified folder.""" + return self.task_constructor( + parent=self, subject=subject, folder_id=self.folder_id + )
+ + +
+[docs] + def get_task(self, param): + """Return a Task instance by it's id. + + :param param: an task_id or a Query instance + :return: task for the specified info + :rtype: Event + """ + if param is None: + return None + if isinstance(param, str): + url = self.build_url( + self._endpoints.get(CONST_GET_TASK).format(id=self.folder_id, ide=param) + ) + params = None + by_id = True + else: + url = self.build_url( + self._endpoints.get(CONST_GET_TASKS).format(id=self.folder_id) + ) + params = {"$top": 1} + params |= param.as_params() + by_id = False + + response = self.con.get(url, params=params) + + if not response: + return None + + if by_id: + task = response.json() + else: + task = response.json().get("value", []) + if task: + task = task[0] + else: + return None + return self.task_constructor(parent=self, **{self._cloud_data_key: task})
+
+ + + +
+[docs] +class ToDo(ApiComponent): + """A of Microsoft To-Do class for MS Graph API. + + In order to use the API following permissions are required. + Delegated (work or school account) - Tasks.Read, Tasks.ReadWrite + """ + + _endpoints = { + CONST_ROOT_FOLDERS: "/todo/lists", + CONST_GET_FOLDER: "/todo/lists/{id}", + } + + folder_constructor = Folder + task_constructor = Task + +
+[docs] + def __init__(self, *, parent=None, con=None, **kwargs): + """Initialise the ToDo object. + + :param parent: parent object + :type parent: Account + :param Connection con: connection to use if no parent specified + :param Protocol protocol: protocol to use if no parent specified + (kwargs) + :param str main_resource: use this resource instead of parent resource + (kwargs) + """ + if parent and con: + raise ValueError("Need a parent or a connection but not both") + self.con = parent.con if parent else con + + # Choose the main_resource passed in kwargs over parent main_resource + main_resource = kwargs.pop("main_resource", None) or ( + getattr(parent, "main_resource", None) if parent else None + ) + + super().__init__( + protocol=parent.protocol if parent else kwargs.get("protocol"), + main_resource=main_resource, + )
+ + + def __str__(self): + """Representation of the ToDo via the Graph api as a string.""" + return self.__repr__() + + def __repr__(self): + """Representation of the ToDo via the Graph api as.""" + return "Microsoft To-Do" + +
+[docs] + def list_folders(self, query=None, limit=None): + """Return a list of folders. + + To use query an order_by check the OData specification here: + https://docs.oasis-open.org/odata/odata/v4.0/errata03/os/complete/ + part2-url-conventions/odata-v4.0-errata03-os-part2-url-conventions + -complete.html + :param query: the query string or object to list folders + :param int limit: max no. of folders to get. Over 999 uses batch. + :rtype: list[Folder] + """ + url = self.build_url(self._endpoints.get(CONST_ROOT_FOLDERS)) + + params = {} + if limit: + params["$top"] = limit + + if query: + if isinstance(query, str): + params["$filter"] = query + else: + params |= query.as_params() + + response = self.con.get(url, params=params or None) + if not response: + return [] + + data = response.json() + + return [ + self.folder_constructor(parent=self, **{self._cloud_data_key: x}) + for x in data.get("value", []) + ]
+ + +
+[docs] + def new_folder(self, folder_name): + """Create a new folder. + + :param str folder_name: name of the new folder + :return: a new Calendar instance + :rtype: Calendar + """ + if not folder_name: + return None + + url = self.build_url(self._endpoints.get(CONST_ROOT_FOLDERS)) + + response = self.con.post(url, data={self._cc("displayName"): folder_name}) + if not response: + return None + + data = response.json() + + # Everything received from cloud must be passed as self._cloud_data_key + return self.folder_constructor(parent=self, **{self._cloud_data_key: data})
+ + +
+[docs] + def get_folder(self, folder_id=None, folder_name=None): + """Return a folder by it's id or name. + + :param str folder_id: the folder id to be retrieved. + :param str folder_name: the folder name to be retrieved. + :return: folder for the given info + :rtype: Calendar + """ + if folder_id and folder_name: + raise RuntimeError("Provide only one of the options") + + if not folder_id and not folder_name: + raise RuntimeError("Provide one of the options") + + if folder_id: + url = self.build_url( + self._endpoints.get(CONST_GET_FOLDER).format(id=folder_id) + ) + response = self.con.get(url) + + return ( + self.folder_constructor( + parent=self, **{self._cloud_data_key: response.json()} + ) + if response + else None + ) + + query = self.new_query("displayName").equals(folder_name) + folders = self.list_folders(query=query) + return folders[0]
+ + +
+[docs] + def get_default_folder(self): + """Return the default folder for the current user. + + :rtype: Folder + """ + folders = self.list_folders() + for folder in folders: + if folder.is_default: + return folder
+ + +
+[docs] + def get_tasks(self, batch=None, order_by=None): + """Get tasks from the default Calendar. + + :param order_by: orders the result set based on this condition + :param int batch: batch size, retrieves items in + batches allowing to retrieve more items than the limit. + :return: list of items in this folder + :rtype: list[Event] or Pagination + """ + default_folder = self.get_default_folder() + + return default_folder.get_tasks(order_by=order_by, batch=batch)
+ + +
+[docs] + def new_task(self, subject=None): + """Return a new (unsaved) Event object in the default folder. + + :param str subject: subject text for the new task + :return: new task + :rtype: Event + """ + default_folder = self.get_default_folder() + return default_folder.new_task(subject=subject)
+
+ +
+ +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/docs/latest/_modules/O365/tasks_graph.html b/docs/latest/_modules/O365/tasks_graph.html new file mode 100644 index 00000000..78e38998 --- /dev/null +++ b/docs/latest/_modules/O365/tasks_graph.html @@ -0,0 +1,981 @@ + + + + + + + + O365.tasks_graph — O365 documentation + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+ +
+
+
+
+ +

Source code for O365.tasks_graph

+"""Methods for accessing MS Tasks/Todos via the MS Graph api."""
+
+import datetime as dt
+import logging
+
+# noinspection PyPep8Naming
+from bs4 import BeautifulSoup as bs
+from dateutil.parser import parse
+
+from .utils import ApiComponent, TrackerSet
+
+log = logging.getLogger(__name__)
+
+CONST_FOLDER = "folder"
+CONST_GET_FOLDER = "get_folder"
+CONST_GET_TASK = "get_task"
+CONST_GET_TASKS = "get_tasks"
+CONST_ROOT_FOLDERS = "root_folders"
+CONST_TASK = "task"
+CONST_TASK_FOLDER = "task_folder"
+
+
+
+[docs] +class Task(ApiComponent): + """A Microsoft To-Do task.""" + + _endpoints = { + CONST_TASK: "/todo/lists/{folder_id}/tasks/{id}", + CONST_TASK_FOLDER: "/todo/lists/{folder_id}/tasks", + } + +
+[docs] + def __init__(self, *, parent=None, con=None, **kwargs): + """Representation of a Microsoft To-Do task. + + :param parent: parent object + :type parent: Folder + :param Connection con: connection to use if no parent specified + :param Protocol protocol: protocol to use if no parent specified + (kwargs) + :param str main_resource: use this resource instead of parent resource + (kwargs) + :param str folder_id: id of the calender to add this task in + (kwargs) + :param str subject: subject of the task (kwargs) + """ + if parent and con: + raise ValueError("Need a parent or a connection but not both") + self.con = parent.con if parent else con + + cloud_data = kwargs.get(self._cloud_data_key, {}) + + self.task_id = cloud_data.get("id") + + # Choose the main_resource passed in kwargs over parent main_resource + main_resource = kwargs.pop("main_resource", None) or ( + getattr(parent, "main_resource", None) if parent else None + ) + + super().__init__( + protocol=parent.protocol if parent else kwargs.get("protocol"), + main_resource=main_resource, + ) + + cc = self._cc # pylint: disable=invalid-name + # internal to know which properties need to be updated on the server + self._track_changes = TrackerSet(casing=cc) + self.folder_id = kwargs.get("folder_id") + cloud_data = kwargs.get(self._cloud_data_key, {}) + + self.task_id = cloud_data.get(cc("id"), None) + self.__subject = cloud_data.get(cc("title"), kwargs.get("subject", "") or "") + body = cloud_data.get(cc("body"), {}) + self.__body = body.get(cc("content"), "") + self.body_type = body.get( + cc("contentType"), "html" + ) # default to HTML for new messages + + self.__created = cloud_data.get(cc("createdDateTime"), None) + self.__modified = cloud_data.get(cc("lastModifiedDateTime"), None) + self.__status = cloud_data.get(cc("status"), None) + self.__is_completed = self.__status == "completed" + self.__importance = cloud_data.get(cc("importance"), None) + + local_tz = self.protocol.timezone + self.__created = ( + parse(self.__created).astimezone(local_tz) if self.__created else None + ) + self.__modified = ( + parse(self.__modified).astimezone(local_tz) if self.__modified else None + ) + + due_obj = cloud_data.get(cc("dueDateTime"), {}) + self.__due = self._parse_date_time_time_zone(due_obj) + + reminder_obj = cloud_data.get(cc("reminderDateTime"), {}) + self.__reminder = self._parse_date_time_time_zone(reminder_obj) + self.__is_reminder_on = cloud_data.get(cc("isReminderOn"), False) + + completed_obj = cloud_data.get(cc("completedDateTime"), {}) + self.__completed = self._parse_date_time_time_zone(completed_obj)
+ + + def __str__(self): + """Representation of the Task via the Graph api as a string.""" + return self.__repr__() + + def __repr__(self): + """Representation of the Task via the Graph api.""" + marker = "x" if self.__is_completed else "o" + if self.__due: + due_str = f"(due: {self.__due.date()} at {self.__due.time()}) " + else: + due_str = "" + + if self.__completed: + compl_str = ( + f"(completed: {self.__completed.date()} at {self.__completed.time()}) " + ) + + else: + compl_str = "" + + return f"Task: ({marker}) {self.__subject} {due_str} {compl_str}" + + def __eq__(self, other): + """Comparison of tasks.""" + return self.task_id == other.task_id + +
+[docs] + def to_api_data(self, restrict_keys=None): + """Return a dict to communicate with the server. + + :param restrict_keys: a set of keys to restrict the returned data to + :rtype: dict + """ + cc = self._cc # pylint: disable=invalid-name + + data = { + cc("title"): self.__subject, + cc("status"): "completed" if self.__is_completed else "notStarted", + } + + if self.__body: + data[cc("body")] = { + cc("contentType"): self.body_type, + cc("content"): self.__body, + } + else: + data[cc("body")] = None + + if self.__due: + data[cc("dueDateTime")] = self._build_date_time_time_zone(self.__due) + else: + data[cc("dueDateTime")] = None + + if self.__reminder: + data[cc("reminderDateTime")] = self._build_date_time_time_zone( + self.__reminder + ) + else: + data[cc("reminderDateTime")] = None + + if self.__completed: + data[cc("completedDateTime")] = self._build_date_time_time_zone( + self.__completed + ) + + if restrict_keys: + for key in list(data.keys()): + if key not in restrict_keys: + del data[key] + return data
+ + + @property + def created(self): + """Return Created time of the task. + + :rtype: datetime + """ + return self.__created + + @property + def modified(self): + """Return Last modified time of the task. + + :rtype: datetime + """ + return self.__modified + + @property + def body(self): + """Return Body of the task. + + :getter: Get body text + :setter: Set body of task + :type: str + """ + return self.__body + + @body.setter + def body(self, value): + self.__body = value + self._track_changes.add(self._cc("body")) + + @property + def importance(self): + """Return Task importance. + + :getter: Get importance level (Low, Normal, High) + :type: str + """ + return self.__importance + + @property + def is_starred(self): + """Is the task starred (high importance). + + :getter: Check if importance is high + :type: bool + """ + return self.__importance.casefold() == "high".casefold() + + @property + def subject(self): + """Subject of the task. + + :getter: Get subject + :setter: Set subject of task + :type: str + """ + return self.__subject + + @subject.setter + def subject(self, value): + self.__subject = value + self._track_changes.add(self._cc("title")) + + @property + def due(self): + """Due Time of task. + + :getter: get the due time + :setter: set the due time + :type: datetime + """ + return self.__due + + @due.setter + def due(self, value): + if value: + if not isinstance(value, dt.date): + raise ValueError("'due' must be a valid datetime object") + if not isinstance(value, dt.datetime): + # force datetime + value = dt.datetime(value.year, value.month, value.day) + if value.tzinfo is None: + # localize datetime + value = value.replace(tzinfo=self.protocol.timezone) + elif value.tzinfo != self.protocol.timezone: + value = value.astimezone(self.protocol.timezone) + self.__due = value + self._track_changes.add(self._cc("dueDateTime")) + + @property + def reminder(self): + """Reminder Time of task. + + :getter: get the reminder time + :setter: set the reminder time + :type: datetime + """ + return self.__reminder + + @reminder.setter + def reminder(self, value): + if value: + if not isinstance(value, dt.date): + raise ValueError("'reminder' must be a valid datetime object") + if not isinstance(value, dt.datetime): + # force datetime + value = dt.datetime(value.year, value.month, value.day) + if value.tzinfo is None: + # localize datetime + value = value.replace(tzinfo=self.protocol.timezone) + elif value.tzinfo != self.protocol.timezone: + value = value.astimezone(self.protocol.timezone) + self.__reminder = value + self._track_changes.add(self._cc("reminderDateTime")) + + @property + def is_reminder_on(self): + """Return isReminderOn of the task. + + :getter: Get isReminderOn + :type: bool + """ + return self.__is_reminder_on + + @property + def status(self): + """Status of task + + :getter: get status + :type: string + """ + return self.__status + + @property + def completed(self): + """Completed Time of task. + + :getter: get the completed time + :setter: set the completed time + :type: datetime + """ + return self.__completed + + @completed.setter + def completed(self, value): + if value is None: + self.mark_uncompleted() + else: + if not isinstance(value, dt.date): + raise ValueError("'completed' must be a valid datetime object") + if not isinstance(value, dt.datetime): + # force datetime + value = dt.datetime(value.year, value.month, value.day) + if value.tzinfo is None: + # localize datetime + value = value.replace(tzinfo=self.protocol.timezone) + elif value.tzinfo != self.protocol.timezone: + value = value.astimezone(self.protocol.timezone) + self.mark_completed() + + self.__completed = value + self._track_changes.add(self._cc("completedDateTime")) + + @property + def is_completed(self): + """Is task completed or not. + + :getter: Is completed + :setter: set the task to completted + :type: bool + """ + return self.__is_completed + +
+[docs] + def mark_completed(self): + """Mark the ask as completed.""" + self.__is_completed = True + self._track_changes.add(self._cc("status"))
+ + +
+[docs] + def mark_uncompleted(self): + """Mark the task as uncompleted.""" + self.__is_completed = False + self._track_changes.add(self._cc("status"))
+ + +
+[docs] + def delete(self): + """Delete a stored task. + + :return: Success / Failure + :rtype: bool + """ + if self.task_id is None: + raise RuntimeError("Attempting to delete an unsaved task") + + url = self.build_url( + self._endpoints.get(CONST_TASK).format( + folder_id=self.folder_id, id=self.task_id + ) + ) + + response = self.con.delete(url) + + return bool(response)
+ + +
+[docs] + def save(self): + """Create a new task or update an existing one. + + Does update by checking what values have changed and update them on the server + :return: Success / Failure + :rtype: bool + """ + if self.task_id: + # update task + if not self._track_changes: + return True # there's nothing to update + url = self.build_url( + self._endpoints.get(CONST_TASK).format( + folder_id=self.folder_id, id=self.task_id + ) + ) + method = self.con.patch + data = self.to_api_data(restrict_keys=self._track_changes) + else: + # new task + url = self.build_url( + self._endpoints.get(CONST_TASK_FOLDER).format(folder_id=self.folder_id) + ) + + method = self.con.post + data = self.to_api_data() + + response = method(url, data=data) + if not response: + return False + + self._track_changes.clear() # clear the tracked changes + + if not self.task_id: + # new task + task = response.json() + + self.task_id = task.get(self._cc("id"), None) + + self.__created = task.get(self._cc("createdDateTime"), None) + self.__modified = task.get(self._cc("lastModifiedDateTime"), None) + self.__completed = task.get(self._cc("completed"), None) + + self.__created = ( + parse(self.__created).astimezone(self.protocol.timezone) + if self.__created + else None + ) + self.__modified = ( + parse(self.__modified).astimezone(self.protocol.timezone) + if self.__modified + else None + ) + self.__is_completed = task.get(self._cc("status"), None) == "completed" + else: + self.__modified = dt.datetime.now().replace(tzinfo=self.protocol.timezone) + + return True
+ + +
+[docs] + def get_body_text(self): + """Parse the body html and returns the body text using bs4. + + :return: body text + :rtype: str + """ + if self.body_type != "html": + return self.body + + try: + soup = bs(self.body, "html.parser") + except RuntimeError: + return self.body + else: + return soup.body.text
+ + +
+[docs] + def get_body_soup(self): + """Return the beautifulsoup4 of the html body. + + :return: Html body + :rtype: BeautifulSoup + """ + return bs(self.body, "html.parser") if self.body_type == "html" else None
+
+ + + +
+[docs] +class Folder(ApiComponent): + """A Microsoft To-Do folder.""" + + _endpoints = { + CONST_FOLDER: "/todo/lists/{id}", + CONST_GET_TASKS: "/todo/lists/{id}/tasks", + CONST_GET_TASK: "/todo/lists/{id}/tasks/{ide}", + } + task_constructor = Task + +
+[docs] + def __init__(self, *, parent=None, con=None, **kwargs): + """Representation of a Microsoft To-Do Folder. + + :param parent: parent object + :type parent: ToDo + :param Connection con: connection to use if no parent specified + :param Protocol protocol: protocol to use if no parent specified + (kwargs) + :param str main_resource: use this resource instead of parent resource + (kwargs) + """ + if parent and con: + raise ValueError("Need a parent or a connection but not both") + self.con = parent.con if parent else con + + # Choose the main_resource passed in kwargs over parent main_resource + main_resource = kwargs.pop("main_resource", None) or ( + getattr(parent, "main_resource", None) if parent else None + ) + + super().__init__( + protocol=parent.protocol if parent else kwargs.get("protocol"), + main_resource=main_resource, + ) + + cloud_data = kwargs.get(self._cloud_data_key, {}) + + self.name = cloud_data.get(self._cc("displayName"), "") + self.folder_id = cloud_data.get(self._cc("id"), None) + self.is_default = False + if cloud_data.get(self._cc("wellknownListName"), "") == "defaultList": + self.is_default = True
+ + + def __str__(self): + """Representation of the Folder via the Graph api as a string.""" + return self.__repr__() + + def __repr__(self): + """Representation of the folder via the Graph api.""" + suffix = " (default)" if self.is_default else "" + return f"Folder: {self.name}{suffix}" + + def __eq__(self, other): + """Comparison of folders.""" + return self.folder_id == other.folder_id + +
+[docs] + def update(self): + """Update this folder. Only name can be changed. + + :return: Success / Failure + :rtype: bool + """ + if not self.folder_id: + return False + + url = self.build_url( + self._endpoints.get(CONST_FOLDER).format(id=self.folder_id) + ) + + data = { + self._cc("displayName"): self.name, + } + + response = self.con.patch(url, data=data) + + return bool(response)
+ + +
+[docs] + def delete(self): + """Delete this folder. + + :return: Success / Failure + :rtype: bool + """ + if not self.folder_id: + return False + + url = self.build_url( + self._endpoints.get(CONST_FOLDER).format(id=self.folder_id) + ) + + response = self.con.delete(url) + if not response: + return False + + self.folder_id = None + + return True
+ + +
+[docs] + def get_tasks(self, query=None, batch=None, order_by=None): + """Return list of tasks of a specified folder. + + :param query: the query string or object to query tasks + :param batch: the batch on to retrieve tasks. + :param order_by: the order clause to apply to returned tasks. + + :rtype: tasks + """ + url = self.build_url( + self._endpoints.get(CONST_GET_TASKS).format(id=self.folder_id) + ) + + # get tasks by the folder id + params = {} + if batch: + params["$top"] = batch + + if order_by: + params["$orderby"] = order_by + + if query: + if isinstance(query, str): + params["$filter"] = query + else: + params |= query.as_params() + + response = self.con.get(url, params=params) + + if not response: + return iter(()) + + data = response.json() + + return ( + self.task_constructor(parent=self, **{self._cloud_data_key: task}) + for task in data.get("value", []) + )
+ + +
+[docs] + def new_task(self, subject=None): + """Create a task within a specified folder.""" + return self.task_constructor( + parent=self, subject=subject, folder_id=self.folder_id + )
+ + +
+[docs] + def get_task(self, param): + """Return a Task instance by it's id. + + :param param: an task_id or a Query instance + :return: task for the specified info + :rtype: Event + """ + if param is None: + return None + if isinstance(param, str): + url = self.build_url( + self._endpoints.get(CONST_GET_TASK).format(id=self.folder_id, ide=param) + ) + params = None + by_id = True + else: + url = self.build_url( + self._endpoints.get(CONST_GET_TASKS).format(id=self.folder_id) + ) + params = {"$top": 1} + params |= param.as_params() + by_id = False + + response = self.con.get(url, params=params) + + if not response: + return None + + if by_id: + task = response.json() + else: + task = response.json().get("value", []) + if task: + task = task[0] + else: + return None + return self.task_constructor(parent=self, **{self._cloud_data_key: task})
+
+ + + +
+[docs] +class ToDo(ApiComponent): + """A of Microsoft To-Do class for MS Graph API. + + In order to use the API following permissions are required. + Delegated (work or school account) - Tasks.Read, Tasks.ReadWrite + """ + + _endpoints = { + CONST_ROOT_FOLDERS: "/todo/lists", + CONST_GET_FOLDER: "/todo/lists/{id}", + } + + folder_constructor = Folder + task_constructor = Task + +
+[docs] + def __init__(self, *, parent=None, con=None, **kwargs): + """Initialise the ToDo object. + + :param parent: parent object + :type parent: Account + :param Connection con: connection to use if no parent specified + :param Protocol protocol: protocol to use if no parent specified + (kwargs) + :param str main_resource: use this resource instead of parent resource + (kwargs) + """ + if parent and con: + raise ValueError("Need a parent or a connection but not both") + self.con = parent.con if parent else con + + # Choose the main_resource passed in kwargs over parent main_resource + main_resource = kwargs.pop("main_resource", None) or ( + getattr(parent, "main_resource", None) if parent else None + ) + + super().__init__( + protocol=parent.protocol if parent else kwargs.get("protocol"), + main_resource=main_resource, + )
+ + + def __str__(self): + """Representation of the ToDo via the Graph api as a string.""" + return self.__repr__() + + def __repr__(self): + """Representation of the ToDo via the Graph api as.""" + return "Microsoft To-Do" + +
+[docs] + def list_folders(self, query=None, limit=None): + """Return a list of folders. + + To use query an order_by check the OData specification here: + https://docs.oasis-open.org/odata/odata/v4.0/errata03/os/complete/ + part2-url-conventions/odata-v4.0-errata03-os-part2-url-conventions + -complete.html + :param query: the query string or object to list folders + :param int limit: max no. of folders to get. Over 999 uses batch. + :rtype: list[Folder] + """ + url = self.build_url(self._endpoints.get(CONST_ROOT_FOLDERS)) + + params = {} + if limit: + params["$top"] = limit + + if query: + if isinstance(query, str): + params["$filter"] = query + else: + params |= query.as_params() + + response = self.con.get(url, params=params or None) + if not response: + return [] + + data = response.json() + + return [ + self.folder_constructor(parent=self, **{self._cloud_data_key: x}) + for x in data.get("value", []) + ]
+ + +
+[docs] + def new_folder(self, folder_name): + """Create a new folder. + + :param str folder_name: name of the new folder + :return: a new Calendar instance + :rtype: Calendar + """ + if not folder_name: + return None + + url = self.build_url(self._endpoints.get(CONST_ROOT_FOLDERS)) + + response = self.con.post(url, data={self._cc("displayName"): folder_name}) + if not response: + return None + + data = response.json() + + # Everything received from cloud must be passed as self._cloud_data_key + return self.folder_constructor(parent=self, **{self._cloud_data_key: data})
+ + +
+[docs] + def get_folder(self, folder_id=None, folder_name=None): + """Return a folder by it's id or name. + + :param str folder_id: the folder id to be retrieved. + :param str folder_name: the folder name to be retrieved. + :return: folder for the given info + :rtype: Calendar + """ + if folder_id and folder_name: + raise RuntimeError("Provide only one of the options") + + if not folder_id and not folder_name: + raise RuntimeError("Provide one of the options") + + if folder_id: + url = self.build_url( + self._endpoints.get(CONST_GET_FOLDER).format(id=folder_id) + ) + response = self.con.get(url) + + return ( + self.folder_constructor( + parent=self, **{self._cloud_data_key: response.json()} + ) + if response + else None + ) + + query = self.new_query("displayName").equals(folder_name) + folders = self.list_folders(query=query) + return folders[0]
+ + +
+[docs] + def get_default_folder(self): + """Return the default folder for the current user. + + :rtype: Folder + """ + folders = self.list_folders() + for folder in folders: + if folder.is_default: + return folder
+ + +
+[docs] + def get_tasks(self, batch=None, order_by=None): + """Get tasks from the default Calendar. + + :param order_by: orders the result set based on this condition + :param int batch: batch size, retrieves items in + batches allowing to retrieve more items than the limit. + :return: list of items in this folder + :rtype: list[Event] or Pagination + """ + default_folder = self.get_default_folder() + + return default_folder.get_tasks(order_by=order_by, batch=batch)
+ + +
+[docs] + def new_task(self, subject=None): + """Return a new (unsaved) Event object in the default folder. + + :param str subject: subject text for the new task + :return: new task + :rtype: Event + """ + default_folder = self.get_default_folder() + return default_folder.new_task(subject=subject)
+
+ +
+ +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/docs/latest/_modules/O365/teams.html b/docs/latest/_modules/O365/teams.html new file mode 100644 index 00000000..f42f46a7 --- /dev/null +++ b/docs/latest/_modules/O365/teams.html @@ -0,0 +1,1230 @@ + + + + + + + + O365.teams — O365 documentation + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+ +
+
+
+
+ +

Source code for O365.teams

+import logging
+from enum import Enum
+
+from dateutil.parser import parse
+
+from .utils import ApiComponent, NEXT_LINK_KEYWORD, Pagination
+
+log = logging.getLogger(__name__)
+
+MAX_BATCH_CHAT_MESSAGES = 50
+MAX_BATCH_CHATS = 50
+
+
+
+[docs] +class Availability(Enum): + """Valid values for Availability.""" + + AVAILABLE = "Available" + BUSY = "Busy" + AWAY = "Away" + DONOTDISTURB = "DoNotDisturb"
+ + + +
+[docs] +class Activity(Enum): + """Valid values for Activity.""" + + AVAILABLE = "Available" + INACALL = "InACall" + INACONFERENCECALL = "InAConferenceCall" + AWAY = "Away" + PRESENTING = "Presenting"
+ + +
+[docs] +class PreferredAvailability(Enum): + """Valid values for Availability.""" + + AVAILABLE = "Available" + BUSY = "Busy" + DONOTDISTURB = "DoNotDisturb" + BERIGHTBACK = "BeRightBack" + AWAY = "Away" + OFFLINE = "Offline"
+ + + +
+[docs] +class PreferredActivity(Enum): + """Valid values for Activity.""" + + AVAILABLE = "Available" + BUSY = "Busy" + DONOTDISTURB = "DoNotDisturb" + BERIGHTBACK = "BeRightBack" + AWAY = "Away" + OFFWORK = "OffWork"
+ + +
+[docs] +class ConversationMember(ApiComponent): + """ A Microsoft Teams conversation member """ + +
+[docs] + def __init__(self, *, parent=None, con=None, **kwargs): + """ A Microsoft Teams conversation member + :param parent: parent object + :type parent: Chat + :param Connection con: connection to use if no parent specified + :param Protocol protocol: protocol to use if no parent specified (kwargs) + :param str main_resource: use this resource instead of parent resource (kwargs) + """ + if parent and con: + raise ValueError('Need a parent or a connection but not both') + self.con = parent.con if parent else con + cloud_data = kwargs.get(self._cloud_data_key, {}) + self.object_id = cloud_data.get('id') + + # Choose the main_resource passed in kwargs over parent main_resource + main_resource = kwargs.pop('main_resource', None) or ( + getattr(parent, 'main_resource', None) if parent else None) + resource_prefix = '/members/{membership_id}'.format( + membership_id=self.object_id) + main_resource = '{}{}'.format(main_resource, resource_prefix) + + super().__init__( + protocol=parent.protocol if parent else kwargs.get('protocol'), + main_resource=main_resource) + self.roles = cloud_data.get('roles') + self.display_name = cloud_data.get('displayName') + self.user_id = cloud_data.get('userId') + self.email = cloud_data.get('email') + self.tenant_id = cloud_data.get('tenantId')
+ + + def __repr__(self): + return 'ConversationMember: {} - {}'.format(self.display_name, + self.email) + + def __str__(self): + return self.__repr__()
+ + + +
+[docs] +class ChatMessage(ApiComponent): + """ A Microsoft Teams chat message """ + +
+[docs] + def __init__(self, *, parent=None, con=None, **kwargs): + """ A Microsoft Teams chat message + :param parent: parent object + :type parent: Channel, Chat, or ChannelMessage + :param Connection con: connection to use if no parent specified + :param Protocol protocol: protocol to use if no parent specified (kwargs) + :param str main_resource: use this resource instead of parent resource (kwargs) + """ + if parent and con: + raise ValueError('Need a parent or a connection but not both') + self.con = parent.con if parent else con + cloud_data = kwargs.get(self._cloud_data_key, {}) + self.object_id = cloud_data.get('id') + + # Choose the main_resource passed in kwargs over parent main_resource + main_resource = kwargs.pop('main_resource', None) or ( + getattr(parent, 'main_resource', None) if parent else None) + + # determine proper resource prefix based on whether the message is a reply + self.reply_to_id = cloud_data.get('replyToId') + if self.reply_to_id: + resource_prefix = '/replies/{message_id}'.format( + message_id=self.object_id) + else: + resource_prefix = '/messages/{message_id}'.format( + message_id=self.object_id) + + main_resource = '{}{}'.format(main_resource, resource_prefix) + super().__init__( + protocol=parent.protocol if parent else kwargs.get('protocol'), + main_resource=main_resource) + + self.message_type = cloud_data.get('messageType') + self.subject = cloud_data.get('subject') + self.summary = cloud_data.get('summary') + self.importance = cloud_data.get('importance') + self.web_url = cloud_data.get('webUrl') + + local_tz = self.protocol.timezone + created = cloud_data.get('createdDateTime') + last_modified = cloud_data.get('lastModifiedDateTime') + last_edit = cloud_data.get('lastEditedDateTime') + deleted = cloud_data.get('deletedDateTime') + self.created_date = parse(created).astimezone( + local_tz) if created else None + self.last_modified_date = parse(last_modified).astimezone( + local_tz) if last_modified else None + self.last_edited_date = parse(last_edit).astimezone( + local_tz) if last_edit else None + self.deleted_date = parse(deleted).astimezone( + local_tz) if deleted else None + + self.chat_id = cloud_data.get('chatId') + self.channel_identity = cloud_data.get('channelIdentity') + + sent_from = cloud_data.get('from') + if sent_from: + from_key = 'user' if sent_from.get('user', None) else 'application' + from_data = sent_from.get(from_key) + else: + from_data = {} + from_key = None + + self.from_id = from_data.get('id') if sent_from else None + self.from_display_name = from_data.get('displayName', + None) if sent_from else None + self.from_type = from_data.get( + '{}IdentityType'.format(from_key)) if sent_from else None + + body = cloud_data.get('body') + self.content_type = body.get('contentType') + self.content = body.get('content')
+ + + def __repr__(self): + return 'ChatMessage: {}'.format(self.from_display_name) + + def __str__(self): + return self.__repr__()
+ + + +
+[docs] +class ChannelMessage(ChatMessage): + """ A Microsoft Teams chat message that is the start of a channel thread """ + _endpoints = {'get_replies': '/replies', + 'get_reply': '/replies/{message_id}'} + + message_constructor = ChatMessage + +
+[docs] + def __init__(self, **kwargs): + """ A Microsoft Teams chat message that is the start of a channel thread """ + super().__init__(**kwargs) + + cloud_data = kwargs.get(self._cloud_data_key, {}) + channel_identity = cloud_data.get('channelIdentity') + self.team_id = channel_identity.get('teamId') + self.channel_id = channel_identity.get('channelId')
+ + +
+[docs] + def get_reply(self, message_id): + """ Returns a specified reply to the channel chat message + :param message_id: the message_id of the reply to retrieve + :type message_id: str or int + :rtype: ChatMessage + """ + url = self.build_url( + self._endpoints.get('get_reply').format(message_id=message_id)) + response = self.con.get(url) + + if not response: + return None + + data = response.json() + + return self.message_constructor(parent=self, + **{self._cloud_data_key: data})
+ + +
+[docs] + def get_replies(self, limit=None, batch=None): + """ Returns a list of replies to the channel chat message + :param int limit: number of replies to retrieve + :param int batch: number of replies to be in each data set + :rtype: list or Pagination + """ + url = self.build_url(self._endpoints.get('get_replies')) + + if not batch and (limit is None or limit > MAX_BATCH_CHAT_MESSAGES): + batch = MAX_BATCH_CHAT_MESSAGES + + params = {'$top': batch if batch else limit} + response = self.con.get(url, params=params) + if not response: + return [] + + data = response.json() + next_link = data.get(NEXT_LINK_KEYWORD, None) + + replies = [self.message_constructor(parent=self, + **{self._cloud_data_key: reply}) + for reply in data.get('value', [])] + + if batch and next_link: + return Pagination(parent=self, data=replies, + constructor=self.message_constructor, + next_link=next_link, limit=limit) + else: + return replies
+ + +
+[docs] + def send_reply(self, content=None, content_type='text'): + """ Sends a reply to the channel chat message + :param content: str of text, str of html, or dict representation of json body + :type content: str or dict + :param str content_type: 'text' to render the content as text or 'html' to render the content as html + """ + data = content if isinstance(content, dict) else { + 'body': {'contentType': content_type, 'content': content}} + url = self.build_url(self._endpoints.get('get_replies')) + response = self.con.post(url, data=data) + + if not response: + return None + + data = response.json() + return self.message_constructor(parent=self, + **{self._cloud_data_key: data})
+
+ + + +
+[docs] +class Chat(ApiComponent): + """ A Microsoft Teams chat """ + _endpoints = {'get_messages': '/messages', + 'get_message': '/messages/{message_id}', + 'get_members': '/members', + 'get_member': '/members/{membership_id}'} + + message_constructor = ChatMessage + member_constructor = ConversationMember + +
+[docs] + def __init__(self, *, parent=None, con=None, **kwargs): + """ A Microsoft Teams chat + :param parent: parent object + :type parent: Teams + :param Connection con: connection to use if no parent specified + :param Protocol protocol: protocol to use if no parent specified (kwargs) + :param str main_resource: use this resource instead of parent resource (kwargs) + """ + if parent and con: + raise ValueError('Need a parent or a connection but not both') + self.con = parent.con if parent else con + + cloud_data = kwargs.get(self._cloud_data_key, {}) + self.object_id = cloud_data.get('id') + + # Choose the main_resource passed in kwargs over parent main_resource + main_resource = kwargs.pop('main_resource', None) or ( + getattr(parent, 'main_resource', None) if parent else None) + resource_prefix = '/chats/{chat_id}'.format(chat_id=self.object_id) + main_resource = '{}{}'.format(main_resource, resource_prefix) + super().__init__( + protocol=parent.protocol if parent else kwargs.get('protocol'), + main_resource=main_resource) + + self.topic = cloud_data.get('topic') + self.chat_type = cloud_data.get('chatType') + self.web_url = cloud_data.get('webUrl') + created = cloud_data.get('createdDateTime') + last_update = cloud_data.get('lastUpdatedDateTime') + local_tz = self.protocol.timezone + self.created_date = parse(created).astimezone( + local_tz) if created else None + self.last_update_date = parse(last_update).astimezone( + local_tz) if last_update else None
+ + +
+[docs] + def get_messages(self, limit=None, batch=None): + """ Returns a list of chat messages from the chat + :param int limit: number of replies to retrieve + :param int batch: number of replies to be in each data set + :rtype: list[ChatMessage] or Pagination of ChatMessage + """ + url = self.build_url(self._endpoints.get('get_messages')) + + if not batch and (limit is None or limit > MAX_BATCH_CHAT_MESSAGES): + batch = MAX_BATCH_CHAT_MESSAGES + + params = {'$top': batch if batch else limit} + response = self.con.get(url, params=params) + if not response: + return [] + + data = response.json() + next_link = data.get(NEXT_LINK_KEYWORD, None) + + messages = [self.message_constructor(parent=self, + **{self._cloud_data_key: message}) + for message in data.get('value', [])] + + if batch and next_link: + return Pagination(parent=self, data=messages, + constructor=self.message_constructor, + next_link=next_link, limit=limit) + else: + return messages
+ + +
+[docs] + def get_message(self, message_id): + """ Returns a specified message from the chat + :param message_id: the message_id of the message to receive + :type message_id: str or int + :rtype: ChatMessage + """ + url = self.build_url( + self._endpoints.get('get_message').format(message_id=message_id)) + response = self.con.get(url) + if not response: + return None + data = response.json() + return self.message_constructor(parent=self, + **{self._cloud_data_key: data})
+ + +
+[docs] + def send_message(self, content=None, content_type='text'): + """ Sends a message to the chat + :param content: str of text, str of html, or dict representation of json body + :type content: str or dict + :param str content_type: 'text' to render the content as text or 'html' to render the content as html + :rtype: ChatMessage + """ + data = content if isinstance(content, dict) else { + 'body': {'contentType': content_type, 'content': content}} + + url = self.build_url(self._endpoints.get('get_messages')) + response = self.con.post(url, data=data) + + if not response: + return None + + data = response.json() + return self.message_constructor(parent=self, + **{self._cloud_data_key: data})
+ + +
+[docs] + def get_members(self): + """ Returns a list of conversation members + :rtype: list[ConversationMember] + """ + url = self.build_url(self._endpoints.get('get_members')) + response = self.con.get(url) + if not response: + return None + data = response.json() + members = [self.member_constructor(parent=self, + **{self._cloud_data_key: member}) + for member in data.get('value', [])] + return members
+ + +
+[docs] + def get_member(self, membership_id): + """Returns a specified conversation member + :param str membership_id: membership_id of member to retrieve + :rtype: ConversationMember + """ + url = self.build_url(self._endpoints.get('get_member').format( + membership_id=membership_id)) + response = self.con.get(url) + if not response: + return None + data = response.json() + return self.member_constructor(parent=self, + **{self._cloud_data_key: data})
+ + + def __repr__(self): + return 'Chat: {}'.format(self.chat_type) + + def __str__(self): + return self.__repr__()
+ + + +
+[docs] +class Presence(ApiComponent): + """ Microsoft Teams Presence """ + + _endpoints = {} + +
+[docs] + def __init__(self, *, parent=None, con=None, **kwargs): + """ Microsoft Teams Presence + + :param parent: parent object + :type parent: Teams + :param Connection con: connection to use if no parent specified + :param Protocol protocol: protocol to use if no parent specified + (kwargs) + :param str main_resource: use this resource instead of parent resource + (kwargs) + """ + if parent and con: + raise ValueError('Need a parent or a connection but not both') + self.con = parent.con if parent else con + + cloud_data = kwargs.get(self._cloud_data_key, {}) + + self.object_id = cloud_data.get('id') + + # Choose the main_resource passed in kwargs over parent main_resource + main_resource = kwargs.pop('main_resource', None) or ( + getattr(parent, 'main_resource', None) if parent else None) + + main_resource = '{}{}'.format(main_resource, '') + + super().__init__( + protocol=parent.protocol if parent else kwargs.get('protocol'), + main_resource=main_resource) + + self.availability = cloud_data.get('availability') + self.activity = cloud_data.get('activity')
+ + + def __str__(self): + return self.__repr__() + + def __repr__(self): + return 'availability: {}'.format(self.availability) + + def __eq__(self, other): + return self.object_id == other.object_id
+ + + +
+[docs] +class Channel(ApiComponent): + """ A Microsoft Teams channel """ + + _endpoints = {'get_messages': '/messages', + 'get_message': '/messages/{message_id}'} + + message_constructor = ChannelMessage + +
+[docs] + def __init__(self, *, parent=None, con=None, **kwargs): + """ A Microsoft Teams channel + + :param parent: parent object + :type parent: Teams or Team + :param Connection con: connection to use if no parent specified + :param Protocol protocol: protocol to use if no parent specified + (kwargs) + :param str main_resource: use this resource instead of parent resource + (kwargs) + """ + if parent and con: + raise ValueError('Need a parent or a connection but not both') + self.con = parent.con if parent else con + + cloud_data = kwargs.get(self._cloud_data_key, {}) + self.object_id = cloud_data.get('id') + + # Choose the main_resource passed in kwargs over parent main_resource + main_resource = kwargs.pop('main_resource', None) or ( + getattr(parent, 'main_resource', None) if parent else None) + + resource_prefix = '/channels/{channel_id}'.format( + channel_id=self.object_id) + main_resource = '{}{}'.format(main_resource, resource_prefix) + super().__init__( + protocol=parent.protocol if parent else kwargs.get('protocol'), + main_resource=main_resource) + + self.display_name = cloud_data.get(self._cc('displayName'), '') + self.description = cloud_data.get('description') + self.email = cloud_data.get('email')
+ + +
+[docs] + def get_message(self, message_id): + """ Returns a specified channel chat messages + :param message_id: number of messages to retrieve + :type message_id: int or str + :rtype: ChannelMessage + """ + url = self.build_url( + self._endpoints.get('get_message').format(message_id=message_id)) + response = self.con.get(url) + + if not response: + return None + + data = response.json() + return self.message_constructor(parent=self, + **{self._cloud_data_key: data})
+ + +
+[docs] + def get_messages(self, limit=None, batch=None): + """ Returns a list of channel chat messages + :param int limit: number of messages to retrieve + :param int batch: number of messages to be in each data set + :rtype: list[ChannelMessage] or Pagination of ChannelMessage + """ + url = self.build_url(self._endpoints.get('get_messages')) + + if not batch and (limit is None or limit > MAX_BATCH_CHAT_MESSAGES): + batch = MAX_BATCH_CHAT_MESSAGES + + params = {'$top': batch if batch else limit} + response = self.con.get(url, params=params) + if not response: + return [] + + data = response.json() + next_link = data.get(NEXT_LINK_KEYWORD, None) + + messages = [self.message_constructor(parent=self, + **{self._cloud_data_key: message}) + for message in data.get('value', [])] + + if batch and next_link: + return Pagination(parent=self, data=messages, + constructor=self.message_constructor, + next_link=next_link, limit=limit) + else: + return messages
+ + +
+[docs] + def send_message(self, content=None, content_type='text'): + """ Sends a message to the channel + :param content: str of text, str of html, or dict representation of json body + :type content: str or dict + :param str content_type: 'text' to render the content as text or 'html' to render the content as html + :rtype: ChannelMessage + """ + data = content if isinstance(content, dict) else { + 'body': {'contentType': content_type, 'content': content}} + + url = self.build_url(self._endpoints.get('get_messages')) + response = self.con.post(url, data=data) + + if not response: + return None + + data = response.json() + return self.message_constructor(parent=self, + **{self._cloud_data_key: data})
+ + + def __str__(self): + return self.__repr__() + + def __repr__(self): + return 'Channel: {}'.format(self.display_name) + + def __eq__(self, other): + return self.object_id == other.object_id
+ + + +
+[docs] +class Team(ApiComponent): + """ A Microsoft Teams team """ + + _endpoints = {'get_channels': '/channels', + 'get_channel': '/channels/{channel_id}'} + + channel_constructor = Channel + +
+[docs] + def __init__(self, *, parent=None, con=None, **kwargs): + """ A Microsoft Teams team + + :param parent: parent object + :type parent: Teams + :param Connection con: connection to use if no parent specified + :param Protocol protocol: protocol to use if no parent specified + (kwargs) + :param str main_resource: use this resource instead of parent resource + (kwargs) + """ + if parent and con: + raise ValueError('Need a parent or a connection but not both') + self.con = parent.con if parent else con + + cloud_data = kwargs.get(self._cloud_data_key, {}) + + self.object_id = cloud_data.get('id') + + # Choose the main_resource passed in kwargs over parent main_resource + main_resource = kwargs.pop('main_resource', None) or ( + getattr(parent, 'main_resource', None) if parent else None) + + resource_prefix = '/teams/{team_id}'.format(team_id=self.object_id) + main_resource = '{}{}'.format(main_resource, resource_prefix) + + super().__init__( + protocol=parent.protocol if parent else kwargs.get('protocol'), + main_resource=main_resource) + + self.display_name = cloud_data.get(self._cc('displayName'), '') + self.description = cloud_data.get(self._cc('description'), '') + self.is_archived = cloud_data.get(self._cc('isArchived'), '') + self.web_url = cloud_data.get(self._cc('webUrl'), '')
+ + + def __str__(self): + return self.__repr__() + + def __repr__(self): + return 'Team: {}'.format(self.display_name) + + def __eq__(self, other): + return self.object_id == other.object_id + +
+[docs] + def get_channels(self): + """ Returns a list of channels the team + + :rtype: list[Channel] + """ + url = self.build_url(self._endpoints.get('get_channels')) + response = self.con.get(url) + + if not response: + return [] + + data = response.json() + + return [self.channel_constructor(parent=self, + **{self._cloud_data_key: channel}) + for channel in data.get('value', [])]
+ + +
+[docs] + def get_channel(self, channel_id): + """ Returns a channel of the team + + :param channel_id: the team_id of the channel to be retrieved. + + :rtype: Channel + """ + url = self.build_url(self._endpoints.get('get_channel').format(channel_id=channel_id)) + response = self.con.get(url) + + if not response: + return None + + data = response.json() + + return self.channel_constructor(parent=self, **{self._cloud_data_key: data})
+
+ + + + + +
+[docs] +class App(ApiComponent): + """ A Microsoft Teams app """ + + _endpoints = {} + +
+[docs] + def __init__(self, *, parent=None, con=None, **kwargs): + """ A Microsoft Teams app + + :param parent: parent object + :type parent: Teams + :param Connection con: connection to use if no parent specified + :param Protocol protocol: protocol to use if no parent specified + (kwargs) + :param str main_resource: use this resource instead of parent resource + (kwargs) + """ + if parent and con: + raise ValueError('Need a parent or a connection but not both') + self.con = parent.con if parent else con + + cloud_data = kwargs.get(self._cloud_data_key, {}) + + self.object_id = cloud_data.get('id') + + # Choose the main_resource passed in kwargs over parent main_resource + main_resource = kwargs.pop('main_resource', None) or ( + getattr(parent, 'main_resource', None) if parent else None) + + main_resource = '{}{}'.format(main_resource, '') + + super().__init__( + protocol=parent.protocol if parent else kwargs.get('protocol'), + main_resource=main_resource) + + self.app_definition = cloud_data.get(self._cc('teamsAppDefinition'), + {})
+ + + def __str__(self): + return self.__repr__() + + def __repr__(self): + return 'App: {}'.format(self.app_definition.get('displayName')) + + def __eq__(self, other): + return self.object_id == other.object_id
+ + + +
+[docs] +class Teams(ApiComponent): + """ A Microsoft Teams class""" + + _endpoints = { + "get_my_presence": "/me/presence", + "get_user_presence": "/users/{user_id}/presence", + "set_my_presence": "/me/presence/setPresence", + "set_my_user_preferred_presence": "/me/presence/setUserPreferredPresence", + "get_my_teams": "/me/joinedTeams", + "get_channels": "/teams/{team_id}/channels", + "create_channel": "/teams/{team_id}/channels", + "get_channel": "/teams/{team_id}/channels/{channel_id}", + "get_apps_in_team": "/teams/{team_id}/installedApps?$expand=teamsAppDefinition", + "get_my_chats": "/me/chats" + } + presence_constructor = Presence + team_constructor = Team + channel_constructor = Channel + app_constructor = App + chat_constructor = Chat + +
+[docs] + def __init__(self, *, parent=None, con=None, **kwargs): + """ A Teams object + + :param parent: parent object + :type parent: Account + :param Connection con: connection to use if no parent specified + :param Protocol protocol: protocol to use if no parent specified + (kwargs) + :param str main_resource: use this resource instead of parent resource + (kwargs) + """ + if parent and con: + raise ValueError('Need a parent or a connection but not both') + self.con = parent.con if parent else con + + # Choose the main_resource passed in kwargs over the host_name + main_resource = kwargs.pop('main_resource', + '') # defaults to blank resource + super().__init__( + protocol=parent.protocol if parent else kwargs.get('protocol'), + main_resource=main_resource)
+ + + def __str__(self): + return self.__repr__() + + def __repr__(self): + return 'Microsoft Teams' + +
+[docs] + def get_my_presence(self): + """ Returns my availability and activity + + :rtype: Presence + """ + + url = self.build_url(self._endpoints.get('get_my_presence')) + + response = self.con.get(url) + + if not response: + return None + + data = response.json() + + return self.presence_constructor(parent=self, + **{self._cloud_data_key: data})
+ + +
+[docs] + def set_my_presence( + self, + session_id, + availability: Availability, + activity: Activity, + expiration_duration, + ): + """Sets my presence status + + :param session_id: the session/capplication id. + :param availability: the availability. + :param activity: the activity. + :param activity: the expiration_duration when status will be unset. + :rtype: Presence + """ + + url = self.build_url(self._endpoints.get("set_my_presence")) + + data = { + "sessionId": session_id, + "availability": availability.value, + "activity": activity.value, + "expirationDutaion": expiration_duration, + } + + response = self.con.post(url, data=data) + + return self.get_my_presence() if response else None
+ + +
+[docs] + def set_my_user_preferred_presence( + self, + availability: PreferredAvailability, + activity: PreferredActivity, + expiration_duration, + ): + """Sets my user preferred presence status + + :param availability: the availability. + :param activity: the activity. + :param activity: the expiration_duration when status will be unset. + :rtype: Presence + """ + + url = self.build_url(self._endpoints.get("set_my_user_preferred_presence")) + + data = { + "availability": availability.value, + "activity": activity.value, + "expirationDutaion": expiration_duration, + } + + response = self.con.post(url, data=data) + + return self.get_my_presence() if response else None
+ + +
+[docs] + def get_user_presence(self, user_id=None, email=None): + """Returns specific user availability and activity + + :rtype: Presence + """ + + url = self.build_url( + self._endpoints.get("get_user_presence").format(user_id=user_id) + ) + + response = self.con.get(url) + + if not response: + return None + + data = response.json() + + return self.presence_constructor(parent=self, **{self._cloud_data_key: data})
+ + +
+[docs] + def get_my_teams(self): + """ Returns a list of teams that I am in + + :rtype: list[Team] + """ + + url = self.build_url(self._endpoints.get('get_my_teams')) + response = self.con.get(url) + + if not response: + return [] + + data = response.json() + + return [ + self.team_constructor(parent=self, **{self._cloud_data_key: site}) + for site in data.get('value', [])]
+ + +
+[docs] + def get_my_chats(self, limit=None, batch=None): + """ Returns a list of chats that I am in + :param int limit: number of chats to retrieve + :param int batch: number of chats to be in each data set + :rtype: list[ChatMessage] or Pagination of Chat + """ + url = self.build_url(self._endpoints.get('get_my_chats')) + + if not batch and (limit is None or limit > MAX_BATCH_CHATS): + batch = MAX_BATCH_CHATS + + params = {'$top': batch if batch else limit} + response = self.con.get(url, params=params) + if not response: + return [] + + data = response.json() + next_link = data.get(NEXT_LINK_KEYWORD, None) + + chats = [self.chat_constructor(parent=self, + **{self._cloud_data_key: message}) + for message in data.get('value', [])] + + if batch and next_link: + return Pagination(parent=self, data=chats, + constructor=self.chat_constructor, + next_link=next_link, limit=limit) + else: + return chats
+ + +
+[docs] + def get_channels(self, team_id): + """ Returns a list of channels of a specified team + + :param team_id: the team_id of the channel to be retrieved. + + :rtype: list[Channel] + """ + + url = self.build_url( + self._endpoints.get('get_channels').format(team_id=team_id)) + + response = self.con.get(url) + + if not response: + return [] + + data = response.json() + + return [ + self.channel_constructor(parent=self, + **{self._cloud_data_key: channel}) + for channel in data.get('value', [])]
+ + +
+[docs] + def create_channel(self, team_id, display_name, description=None): + """ Creates a channel within a specified team + + :param team_id: the team_id where the channel is created. + :param display_name: the channel display name. + :param description: the channel description. + :rtype: Channel + """ + + url = self.build_url( + self._endpoints.get('get_channels').format(team_id=team_id)) + + if description: + data = { + 'displayName': display_name, + 'description': description, + } + else: + data = { + 'displayName': display_name, + } + + response = self.con.post(url, data=data) + + if not response: + return None + + data = response.json() + + return self.channel_constructor(parent=self, + **{self._cloud_data_key: data})
+ + +
+[docs] + def get_channel(self, team_id, channel_id): + """ Returns the channel info for a given channel + + :param team_id: the team_id of the channel. + :param channel_id: the channel_id of the channel. + + :rtype: list[Channel] + """ + + url = self.build_url( + self._endpoints.get('get_channel').format(team_id=team_id, + channel_id=channel_id)) + + response = self.con.get(url) + + if not response: + return None + + data = response.json() + + return self.channel_constructor(parent=self, + **{self._cloud_data_key: data})
+ + +
+[docs] + def get_apps_in_team(self, team_id): + """ Returns a list of apps of a specified team + + :param team_id: the team_id of the team to get the apps of. + + :rtype: list[App] + """ + + url = self.build_url( + self._endpoints.get('get_apps_in_team').format(team_id=team_id)) + response = self.con.get(url) + + if not response: + return [] + + data = response.json() + + return [ + self.app_constructor(parent=self, **{self._cloud_data_key: site}) + for site in data.get('value', [])]
+
+ +
+ +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/docs/latest/_modules/O365/utils/attachment.html b/docs/latest/_modules/O365/utils/attachment.html new file mode 100644 index 00000000..7dd02452 --- /dev/null +++ b/docs/latest/_modules/O365/utils/attachment.html @@ -0,0 +1,685 @@ + + + + + + + + + + + O365.utils.attachment — O365 documentation + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + +
+ +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+
+
+
+ +

Source code for O365.utils.attachment

+import base64
+import logging
+from pathlib import Path
+from io import BytesIO
+
+from .utils import ApiComponent
+
+log = logging.getLogger(__name__)
+
+
+
[docs]class AttachableMixin: +
[docs] def __init__(self, attachment_name_property=None, attachment_type=None): + """ Defines the functionality for an object to be attachable. + Any object that inherits from this class will be attachable + (if the underlying api allows that) + + """ + self.__attachment_name = None + self.__attachment_name_property = attachment_name_property + self.__attachment_type = self._gk(attachment_type)
+ + @property + def attachment_name(self): + """ Name of the attachment + + :getter: get attachment name + :setter: set new name for the attachment + :type: str + """ + if self.__attachment_name is not None: + return self.__attachment_name + if self.__attachment_name_property: + return getattr(self, self.__attachment_name_property, '') + else: + # property order resolution: + # 1) try property 'subject' + # 2) try property 'name' + try: + attachment_name = getattr(self, 'subject') + except AttributeError: + attachment_name = getattr(self, 'name', '') + return attachment_name + + @attachment_name.setter + def attachment_name(self, value): + self.__attachment_name = value + + @property + def attachment_type(self): + """ Type of attachment + + :rtype: str + """ + return self.__attachment_type + +
[docs] def to_api_data(self): + """ Returns a dict to communicate with the server + + :rtype: dict + """ + raise NotImplementedError()
+ + +
[docs]class BaseAttachment(ApiComponent): + """ BaseAttachment class is the base object for dealing with attachments """ + + _endpoints = {'attach': '/messages/{id}/attachments'} + +
[docs] def __init__(self, attachment=None, *, parent=None, **kwargs): + """ Creates a new attachment, optionally from existing cloud data + + :param attachment: attachment data (dict = cloud data, + other = user data) + :type attachment: dict or str or Path or list[str] or AttachableMixin + :param BaseAttachments parent: the parent Attachments + :param Protocol protocol: protocol to use if no parent specified + (kwargs) + :param str main_resource: use this resource instead of parent resource + (kwargs) + """ + kwargs.setdefault('protocol', getattr(parent, 'protocol', None)) + kwargs.setdefault('main_resource', + getattr(parent, 'main_resource', None)) + + super().__init__(**kwargs) + self.name = None + self.attachment_type = 'file' + self.attachment_id = None + self.content_id = None + self.is_inline = False + self.attachment = None + self.content = None + self.on_disk = False + self.on_cloud = kwargs.get('on_cloud', False) + self.size = None + + if attachment: + if isinstance(attachment, dict): + if self._cloud_data_key in attachment: + # data from the cloud + attachment = attachment.get(self._cloud_data_key) + self.attachment_id = attachment.get(self._cc('id'), None) + self.content_id = attachment.get(self._cc('contentId'), None) + self.is_inline = attachment.get(self._cc('IsInline'), False) + self.name = attachment.get(self._cc('name'), None) + self.content = attachment.get(self._cc('contentBytes'), + None) + self.attachment_type = 'item' if 'item' in attachment.get( + '@odata.type', '').lower() else 'file' + self.on_disk = False + self.size = attachment.get(self._cc('size'), None) + else: + file_path = attachment.get('path', attachment.get('name')) + if file_path is None: + raise ValueError('Must provide a valid "path" or ' + '"name" for the attachment') + self.content = attachment.get('content') + self.on_disk = attachment.get('on_disk') + self.attachment_id = attachment.get('attachment_id') + self.attachment = Path(file_path) if self.on_disk else None + self.name = (self.attachment.name if self.on_disk + else attachment.get('name')) + self.size = self.attachment.stat().st_size if self.attachment else None + + elif isinstance(attachment, str): + self.attachment = Path(attachment) + self.name = self.attachment.name + elif isinstance(attachment, Path): + self.attachment = attachment + self.name = self.attachment.name + elif isinstance(attachment, (tuple, list)): + # files with custom names or Inmemory objects + file_obj, custom_name = attachment + if isinstance(file_obj, BytesIO): + # in memory objects + self.content = base64.b64encode(file_obj.getvalue()).decode('utf-8') + else: + self.attachment = Path(file_obj) + self.name = custom_name + + elif isinstance(attachment, AttachableMixin): + # Object that can be attached (Message for example) + self.attachment_type = 'item' + self.attachment = attachment + self.name = attachment.attachment_name + self.content = attachment.to_api_data() + self.content['@odata.type'] = attachment.attachment_type + + if self.content is None and self.attachment and self.attachment.exists(): + with self.attachment.open('rb') as file: + self.content = base64.b64encode(file.read()).decode('utf-8') + self.on_disk = True + self.size = self.attachment.stat().st_size
+ + def __len__(self): + """ Returns the size of this attachment """ + return self.size + + def __eq__(self, other): + return self.attachment_id == other.attachment_id + +
[docs] def to_api_data(self): + """ Returns a dict to communicate with the server + + :rtype: dict + """ + data = {'@odata.type': self._gk( + '{}_attachment_type'.format(self.attachment_type)), + self._cc('name'): self.name} + + if self.is_inline: + data[self._cc('isInline')] = self.is_inline + if self.attachment_type == 'file': + data[self._cc('contentBytes')] = self.content + if self.content_id is not None: + data[self._cc('contentId')] = self.content_id + else: + data[self._cc('item')] = self.content + + return data
+ +
[docs] def save(self, location=None, custom_name=None): + """ Save the attachment locally to disk + + :param str location: path string to where the file is to be saved. + :param str custom_name: a custom name to be saved as + :return: Success / Failure + :rtype: bool + """ + if not self.content: + return False + + location = Path(location or '') + if not location.exists(): + log.debug('the location provided does not exist') + return False + + name = custom_name or self.name + name = name.replace('/', '-').replace('\\', '') + try: + path = location / name + with path.open('wb') as file: + file.write(base64.b64decode(self.content)) + self.attachment = path + self.on_disk = True + self.size = self.attachment.stat().st_size + + log.debug('file saved locally.') + except Exception as e: + log.error('file failed to be saved: %s', str(e)) + return False + return True
+ +
[docs] def attach(self, api_object, on_cloud=False): + """ Attach this attachment to an existing api_object. This + BaseAttachment object must be an orphan BaseAttachment created for the + sole purpose of attach it to something and therefore run this method. + + :param api_object: object to attach to + :param on_cloud: if the attachment is on cloud or not + :return: Success / Failure + :rtype: bool + """ + + if self.on_cloud: + # item is already saved on the cloud. + return True + + # api_object must exist and if implements attachments + # then we can attach to it. + if api_object and getattr(api_object, 'attachments', None): + if on_cloud: + if not api_object.object_id: + raise RuntimeError( + 'A valid object id is needed in order to attach a file') + # api_object builds its own url using its + # resource and main configuration + url = api_object.build_url(self._endpoints.get('attach').format( + id=api_object.object_id)) + + response = api_object.con.post(url, data=self.to_api_data()) + + return bool(response) + else: + if self.attachment_type == 'file': + api_object.attachments.add([{ + 'attachment_id': self.attachment_id, + # TODO: copy attachment id? or set to None? + 'path': str( + self.attachment) if self.attachment else None, + 'name': self.name, + 'content': self.content, + 'on_disk': self.on_disk + }]) + else: + raise RuntimeError('Only file attachments can be attached')
+ + def __str__(self): + return self.__repr__() + + def __repr__(self): + return 'Attachment: {}'.format(self.name)
+ + +
[docs]class BaseAttachments(ApiComponent): + """ A Collection of BaseAttachments """ + + _endpoints = { + 'attachments': '/messages/{id}/attachments', + 'attachment': '/messages/{id}/attachments/{ida}' + } + _attachment_constructor = BaseAttachment + +
[docs] def __init__(self, parent, attachments=None): + """ Attachments must be a list of path strings or dictionary elements + + :param Account parent: parent object + :param attachments: list of attachments + :type attachments: list[str] or list[Path] or str or Path or dict + """ + super().__init__(protocol=parent.protocol, + main_resource=parent.main_resource) + self._parent = parent + self.__attachments = [] + # holds on_cloud attachments removed from the parent object + self.__removed_attachments = [] + self.untrack = True + if attachments: + self.add(attachments) + self.untrack = False
+ + def __iter__(self): + return iter(self.__attachments) + + def __getitem__(self, key): + return self.__attachments[key] + + def __contains__(self, item): + return item in {attachment.name for attachment in self.__attachments} + + def __len__(self): + return len(self.__attachments) + + def __str__(self): + attachments = len(self.__attachments) + parent_has_attachments = getattr(self._parent, 'has_attachments', False) + if parent_has_attachments and attachments == 0: + return 'Number of Attachments: unknown' + else: + return 'Number of Attachments: {}'.format(attachments) + + def __repr__(self): + return self.__str__() + + def __bool__(self): + return bool(len(self.__attachments)) + +
[docs] def to_api_data(self): + """ Returns a dict to communicate with the server + + :rtype: dict + """ + return [attachment.to_api_data() for attachment in self.__attachments if + attachment.on_cloud is False]
+ +
[docs] def clear(self): + """ Clear the attachments """ + for attachment in self.__attachments: + if attachment.on_cloud: + self.__removed_attachments.append(attachment) + self.__attachments = [] + self._update_parent_attachments() + self._track_changes()
+ + def _track_changes(self): + """ Update the track_changes on the parent to reflect + a needed update on this field """ + if getattr(self._parent, '_track_changes', + None) is not None and self.untrack is False: + # noinspection PyProtectedMember + self._parent._track_changes.add('attachments') + + def _update_parent_attachments(self): + """ Tries to update the parent property 'has_attachments' """ + try: + self._parent.has_attachments = bool(len(self.__attachments)) + except AttributeError: + pass + +
[docs] def add(self, attachments): + """ Add more attachments + + :param attachments: list of attachments + :type attachments: list[str] or list[Path] or str or Path or dict + """ + if attachments: + if isinstance(attachments, (str, Path)): + attachments = [attachments] + if isinstance(attachments, (list, tuple, set)): + # User provided attachments + attachments_temp = [ + self._attachment_constructor(attachment, parent=self) + for attachment in attachments] + elif isinstance(attachments, + dict) and self._cloud_data_key in attachments: + # Cloud downloaded attachments. We pass on_cloud=True + # to track if this attachment is saved on the server + attachments_temp = [self._attachment_constructor( + {self._cloud_data_key: attachment}, parent=self, + on_cloud=True) + for attachment in + attachments.get(self._cloud_data_key, [])] + else: + raise ValueError('Attachments must be a str or Path or a ' + 'list, tuple or set of the former') + + self.__attachments.extend(attachments_temp) + self._update_parent_attachments() + self._track_changes()
+ +
[docs] def remove(self, attachments): + """ Remove the specified attachments + + :param attachments: list of attachments + :type attachments: list[str] or list[Path] or str or Path or dict + """ + if isinstance(attachments, (list, tuple)): + attachments = ({attachment.name + if isinstance(attachment, BaseAttachment) + else attachment for attachment in attachments}) + elif isinstance(attachments, str): + attachments = {attachments} + elif isinstance(attachments, BaseAttachment): + attachments = {attachments.name} + else: + raise ValueError('Incorrect parameter type for attachments') + + new_attachments = [] + for attachment in self.__attachments: + if attachment.name not in attachments: + new_attachments.append(attachment) + else: + if attachment.on_cloud: + # add to removed_attachments so later we can delete them + self.__removed_attachments.append( + attachment) + self.__attachments = new_attachments + self._update_parent_attachments() + self._track_changes()
+ +
[docs] def download_attachments(self): + """ Downloads this message attachments into memory. + Need a call to 'attachment.save' to save them on disk. + + :return: Success / Failure + :rtype: bool + """ + if not self._parent.has_attachments: + log.debug( + 'Parent {} has no attachments, skipping out early.'.format( + self._parent.__class__.__name__)) + return False + + if not self._parent.object_id: + raise RuntimeError( + 'Attempted to download attachments of an unsaved {}'.format( + self._parent.__class__.__name__)) + + url = self.build_url(self._endpoints.get('attachments').format( + id=self._parent.object_id)) + + response = self._parent.con.get(url) + if not response: + return False + + attachments = response.json().get('value', []) + + # Everything received from cloud must be passed as self._cloud_data_key + self.untrack = True + self.add({self._cloud_data_key: attachments}) + self.untrack = False + + # TODO: when it's a item attachment the attachment itself + # is not downloaded. We must download it... + # TODO: idea: retrieve the attachments ids' only with + # select and then download one by one. + return True
+ + def _update_attachments_to_cloud(self): + """ Push new, unsaved attachments to the cloud and remove removed + attachments. This method should not be called for non draft messages. + """ + url = self.build_url(self._endpoints.get('attachments').format( + id=self._parent.object_id)) + + # ! potentially several api requests can be made by this method. + + for attachment in self.__attachments: + if attachment.on_cloud is False: + # upload attachment: + response = self._parent.con.post(url, + data=attachment.to_api_data()) + if not response: + return False + + data = response.json() + + # update attachment data + attachment.attachment_id = data.get('id') + attachment.content = data.get(self._cc('contentBytes'), None) + attachment.on_cloud = True + + for attachment in self.__removed_attachments: + if attachment.on_cloud and attachment.attachment_id is not None: + # delete attachment + url = self.build_url(self._endpoints.get('attachment').format( + id=self._parent.object_id, ida=attachment.attachment_id)) + + response = self._parent.con.delete(url) + if not response: + return False + + self.__removed_attachments = [] # reset the removed attachments + + log.debug('Successfully updated attachments on {}'.format( + self._parent.object_id)) + + return True
+
+ +
+ +
+ + +
+
+ +
+ +
+ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/O365/utils/query.html b/docs/latest/_modules/O365/utils/query.html new file mode 100644 index 00000000..0ffcfe54 --- /dev/null +++ b/docs/latest/_modules/O365/utils/query.html @@ -0,0 +1,1119 @@ + + + + + + + + O365.utils.query — O365 documentation + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+ +
+
+
+
+ +

Source code for O365.utils.query

+from __future__ import annotations
+
+import datetime as dt
+from abc import ABC, abstractmethod
+from typing import Union, Optional, TYPE_CHECKING, Type, Iterator, Literal, TypeAlias
+
+if TYPE_CHECKING:
+    from O365.connection import Protocol
+
+FilterWord: TypeAlias = Union[str, bool, None, dt.date, int, float]
+
+
+
+[docs] +class QueryBase(ABC): + __slots__ = () + +
+[docs] + @abstractmethod + def as_params(self) -> dict: + pass
+ + +
+[docs] + @abstractmethod + def render(self) -> str: + pass
+ + + def __str__(self): + return self.__repr__() + + def __repr__(self): + return self.render() + + @abstractmethod + def __and__(self, other): + pass + + @abstractmethod + def __or__(self, other): + pass + +
+[docs] + def get_filter_by_attribute(self, attribute: str) -> Optional[str]: + """ + Returns a filter value by attribute name. It will match the attribute to the start of each filter attribute + and return the first found. + + :param attribute: the attribute you want to search + :return: The value applied to that attribute or None + """ + search_object: Optional[QueryFilter] = getattr(self, "_filter_instance", None) or getattr(self, "filters", None) + if search_object is not None: + # CompositeFilter, IterableFilter, ModifierQueryFilter (negate, group) + return search_object.get_filter_by_attribute(attribute) + + search_object: Optional[list[QueryFilter]] = getattr(self, "_filter_instances", None) + if search_object is not None: + # ChainFilter + for filter_obj in search_object: + result = filter_obj.get_filter_by_attribute(attribute) + if result is not None: + return result + return None + + search_object: Optional[str] = getattr(self, "_attribute", None) + if search_object is not None: + # LogicalFilter or FunctionFilter + if search_object.lower().startswith(attribute.lower()): + return getattr(self, "_word") + return None
+
+ + + +
+[docs] +class QueryFilter(QueryBase, ABC): + __slots__ = () + +
+[docs] + @abstractmethod + def render(self, item_name: Optional[str] = None) -> str: + pass
+ + +
+[docs] + def as_params(self) -> dict: + return {"$filter": self.render()}
+ + + def __and__(self, other: Optional[QueryBase]) -> QueryBase: + if other is None: + return self + if isinstance(other, QueryFilter): + return ChainFilter("and", [self, other]) + elif isinstance(other, OrderByFilter): + return CompositeFilter(filters=self, order_by=other) + elif isinstance(other, SearchFilter): + raise ValueError("Can't mix search with filters or order by clauses.") + elif isinstance(other, SelectFilter): + return CompositeFilter(filters=self, select=other) + elif isinstance(other, ExpandFilter): + return CompositeFilter(filters=self, expand=other) + else: + raise ValueError(f"Can't mix {type(other)} with {type(self)}") + + + def __or__(self, other: QueryFilter) -> ChainFilter: + if not isinstance(other, QueryFilter): + raise ValueError("Can't chain a non-query filter with and 'or' operator. Use 'and' instead.") + return ChainFilter("or", [self, other])
+ + + +
+[docs] +class OperationQueryFilter(QueryFilter, ABC): + __slots__ = ("_operation",) + +
+[docs] + def __init__(self, operation: str): + self._operation: str = operation
+
+ + + +
+[docs] +class LogicalFilter(OperationQueryFilter): + __slots__ = ("_operation", "_attribute", "_word") + +
+[docs] + def __init__(self, operation: str, attribute: str, word: str): + super().__init__(operation) + self._attribute: str = attribute + self._word: str = word
+ + + def _prepare_attribute(self, item_name: str = None) -> str: + if item_name: + if self._attribute is None: + # iteration will occur in the item itself + return f"{item_name}" + else: + return f"{item_name}/{self._attribute}" + else: + return self._attribute + +
+[docs] + def render(self, item_name: Optional[str] = None) -> str: + return f"{self._prepare_attribute(item_name)} {self._operation} {self._word}"
+
+ + + +
+[docs] +class FunctionFilter(LogicalFilter): + __slots__ = ("_operation", "_attribute", "_word") + +
+[docs] + def render(self, item_name: Optional[str] = None) -> str: + return f"{self._operation}({self._prepare_attribute(item_name)}, {self._word})"
+
+ + + +
+[docs] +class IterableFilter(OperationQueryFilter): + __slots__ = ("_operation", "_collection", "_item_name", "_filter_instance") + +
+[docs] + def __init__(self, operation: str, collection: str, filter_instance: QueryFilter, *, item_name: str = "a"): + super().__init__(operation) + self._collection: str = collection + self._item_name: str = item_name + self._filter_instance: QueryFilter = filter_instance
+ + +
+[docs] + def render(self, item_name: Optional[str] = None) -> str: + # an iterable filter will always ignore external item names + filter_instance_render = self._filter_instance.render(item_name=self._item_name) + return f"{self._collection}/{self._operation}({self._item_name}: {filter_instance_render})"
+
+ + + +
+[docs] +class ChainFilter(OperationQueryFilter): + __slots__ = ("_operation", "_filter_instances") + +
+[docs] + def __init__(self, operation: str, filter_instances: list[QueryFilter]): + assert operation in ("and", "or") + super().__init__(operation) + self._filter_instances: list[QueryFilter] = filter_instances
+ + +
+[docs] + def render(self, item_name: Optional[str] = None) -> str: + return f" {self._operation} ".join([fi.render(item_name) for fi in self._filter_instances])
+
+ + + +
+[docs] +class ModifierQueryFilter(QueryFilter, ABC): + __slots__ = ("_filter_instance",) + +
+[docs] + def __init__(self, filter_instance: QueryFilter): + self._filter_instance: QueryFilter = filter_instance
+
+ + + +
+[docs] +class NegateFilter(ModifierQueryFilter): + __slots__ = ("_filter_instance",) + +
+[docs] + def render(self, item_name: Optional[str] = None) -> str: + return f"not {self._filter_instance.render(item_name=item_name)}"
+
+ + + +
+[docs] +class GroupFilter(ModifierQueryFilter): + __slots__ = ("_filter_instance",) + +
+[docs] + def render(self, item_name: Optional[str] = None) -> str: + return f"({self._filter_instance.render(item_name=item_name)})"
+
+ + + +
+[docs] +class SearchFilter(QueryBase): + __slots__ = ("_search",) + +
+[docs] + def __init__(self, word: Optional[Union[str, int, bool]] = None, attribute: Optional[str] = None): + if word: + if attribute: + self._search: str = f"{attribute}:{word}" + else: + self._search: str = word + else: + self._search: str = ""
+ + + def _combine(self, search_one: str, search_two: str, operator: str = "and"): + self._search = f"{search_one} {operator} {search_two}" + +
+[docs] + def render(self) -> str: + return f'"{self._search}"'
+ + +
+[docs] + def as_params(self) -> dict: + return {"$search": self.render()}
+ + + def __and__(self, other: Optional[QueryBase]) -> QueryBase: + if other is None: + return self + if isinstance(other, SearchFilter): + new_search = self.__class__() + new_search._combine(self._search, other._search, operator="and") + return new_search + elif isinstance(other, QueryFilter): + raise ValueError("Can't mix search with filters clauses.") + elif isinstance(other, OrderByFilter): + raise ValueError("Can't mix search with order by clauses.") + elif isinstance(other, SelectFilter): + return CompositeFilter(search=self, select=other) + elif isinstance(other, ExpandFilter): + return CompositeFilter(search=self, expand=other) + else: + raise ValueError(f"Can't mix {type(other)} with {type(self)}") + + def __or__(self, other: QueryBase) -> SearchFilter: + if not isinstance(other, SearchFilter): + raise ValueError("Can't chain a non-search filter with and 'or' operator. Use 'and' instead.") + new_search = self.__class__() + new_search._combine(self._search, other._search, operator="or") + return new_search
+ + + +
+[docs] +class OrderByFilter(QueryBase): + __slots__ = ("_orderby",) + +
+[docs] + def __init__(self): + self._orderby: list[tuple[str, bool]] = []
+ + + def _sorted_attributes(self) -> list[str]: + return [att for att, asc in self._orderby] + +
+[docs] + def add(self, attribute: str, ascending: bool = True) -> None: + if not attribute: + raise ValueError("Attribute can't be empty") + if attribute not in self._sorted_attributes(): + self._orderby.append((attribute, ascending))
+ + +
+[docs] + def render(self) -> str: + return ",".join(f"{att} {'' if asc else 'desc'}".strip() for att, asc in self._orderby)
+ + +
+[docs] + def as_params(self) -> dict: + return {"$orderby": self.render()}
+ + + def __and__(self, other: Optional[QueryBase]) -> QueryBase: + if other is None: + return self + if isinstance(other, OrderByFilter): + new_order_by = self.__class__() + for att, asc in self._orderby: + new_order_by.add(att, asc) + for att, asc in other._orderby: + new_order_by.add(att, asc) + return new_order_by + elif isinstance(other, SearchFilter): + raise ValueError("Can't mix order by with search clauses.") + elif isinstance(other, QueryFilter): + return CompositeFilter(order_by=self, filters=other) + elif isinstance(other, SelectFilter): + return CompositeFilter(order_by=self, select=other) + elif isinstance(other, ExpandFilter): + return CompositeFilter(order_by=self, expand=other) + else: + raise ValueError(f"Can't mix {type(other)} with {type(self)}") + + def __or__(self, other: QueryBase): + raise RuntimeError("Orderby clauses are mutually exclusive")
+ + + +
+[docs] +class ContainerQueryFilter(QueryBase): + __slots__ = ("_container", "_keyword") + +
+[docs] + def __init__(self, *args: Union[str, tuple[str, SelectFilter]]): + self._container: list[Union[str, tuple[str, SelectFilter]]] = list(args) + self._keyword: str = ''
+ + +
+[docs] + def append(self, item: Union[str, tuple[str, SelectFilter]]) -> None: + self._container.append(item)
+ + + def __iter__(self) -> Iterator[Union[str, tuple[str, SelectFilter]]]: + return iter(self._container) + + def __contains__(self, attribute: str) -> bool: + return attribute in [item[0] if isinstance(item, tuple) else item for item in self._container] + + def __and__(self, other: Optional[QueryBase]) -> QueryBase: + if other is None: + return self + if (isinstance(other, SelectFilter) and isinstance(self, SelectFilter) + ) or (isinstance(other, ExpandFilter) and isinstance(self, ExpandFilter)): + new_container = self.__class__(*self) + for item in other: + if isinstance(item, tuple): + attribute = item[0] + else: + attribute = item + if attribute not in new_container: + new_container.append(item) + return new_container + elif isinstance(other, QueryFilter): + return CompositeFilter(**{self._keyword: self, "filters": other}) + elif isinstance(other, SearchFilter): + return CompositeFilter(**{self._keyword: self, "search": other}) + elif isinstance(other, OrderByFilter): + return CompositeFilter(**{self._keyword: self, "order_by": other}) + elif isinstance(other, SelectFilter): + return CompositeFilter(**{self._keyword: self, "select": other}) + elif isinstance(other, ExpandFilter): + return CompositeFilter(**{self._keyword: self, "expand": other}) + else: + raise ValueError(f"Can't mix {type(other)} with {type(self)}") + + def __or__(self, other: Optional[QueryBase]): + raise RuntimeError("Can't combine multiple composite filters with an 'or' statement. Use 'and' instead.") + +
+[docs] + def render(self) -> str: + return ",".join(self._container)
+ + +
+[docs] + def as_params(self) -> dict: + return {f"${self._keyword}": self.render()}
+
+ + + +
+[docs] +class SelectFilter(ContainerQueryFilter): + __slots__ = ("_container", "_keyword") + +
+[docs] + def __init__(self, *args: str): + super().__init__(*args) + self._keyword: str = "select"
+
+ + + +
+[docs] +class ExpandFilter(ContainerQueryFilter): + __slots__ = ("_container", "_keyword") + +
+[docs] + def __init__(self, *args: Union[str, tuple[str, SelectFilter]]): + super().__init__(*args) + self._keyword: str = "expand"
+ + +
+[docs] + def render(self) -> str: + renders = [] + for item in self._container: + if isinstance(item, tuple): + renders.append(f"{item[0]}($select={item[1].render()})") + else: + renders.append(item) + return ",".join(renders)
+
+ + + +
+[docs] +class CompositeFilter(QueryBase): + """ A Query object that holds all query parameters. """ + + __slots__ = ("filters", "search", "order_by", "select", "expand") + +
+[docs] + def __init__(self, *, filters: Optional[QueryFilter] = None, search: Optional[SearchFilter] = None, + order_by: Optional[OrderByFilter] = None, select: Optional[SelectFilter] = None, + expand: Optional[ExpandFilter] = None): + self.filters: Optional[QueryFilter] = filters + self.search: Optional[SearchFilter] = search + self.order_by: Optional[OrderByFilter] = order_by + self.select: Optional[SelectFilter] = select + self.expand: Optional[ExpandFilter] = expand
+ + +
+[docs] + def render(self) -> str: + return ( + f"Filters: {self.filters.render() if self.filters else ''}\n" + f"Search: {self.search.render() if self.search else ''}\n" + f"OrderBy: {self.order_by.render() if self.order_by else ''}\n" + f"Select: {self.select.render() if self.select else ''}\n" + f"Expand: {self.expand.render() if self.expand else ''}" + )
+ + + @property + def has_only_filters(self) -> bool: + """ Returns true if it only has filters""" + return (self.filters is not None and self.search is None and + self.order_by is None and self.select is None and self.expand is None) + +
+[docs] + def as_params(self) -> dict: + params = {} + if self.filters: + params.update(self.filters.as_params()) + if self.search: + params.update(self.search.as_params()) + if self.order_by: + params.update(self.order_by.as_params()) + if self.expand: + params.update(self.expand.as_params()) + if self.select: + params.update(self.select.as_params()) + return params
+ + + def __and__(self, other: Optional[QueryBase]) -> CompositeFilter: + """ Combine this CompositeFilter with another QueryBase object """ + if other is None: + return self + nc = CompositeFilter(filters=self.filters, search=self.search, order_by=self.order_by, + select=self.select, expand=self.expand) + if isinstance(other, QueryFilter): + if self.search is not None: + raise ValueError("Can't mix search with filters or order by clauses.") + nc.filters = nc.filters & other if nc.filters else other + elif isinstance(other, OrderByFilter): + if self.search is not None: + raise ValueError("Can't mix search with filters or order by clauses.") + nc.order_by = nc.order_by & other if nc.order_by else other + elif isinstance(other, SearchFilter): + if self.filters is not None or self.order_by is not None: + raise ValueError("Can't mix search with filters or order by clauses.") + nc.search = nc.search & other if nc.search else other + elif isinstance(other, SelectFilter): + nc.select = nc.select & other if nc.select else other + elif isinstance(other, ExpandFilter): + nc.expand = nc.expand & other if nc.expand else other + elif isinstance(other, CompositeFilter): + if (self.search and (other.filters or other.order_by) + ) or (other.search and (self.filters or self.order_by)): + raise ValueError("Can't mix search with filters or order by clauses.") + nc.filters = nc.filters & other.filters if nc.filters else other.filters + nc.search = nc.search & other.search if nc.search else other.search + nc.order_by = nc.order_by & other.order_by if nc.order_by else other.order_by + nc.select = nc.select & other.select if nc.select else other.select + nc.expand = nc.expand & other.expand if nc.expand else other.expand + return nc + + def __or__(self, other: Optional[QueryBase]) -> CompositeFilter: + if isinstance(other, CompositeFilter): + if self.has_only_filters and other.has_only_filters: + return CompositeFilter(filters=self.filters | other.filters) + raise RuntimeError("Can't combine multiple composite filters with an 'or' statement. Use 'and' instead.")
+ + + +
+[docs] +class QueryBuilder: + + _attribute_mapping = { + "from": "from/emailAddress/address", + "to": "toRecipients/emailAddress/address", + "start": "start/DateTime", + "end": "end/DateTime", + "due": "duedatetime/DateTime", + "reminder": "reminderdatetime/DateTime", + "flag": "flag/flagStatus", + "body": "body/content" + } + +
+[docs] + def __init__(self, protocol: Union[Protocol, Type[Protocol]]): + """ Build a query to apply OData filters + https://docs.microsoft.com/en-us/graph/query-parameters + + :param Protocol protocol: protocol to retrieve the timezone from + """ + self.protocol = protocol() if isinstance(protocol, type) else protocol
+ + + def _parse_filter_word(self, word: FilterWord) -> str: + """ Converts the word parameter into a string """ + if isinstance(word, str): + # string must be enclosed in quotes + parsed_word = f"'{word}'" + elif isinstance(word, bool): + # bools are treated as lower case bools + parsed_word = str(word).lower() + elif word is None: + parsed_word = "null" + elif isinstance(word, dt.date): + if isinstance(word, dt.datetime): + if word.tzinfo is None: + # if it's a naive datetime, localize the datetime. + word = word.replace(tzinfo=self.protocol.timezone) # localize datetime into local tz + # convert datetime to iso format + parsed_word = f"{word.isoformat()}" + else: + # other cases like int or float, return as a string. + parsed_word = str(word) + return parsed_word + + def _get_attribute_from_mapping(self, attribute: str) -> str: + """ + Look up the provided attribute into the query builder mapping + Applies a conversion to the appropriate casing defined by the protocol. + + :param attribute: attribute to look up + :return: the attribute itself of if found the corresponding complete attribute in the mapping + """ + mapping = self._attribute_mapping.get(attribute) + if mapping: + attribute = "/".join( + [self.protocol.convert_case(step) for step in + mapping.split("/")]) + else: + attribute = self.protocol.convert_case(attribute) + return attribute + +
+[docs] + def logical_operation(self, operation: str, attribute: str, word: FilterWord) -> CompositeFilter: + """ Apply a logical operation like equals, less than, etc. + + :param operation: how to combine with a new one + :param attribute: attribute to compare word with + :param word: value to compare the attribute with + :return: a CompositeFilter instance that can render the OData logical operation + """ + logical_filter = LogicalFilter(operation, + self._get_attribute_from_mapping(attribute), + self._parse_filter_word(word)) + return CompositeFilter(filters=logical_filter)
+ + +
+[docs] + def equals(self, attribute: str, word: FilterWord) -> CompositeFilter: + """ Return an equals check + + :param attribute: attribute to compare word with + :param word: word to compare with + :return: a CompositeFilter instance that can render the OData this logical operation + """ + return self.logical_operation("eq", attribute, word)
+ + +
+[docs] + def unequal(self, attribute: str, word: FilterWord) -> CompositeFilter: + """ Return an unequal check + + :param attribute: attribute to compare word with + :param word: word to compare with + :return: a CompositeFilter instance that can render the OData this logical operation + """ + return self.logical_operation("ne", attribute, word)
+ + +
+[docs] + def greater(self, attribute: str, word: FilterWord) -> CompositeFilter: + """ Return a 'greater than' check + + :param attribute: attribute to compare word with + :param word: word to compare with + :return: a CompositeFilter instance that can render the OData this logical operation + """ + return self.logical_operation("gt", attribute, word)
+ + +
+[docs] + def greater_equal(self, attribute: str, word: FilterWord) -> CompositeFilter: + """ Return a 'greater than or equal to' check + + :param attribute: attribute to compare word with + :param word: word to compare with + :return: a CompositeFilter instance that can render the OData this logical operation + """ + return self.logical_operation("ge", attribute, word)
+ + +
+[docs] + def less(self, attribute: str, word: FilterWord) -> CompositeFilter: + """ Return a 'less than' check + + :param attribute: attribute to compare word with + :param word: word to compare with + :return: a CompositeFilter instance that can render the OData this logical operation + """ + return self.logical_operation("lt", attribute, word)
+ + +
+[docs] + def less_equal(self, attribute: str, word: FilterWord) -> CompositeFilter: + """ Return a 'less than or equal to' check + + :param attribute: attribute to compare word with + :param word: word to compare with + :return: a CompositeFilter instance that can render the OData this logical operation + """ + return self.logical_operation("le", attribute, word)
+ + +
+[docs] + def function_operation(self, operation: str, attribute: str, word: FilterWord) -> CompositeFilter: + """ Apply a function operation + + :param operation: function name to operate on attribute + :param attribute: the name of the attribute on which to apply the function + :param word: value to feed the function + :return: a CompositeFilter instance that can render the OData function operation + """ + function_filter = FunctionFilter(operation, + self._get_attribute_from_mapping(attribute), + self._parse_filter_word(word)) + return CompositeFilter(filters=function_filter)
+ + +
+[docs] + def contains(self, attribute: str, word: FilterWord) -> CompositeFilter: + """ Adds a contains word check + + :param attribute: the name of the attribute on which to apply the function + :param word: value to feed the function + :return: a CompositeFilter instance that can render the OData function operation + """ + return self.function_operation("contains", attribute, word)
+ + +
+[docs] + def startswith(self, attribute: str, word: FilterWord) -> CompositeFilter: + """ Adds a startswith word check + + :param attribute: the name of the attribute on which to apply the function + :param word: value to feed the function + :return: a CompositeFilter instance that can render the OData function operation + """ + return self.function_operation("startswith", attribute, word)
+ + +
+[docs] + def endswith(self, attribute: str, word: FilterWord) -> CompositeFilter: + """ Adds a endswith word check + + :param attribute: the name of the attribute on which to apply the function + :param word: value to feed the function + :return: a CompositeFilter instance that can render the OData function operation + """ + return self.function_operation("endswith", attribute, word)
+ + +
+[docs] + def iterable_operation(self, operation: str, collection: str, filter_instance: CompositeFilter, + *, item_name: str = "a") -> CompositeFilter: + """ Performs the provided filter operation on a collection by iterating over it. + + For example: + + .. code-block:: python + + q.iterable( + operation='any', + collection='email_addresses', + filter_instance=q.equals('address', 'george@best.com') + ) + + will transform to a filter such as: + emailAddresses/any(a:a/address eq 'george@best.com') + + :param operation: the iterable operation name + :param collection: the collection to apply the iterable operation on + :param filter_instance: a CompositeFilter instance on which you will apply the iterable operation + :param item_name: the name of the collection item to be used on the filter_instance + :return: a CompositeFilter instance that can render the OData iterable operation + """ + iterable_filter = IterableFilter(operation, + self._get_attribute_from_mapping(collection), + filter_instance.filters, + item_name=item_name) + return CompositeFilter(filters=iterable_filter)
+ + + +
+[docs] + def any(self, collection: str, filter_instance: CompositeFilter, *, item_name: str = "a") -> CompositeFilter: + """ Performs a filter with the OData 'any' keyword on the collection + + For example: + q.any(collection='email_addresses', filter_instance=q.equals('address', 'george@best.com')) + + will transform to a filter such as: + + emailAddresses/any(a:a/address eq 'george@best.com') + + :param collection: the collection to apply the iterable operation on + :param filter_instance: a CompositeFilter Instance on which you will apply the iterable operation + :param item_name: the name of the collection item to be used on the filter_instance + :return: a CompositeFilter instance that can render the OData iterable operation + """ + + return self.iterable_operation("any", collection=collection, + filter_instance=filter_instance, item_name=item_name)
+ + + +
+[docs] + def all(self, collection: str, filter_instance: CompositeFilter, *, item_name: str = "a") -> CompositeFilter: + """ Performs a filter with the OData 'all' keyword on the collection + + For example: + q.all(collection='email_addresses', filter_instance=q.equals('address', 'george@best.com')) + + will transform to a filter such as: + + emailAddresses/all(a:a/address eq 'george@best.com') + + :param collection: the collection to apply the iterable operation on + :param filter_instance: a CompositeFilter Instance on which you will apply the iterable operation + :param item_name: the name of the collection item to be used on the filter_instance + :return: a CompositeFilter instance that can render the OData iterable operation + """ + + return self.iterable_operation("all", collection=collection, + filter_instance=filter_instance, item_name=item_name)
+ + +
+[docs] + @staticmethod + def negate(filter_instance: CompositeFilter) -> CompositeFilter: + """ Apply a not operator to the provided QueryFilter + :param filter_instance: a CompositeFilter instance + :return: a CompositeFilter with its filter negated + """ + negate_filter = NegateFilter(filter_instance=filter_instance.filters) + return CompositeFilter(filters=negate_filter)
+ + + def _chain(self, operator: str, *filter_instances: CompositeFilter, group: bool = False) -> CompositeFilter: + chain = ChainFilter(operation=operator, filter_instances=[fl.filters for fl in filter_instances]) + chain = CompositeFilter(filters=chain) + if group: + return self.group(chain) + else: + return chain + +
+[docs] + def chain_and(self, *filter_instances: CompositeFilter, group: bool = False) -> CompositeFilter: + """ Start a chain 'and' operation + + :param filter_instances: a list of other CompositeFilter you want to combine with the 'and' operation + :param group: will group this chain operation if True + :return: a CompositeFilter with the filter instances combined with an 'and' operation + """ + return self._chain("and", *filter_instances, group=group)
+ + +
+[docs] + def chain_or(self, *filter_instances: CompositeFilter, group: bool = False) -> CompositeFilter: + """ Start a chain 'or' operation. Will automatically apply a grouping. + + :param filter_instances: a list of other CompositeFilter you want to combine with the 'or' operation + :param group: will group this chain operation if True + :return: a CompositeFilter with the filter instances combined with an 'or' operation + """ + return self._chain("or", *filter_instances, group=group)
+ + +
+[docs] + @staticmethod + def group(filter_instance: CompositeFilter) -> CompositeFilter: + """ Applies a grouping to the provided filter_instance """ + group_filter = GroupFilter(filter_instance.filters) + return CompositeFilter(filters=group_filter)
+ + +
+[docs] + def search(self, word: Union[str, int, bool], attribute: Optional[str] = None) -> CompositeFilter: + """ + Perform a search. + Note from graph docs: + + You can currently search only message and person collections. + A $search request returns up to 250 results. + You cannot use $filter or $orderby in a search request. + + :param word: the text to search + :param attribute: the attribute to search the word on + :return: a CompositeFilter instance that can render the OData search operation + """ + word = self._parse_filter_word(word) + if attribute: + attribute = self._get_attribute_from_mapping(attribute) + search = SearchFilter(word=word, attribute=attribute) + return CompositeFilter(search=search)
+ + +
+[docs] + @staticmethod + def orderby(*attributes: tuple[Union[str, tuple[str, bool]]]) -> CompositeFilter: + """ + Returns an 'order by' query param + This is useful to order the result set of query from a resource. + Note that not all attributes can be sorted and that all resources have different sort capabilities + + :param attributes: the attributes to orderby + :return: a CompositeFilter instance that can render the OData order by operation + """ + new_order_by = OrderByFilter() + for order_by_clause in attributes: + if isinstance(order_by_clause, str): + new_order_by.add(order_by_clause) + elif isinstance(order_by_clause, tuple): + new_order_by.add(order_by_clause[0], order_by_clause[1]) + else: + raise ValueError("Arguments must be attribute strings or tuples" + " of attribute strings and ascending booleans") + return CompositeFilter(order_by=new_order_by)
+ + +
+[docs] + def select(self, *attributes: str) -> CompositeFilter: + """ + Returns a 'select' query param + This is useful to return a limited set of attributes from a resource or return attributes that are not + returned by default by the resource. + + :param attributes: a tuple of attribute names to select + :return: a CompositeFilter instance that can render the OData select operation + """ + select = SelectFilter() + for attribute in attributes: + attribute = self.protocol.convert_case(attribute) + if attribute.lower() in ["meetingmessagetype"]: + attribute = f"{self.protocol.keyword_data_store['event_message_type']}/{attribute}" + select.append(attribute) + return CompositeFilter(select=select)
+ + +
+[docs] + def expand(self, relationship: str, select: Optional[CompositeFilter] = None) -> CompositeFilter: + """ + Returns an 'expand' query param + Important: If the 'expand' is a relationship (e.g. "event" or "attachments"), then the ApiComponent using + this query should know how to handle the relationship (e.g. Message knows how to handle attachments, + and event (if it's an EventMessage). + Important: When using expand on multi-value relationships a max of 20 items will be returned. + + :param relationship: a relationship that will be expanded + :param select: a CompositeFilter instance to select attributes on the expanded relationship + :return: a CompositeFilter instance that can render the OData expand operation + """ + expand = ExpandFilter() + # this will prepend the event message type tag based on the protocol + if relationship == "event": + relationship = f"{self.protocol.get_service_keyword('event_message_type')}/event" + + if select is not None: + expand.append((relationship, select.select)) + else: + expand.append(relationship) + return CompositeFilter(expand=expand)
+
+ +
+ +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/docs/latest/_modules/O365/utils/token.html b/docs/latest/_modules/O365/utils/token.html new file mode 100644 index 00000000..73d9269a --- /dev/null +++ b/docs/latest/_modules/O365/utils/token.html @@ -0,0 +1,1279 @@ + + + + + + + + O365.utils.token — O365 documentation + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+ +
+
+
+
+ +

Source code for O365.utils.token

+import datetime as dt
+import json
+import logging
+import os
+from pathlib import Path
+from typing import Optional, Protocol, Union
+
+from msal.token_cache import TokenCache
+
+log = logging.getLogger(__name__)
+
+
+RESERVED_SCOPES = {"profile", "openid", "offline_access"}
+
+
+
+[docs] +class CryptographyManagerType(Protocol): + """Abstract cryptography manafer""" + +
+[docs] + def encrypt(self, data: str) -> bytes: ...
+ + +
+[docs] + def decrypt(self, data: bytes) -> str: ...
+
+ + + +
+[docs] +class BaseTokenBackend(TokenCache): + """A base token storage class""" + + serializer = json # The default serializer is json + +
+[docs] + def __init__(self): + super().__init__() + self._has_state_changed: bool = False + #: Optional cryptography manager. |br| **Type:** CryptographyManagerType + self.cryptography_manager: Optional[CryptographyManagerType] = None
+ + + @property + def has_data(self) -> bool: + """Does the token backend contain data.""" + return bool(self._cache) + +
+[docs] + def token_expiration_datetime( + self, *, username: Optional[str] = None + ) -> Optional[dt.datetime]: + """ + Returns the current access token expiration datetime + If the refresh token is present, then the expiration datetime is extended by 3 months + :param str username: The username from which check the tokens + :return dt.datetime or None: The expiration datetime + """ + access_token = self.get_access_token(username=username) + if access_token is None: + return None + + expires_on = access_token.get("expires_on") + if expires_on is None: + # consider the token has expired + return None + else: + expires_on = int(expires_on) + return dt.datetime.fromtimestamp(expires_on)
+ + +
+[docs] + def token_is_expired(self, *, username: Optional[str] = None) -> bool: + """ + Checks whether the current access token is expired + :param str username: The username from which check the tokens + :return bool: True if the token is expired, False otherwise + """ + token_expiration_datetime = self.token_expiration_datetime(username=username) + if token_expiration_datetime is None: + return True + else: + return dt.datetime.now() > token_expiration_datetime
+ + +
+[docs] + def token_is_long_lived(self, *, username: Optional[str] = None) -> bool: + """Returns if the token backend has a refresh token""" + return self.get_refresh_token(username=username) is not None
+ + + def _get_home_account_id(self, username: str) -> Optional[str]: + """Gets the home_account_id string from the ACCOUNT cache for the specified username""" + + result = list( + self.search(TokenCache.CredentialType.ACCOUNT, query={"username": username}) + ) + if result: + return result[0].get("home_account_id") + else: + log.debug(f"No account found for username: {username}") + return None + +
+[docs] + def get_all_accounts(self) -> list[dict]: + """Returns a list of all accounts present in the token cache""" + return list(self.search(TokenCache.CredentialType.ACCOUNT))
+ + +
+[docs] + def get_account( + self, *, username: Optional[str] = None, home_account_id: Optional[str] = None + ) -> Optional[dict]: + """Gets the account object for the specified username or home_account_id""" + if username and home_account_id: + raise ValueError( + 'Provide nothing or either username or home_account_id to "get_account", but not both' + ) + + query = None + if username is not None: + query = {"username": username} + if home_account_id is not None: + query = {"home_account_id": home_account_id} + + result = list(self.search(TokenCache.CredentialType.ACCOUNT, query=query)) + + if result: + return result[0] + else: + return None
+ + +
+[docs] + def get_access_token(self, *, username: Optional[str] = None) -> Optional[dict]: + """ + Retrieve the stored access token + If username is None, then the first access token will be retrieved + :param str username: The username from which retrieve the access token + """ + query = None + if username is not None: + home_account_id = self._get_home_account_id(username) + if home_account_id: + query = {"home_account_id": home_account_id} + else: + return None + + results = list(self.search(TokenCache.CredentialType.ACCESS_TOKEN, query=query)) + return results[0] if results else None
+ + +
+[docs] + def get_refresh_token(self, *, username: Optional[str] = None) -> Optional[dict]: + """Retrieve the stored refresh token + If username is None, then the first access token will be retrieved + :param str username: The username from which retrieve the refresh token + """ + query = None + if username is not None: + home_account_id = self._get_home_account_id(username) + if home_account_id: + query = {"home_account_id": home_account_id} + else: + return None + + results = list( + self.search(TokenCache.CredentialType.REFRESH_TOKEN, query=query) + ) + return results[0] if results else None
+ + +
+[docs] + def get_id_token(self, *, username: Optional[str] = None) -> Optional[dict]: + """Retrieve the stored id token + If username is None, then the first id token will be retrieved + :param str username: The username from which retrieve the id token + """ + query = None + if username is not None: + home_account_id = self._get_home_account_id(username) + if home_account_id: + query = {"home_account_id": home_account_id} + else: + return None + + results = list(self.search(TokenCache.CredentialType.ID_TOKEN, query=query)) + return results[0] if results else None
+ + +
+[docs] + def get_token_scopes( + self, *, username: Optional[str] = None, remove_reserved: bool = False + ) -> Optional[list]: + """ + Retrieve the scopes the token (refresh first then access) has permissions on + :param str username: The username from which retrieve the refresh token + :param bool remove_reserved: if True RESERVED_SCOPES will be removed from the list + """ + token = self.get_refresh_token(username=username) or self.get_access_token( + username=username + ) + if token: + scopes_str = token.get("target") + if scopes_str: + scopes = scopes_str.split(" ") + if remove_reserved: + scopes = [scope for scope in scopes if scope not in RESERVED_SCOPES] + return scopes + return None
+ + +
+[docs] + def remove_data(self, *, username: str) -> bool: + """ + Removes all tokens and all related data from the token cache for the specified username. + Returns success or failure. + :param str username: The username from which remove the tokens and related data + """ + home_account_id = self._get_home_account_id(username) + if not home_account_id: + return False + + query = {"home_account_id": home_account_id} + + # remove id token + results = list(self.search(TokenCache.CredentialType.ID_TOKEN, query=query)) + for id_token in results: + self.remove_idt(id_token) + + # remove access token + results = list(self.search(TokenCache.CredentialType.ACCESS_TOKEN, query=query)) + for access_token in results: + self.remove_at(access_token) + + # remove refresh tokens + results = list( + self.search(TokenCache.CredentialType.REFRESH_TOKEN, query=query) + ) + for refresh_token in results: + self.remove_rt(refresh_token) + + # remove accounts + results = list(self.search(TokenCache.CredentialType.ACCOUNT, query=query)) + for account in results: + self.remove_account(account) + + self._has_state_changed = True + return True
+ + +
+[docs] + def add(self, event, **kwargs) -> None: + """Add to the current cache.""" + super().add(event, **kwargs) + self._has_state_changed = True
+ + +
+[docs] + def modify(self, credential_type, old_entry, new_key_value_pairs=None) -> None: + """Modify content in the cache.""" + super().modify(credential_type, old_entry, new_key_value_pairs) + self._has_state_changed = True
+ + +
+[docs] + def serialize(self) -> Union[bytes, str]: + """Serialize the current cache state into a string.""" + with self._lock: + self._has_state_changed = False + token_str = self.serializer.dumps(self._cache, indent=4) + if self.cryptography_manager is not None: + token_str = self.cryptography_manager.encrypt(token_str) + return token_str
+ + +
+[docs] + def deserialize(self, token_cache_state: Union[bytes, str]) -> dict: + """Deserialize the cache from a state previously obtained by serialize()""" + with self._lock: + self._has_state_changed = False + if self.cryptography_manager is not None: + token_cache_state = self.cryptography_manager.decrypt(token_cache_state) + return self.serializer.loads(token_cache_state) if token_cache_state else {}
+ + +
+[docs] + def load_token(self) -> bool: + """ + Abstract method that will retrieve the token data from the backend + This MUST be implemented in subclasses + """ + raise NotImplementedError
+ + +
+[docs] + def save_token(self, force=False) -> bool: + """ + Abstract method that will save the token data into the backend + This MUST be implemented in subclasses + """ + raise NotImplementedError
+ + +
+[docs] + def delete_token(self) -> bool: + """Optional Abstract method to delete the token from the backend""" + raise NotImplementedError
+ + +
+[docs] + def check_token(self) -> bool: + """Optional Abstract method to check for the token existence in the backend""" + raise NotImplementedError
+ + +
+[docs] + def should_refresh_token(self, con=None) -> Optional[bool]: + """ + This method is intended to be implemented for environments + where multiple Connection instances are running on parallel. + + This method should check if it's time to refresh the token or not. + The chosen backend can store a flag somewhere to answer this question. + This can avoid race conditions between different instances trying to + refresh the token at once, when only one should make the refresh. + + This is an example of how to achieve this: + + #. Along with the token store a Flag + #. The first to see the Flag as True must transactional update it + to False. This method then returns True and therefore the + connection will refresh the token. + #. The save_token method should be rewritten to also update the flag + back to True always. + #. Meanwhile between steps 2 and 3, any other token backend checking + for this method should get the flag with a False value. + + | This method should then wait and check again the flag. + | This can be implemented as a call with an incremental backoff + factor to avoid too many calls to the database. + | At a given point in time, the flag will return True. + | Then this method should load the token and finally return False + signaling there is no need to refresh the token. + + | If this returns True, then the Connection will refresh the token. + | If this returns False, then the Connection will NOT refresh the token. + | If this returns None, then this method already executed the refresh and therefore + the Connection does not have to. + + By default, this always returns True + + There is an example of this in the example's folder. + + :param Connection con: the connection that calls this method. This + is passed because maybe the locking mechanism needs to refresh the token within the lock applied in this method. + :rtype: bool or None + :return: | True if the Connection can refresh the token + | False if the Connection should not refresh the token + | None if the token was refreshed and therefore the + | Connection should do nothing. + """ + return True
+
+ + + +
+[docs] +class FileSystemTokenBackend(BaseTokenBackend): + """A token backend based on files on the filesystem""" + +
+[docs] + def __init__(self, token_path=None, token_filename=None): + """ + Init Backend + :param str or Path token_path: the path where to store the token + :param str token_filename: the name of the token file + """ + super().__init__() + if not isinstance(token_path, Path): + token_path = Path(token_path) if token_path else Path() + + if token_path.is_file(): + #: Path to the token stored in the file system. |br| **Type:** str + self.token_path = token_path + else: + token_filename = token_filename or "o365_token.txt" + self.token_path = token_path / token_filename
+ + + def __repr__(self): + return str(self.token_path) + +
+[docs] + def load_token(self) -> bool: + """ + Retrieves the token from the File System and stores it in the cache + :return bool: Success / Failure + """ + if self.token_path.exists(): + with self.token_path.open("r") as token_file: + token_dict = self.deserialize(token_file.read()) + if "access_token" in token_dict: + raise ValueError( + "The token you are trying to load is not valid anymore. " + "Please delete the token and proceed to authenticate again." + ) + self._cache = token_dict + log.debug(f"Token loaded from {self.token_path}") + return True + return False
+ + +
+[docs] + def save_token(self, force=False) -> bool: + """ + Saves the token cache dict in the specified file + Will create the folder if it doesn't exist + :param bool force: Force save even when state has not changed + :return bool: Success / Failure + """ + if not self._cache: + return False + + if force is False and self._has_state_changed is False: + return True + + try: + if not self.token_path.parent.exists(): + self.token_path.parent.mkdir(parents=True) + except Exception as e: + log.error("Token could not be saved: {}".format(str(e))) + return False + + with self.token_path.open("w") as token_file: + token_file.write(self.serialize()) + return True
+ + +
+[docs] + def delete_token(self) -> bool: + """ + Deletes the token file + :return bool: Success / Failure + """ + if self.token_path.exists(): + self.token_path.unlink() + return True + return False
+ + +
+[docs] + def check_token(self) -> bool: + """ + Checks if the token exists in the filesystem + :return bool: True if exists, False otherwise + """ + return self.token_path.exists()
+
+ + + +
+[docs] +class MemoryTokenBackend(BaseTokenBackend): + """A token backend stored in memory.""" + + def __repr__(self): + return "MemoryTokenBackend" + +
+[docs] + def load_token(self) -> bool: + return True
+ + +
+[docs] + def save_token(self, force=False) -> bool: + return True
+
+ + + +
+[docs] +class EnvTokenBackend(BaseTokenBackend): + """A token backend based on environmental variable.""" + +
+[docs] + def __init__(self, token_env_name=None): + """ + Init Backend + :param str token_env_name: the name of the environmental variable that will hold the token + """ + super().__init__() + + #: Name of the environment token (Default - `O365TOKEN`). |br| **Type:** str + self.token_env_name = token_env_name if token_env_name else "O365TOKEN"
+ + + def __repr__(self): + return str(self.token_env_name) + +
+[docs] + def load_token(self) -> bool: + """ + Retrieves the token from the environmental variable + :return bool: Success / Failure + """ + if self.token_env_name in os.environ: + self._cache = self.deserialize(os.environ.get(self.token_env_name)) + return True + return False
+ + +
+[docs] + def save_token(self, force=False) -> bool: + """ + Saves the token dict in the specified environmental variable + :param bool force: Force save even when state has not changed + :return bool: Success / Failure + """ + if not self._cache: + return False + + if force is False and self._has_state_changed is False: + return True + + os.environ[self.token_env_name] = self.serialize() + + return True
+ + +
+[docs] + def delete_token(self) -> bool: + """ + Deletes the token environmental variable + :return bool: Success / Failure + """ + if self.token_env_name in os.environ: + del os.environ[self.token_env_name] + return True + return False
+ + +
+[docs] + def check_token(self) -> bool: + """ + Checks if the token exists in the environmental variables + :return bool: True if exists, False otherwise + """ + return self.token_env_name in os.environ
+
+ + + +
+[docs] +class FirestoreBackend(BaseTokenBackend): + """A Google Firestore database backend to store tokens""" + +
+[docs] + def __init__(self, client, collection, doc_id, field_name="token"): + """ + Init Backend + :param firestore.Client client: the firestore Client instance + :param str collection: the firestore collection where to store tokens (can be a field_path) + :param str doc_id: # the key of the token document. Must be unique per-case. + :param str field_name: the name of the field that stores the token in the document + """ + super().__init__() + #: Fire store client. |br| **Type:** firestore.Client + self.client = client + #: Fire store colelction. |br| **Type:** str + self.collection = collection + #: Fire store token document key. |br| **Type:** str + self.doc_id = doc_id + #: Fire store document reference. |br| **Type:** any + self.doc_ref = client.collection(collection).document(doc_id) + #: Fire store token field name (Default - `token`). |br| **Type:** str + self.field_name = field_name
+ + + def __repr__(self): + return "Collection: {}. Doc Id: {}".format(self.collection, self.doc_id) + +
+[docs] + def load_token(self) -> bool: + """ + Retrieves the token from the store + :return bool: Success / Failure + """ + try: + doc = self.doc_ref.get() + except Exception as e: + log.error( + "Token (collection: {}, doc_id: {}) " + "could not be retrieved from the backend: {}".format( + self.collection, self.doc_id, str(e) + ) + ) + doc = None + if doc and doc.exists: + token_str = doc.get(self.field_name) + if token_str: + self._cache = self.deserialize(token_str) + return True + return False
+ + +
+[docs] + def save_token(self, force=False) -> bool: + """ + Saves the token dict in the store + :param bool force: Force save even when state has not changed + :return bool: Success / Failure + """ + if not self._cache: + return False + + if force is False and self._has_state_changed is False: + return True + + try: + # set token will overwrite previous data + self.doc_ref.set({self.field_name: self.serialize()}) + except Exception as e: + log.error("Token could not be saved: {}".format(str(e))) + return False + + return True
+ + +
+[docs] + def delete_token(self) -> bool: + """ + Deletes the token from the store + :return bool: Success / Failure + """ + try: + self.doc_ref.delete() + except Exception as e: + log.error( + "Could not delete the token (key: {}): {}".format(self.doc_id, str(e)) + ) + return False + return True
+ + +
+[docs] + def check_token(self) -> bool: + """ + Checks if the token exists + :return bool: True if it exists on the store + """ + try: + doc = self.doc_ref.get() + except Exception as e: + log.error( + "Token (collection: {}, doc_id: {}) " + "could not be retrieved from the backend: {}".format( + self.collection, self.doc_id, str(e) + ) + ) + doc = None + return doc and doc.exists
+
+ + + +
+[docs] +class AWSS3Backend(BaseTokenBackend): + """An AWS S3 backend to store tokens""" + +
+[docs] + def __init__(self, bucket_name, filename): + """ + Init Backend + :param str bucket_name: Name of the S3 bucket + :param str filename: Name of the S3 file + """ + try: + import boto3 + except ModuleNotFoundError as e: + raise Exception( + "Please install the boto3 package to use this token backend." + ) from e + super().__init__() + #: S3 bucket name. |br| **Type:** str + self.bucket_name = bucket_name + #: S3 file name. |br| **Type:** str + self.filename = filename + self._client = boto3.client("s3")
+ + + def __repr__(self): + return "AWSS3Backend('{}', '{}')".format(self.bucket_name, self.filename) + +
+[docs] + def load_token(self) -> bool: + """ + Retrieves the token from the store + :return bool: Success / Failure + """ + try: + token_object = self._client.get_object( + Bucket=self.bucket_name, Key=self.filename + ) + self._cache = self.deserialize(token_object["Body"].read()) + except Exception as e: + log.error( + "Token ({}) could not be retrieved from the backend: {}".format( + self.filename, e + ) + ) + return False + return True
+ + +
+[docs] + def save_token(self, force=False) -> bool: + """ + Saves the token dict in the store + :param bool force: Force save even when state has not changed + :return bool: Success / Failure + """ + if not self._cache: + return False + + if force is False and self._has_state_changed is False: + return True + + token_str = str.encode(self.serialize()) + if self.check_token(): # file already exists + try: + _ = self._client.put_object( + Bucket=self.bucket_name, Key=self.filename, Body=token_str + ) + except Exception as e: + log.error("Token file could not be saved: {}".format(e)) + return False + else: # create a new token file + try: + r = self._client.put_object( + ACL="private", + Bucket=self.bucket_name, + Key=self.filename, + Body=token_str, + ContentType="text/plain", + ) + except Exception as e: + log.error("Token file could not be created: {}".format(e)) + return False + + return True
+ + +
+[docs] + def delete_token(self) -> bool: + """ + Deletes the token from the store + :return bool: Success / Failure + """ + try: + r = self._client.delete_object(Bucket=self.bucket_name, Key=self.filename) + except Exception as e: + log.error("Token file could not be deleted: {}".format(e)) + return False + else: + log.warning( + "Deleted token file {} in bucket {}.".format( + self.filename, self.bucket_name + ) + ) + return True
+ + +
+[docs] + def check_token(self) -> bool: + """ + Checks if the token exists + :return bool: True if it exists on the store + """ + try: + _ = self._client.head_object(Bucket=self.bucket_name, Key=self.filename) + except: + return False + else: + return True
+
+ + + +
+[docs] +class AWSSecretsBackend(BaseTokenBackend): + """An AWS Secrets Manager backend to store tokens""" + +
+[docs] + def __init__(self, secret_name, region_name): + """ + Init Backend + :param str secret_name: Name of the secret stored in Secrets Manager + :param str region_name: AWS region hosting the secret (for example, 'us-east-2') + """ + try: + import boto3 + except ModuleNotFoundError as e: + raise Exception( + "Please install the boto3 package to use this token backend." + ) from e + super().__init__() + #: AWS Secret secret name. |br| **Type:** str + self.secret_name = secret_name + #: AWS Secret region name. |br| **Type:** str + self.region_name = region_name + self._client = boto3.client("secretsmanager", region_name=region_name)
+ + + def __repr__(self): + return "AWSSecretsBackend('{}', '{}')".format( + self.secret_name, self.region_name + ) + +
+[docs] + def load_token(self) -> bool: + """ + Retrieves the token from the store + :return bool: Success / Failure + """ + try: + get_secret_value_response = self._client.get_secret_value( + SecretId=self.secret_name + ) + token_str = get_secret_value_response["SecretString"] + self._cache = self.deserialize(token_str) + except Exception as e: + log.error( + "Token (secret: {}) could not be retrieved from the backend: {}".format( + self.secret_name, e + ) + ) + return False + + return True
+ + +
+[docs] + def save_token(self, force=False) -> bool: + """ + Saves the token dict in the store + :param bool force: Force save even when state has not changed + :return bool: Success / Failure + """ + if not self._cache: + return False + + if force is False and self._has_state_changed is False: + return True + + if self.check_token(): # secret already exists + try: + _ = self._client.update_secret( + SecretId=self.secret_name, SecretString=self.serialize() + ) + except Exception as e: + log.error("Token secret could not be saved: {}".format(e)) + return False + else: # create a new secret + try: + r = self._client.create_secret( + Name=self.secret_name, + Description="Token generated by the O365 python package (https://pypi.org/project/O365/).", + SecretString=self.serialize(), + ) + except Exception as e: + log.error("Token secret could not be created: {}".format(e)) + return False + else: + log.warning( + "\nCreated secret {} ({}). Note: using AWS Secrets Manager incurs charges, " + "please see https://aws.amazon.com/secrets-manager/pricing/ " + "for pricing details.\n".format(r["Name"], r["ARN"]) + ) + + return True
+ + +
+[docs] + def delete_token(self) -> bool: + """ + Deletes the token from the store + :return bool: Success / Failure + """ + try: + r = self._client.delete_secret( + SecretId=self.secret_name, ForceDeleteWithoutRecovery=True + ) + except Exception as e: + log.error("Token secret could not be deleted: {}".format(e)) + return False + else: + log.warning("Deleted token secret {} ({}).".format(r["Name"], r["ARN"])) + return True
+ + +
+[docs] + def check_token(self) -> bool: + """ + Checks if the token exists + :return bool: True if it exists on the store + """ + try: + _ = self._client.describe_secret(SecretId=self.secret_name) + except: + return False + else: + return True
+
+ + + +
+[docs] +class BitwardenSecretsManagerBackend(BaseTokenBackend): + """A Bitwarden Secrets Manager backend to store tokens""" + +
+[docs] + def __init__(self, access_token: str, secret_id: str): + """ + Init Backend + :param str access_token: Access Token used to access the Bitwarden Secrets Manager API + :param str secret_id: ID of Bitwarden Secret used to store the O365 token + """ + try: + from bitwarden_sdk import BitwardenClient + except ModuleNotFoundError as e: + raise Exception( + "Please install the bitwarden-sdk package to use this token backend." + ) from e + super().__init__() + #: Bitwarden client. |br| **Type:** BitWardenClient + self.client = BitwardenClient() + #: Bitwarden login access token. |br| **Type:** str + self.client.auth().login_access_token(access_token) + #: Bitwarden secret is. |br| **Type:** str + self.secret_id = secret_id + #: Bitwarden secret. |br| **Type:** str + self.secret = None
+ + + def __repr__(self): + return "BitwardenSecretsManagerBackend('{}')".format(self.secret_id) + +
+[docs] + def load_token(self) -> bool: + """ + Retrieves the token from Bitwarden Secrets Manager + :return bool: Success / Failure + """ + resp = self.client.secrets().get(self.secret_id) + if not resp.success: + return False + + self.secret = resp.data + + try: + self._cache = self.deserialize(self.secret.value) + return True + except: + logging.warning("Existing token could not be decoded") + return False
+ + +
+[docs] + def save_token(self, force=False) -> bool: + """ + Saves the token dict in Bitwarden Secrets Manager + :param bool force: Force save even when state has not changed + :return bool: Success / Failure + """ + if self.secret is None: + raise ValueError(f'You have to set "self.secret" data first.') + + if not self._cache: + return False + + if force is False and self._has_state_changed is False: + return True + + self.client.secrets().update( + self.secret.id, + self.secret.key, + self.secret.note, + self.secret.organization_id, + self.serialize(), + [self.secret.project_id], + ) + return True
+
+ + + +
+[docs] +class DjangoTokenBackend(BaseTokenBackend): + """ + A Django database token backend to store tokens. To use this backend add the `TokenModel` + model below into your Django application. + + .. code-block:: python + + class TokenModel(models.Model): + token = models.JSONField() + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + def __str__(self): + return f"Token for {self.token.get('client_id', 'unknown')}" + + Example usage: + + .. code-block:: python + + from O365.utils import DjangoTokenBackend + from models import TokenModel + + token_backend = DjangoTokenBackend(token_model=TokenModel) + account = Account(credentials, token_backend=token_backend) + """ + +
+[docs] + def __init__(self, token_model=None): + """ + Initializes the DjangoTokenBackend. + + :param token_model: The Django model class to use for storing and retrieving tokens (defaults to TokenModel). + """ + super().__init__() + # Use the provided token_model class + #: Django token model |br| **Type:** TokenModel + self.token_model = token_model
+ + + def __repr__(self): + return "DjangoTokenBackend" + +
+[docs] + def load_token(self) -> bool: + """ + Retrieves the latest token from the Django database + :return bool: Success / Failure + """ + + try: + # Retrieve the latest token based on the most recently created record + token_record = self.token_model.objects.latest("created_at") + self._cache = self.deserialize(token_record.token) + except Exception as e: + log.warning(f"No token found in the database, creating a new one: {str(e)}") + return False + + return True
+ + +
+[docs] + def save_token(self, force=False) -> bool: + """ + Saves the token dict in the Django database + :param bool force: Force save even when state has not changed + :return bool: Success / Failure + """ + if not self._cache: + return False + + if force is False and self._has_state_changed is False: + return True + + try: + # Create a new token record in the database + self.token_model.objects.create(token=self.serialize()) + except Exception as e: + log.error(f"Token could not be saved: {str(e)}") + return False + + return True
+ + +
+[docs] + def delete_token(self) -> bool: + """ + Deletes the latest token from the Django database + :return bool: Success / Failure + """ + try: + # Delete the latest token + token_record = self.token_model.objects.latest("created_at") + token_record.delete() + except Exception as e: + log.error(f"Could not delete token: {str(e)}") + return False + return True
+ + +
+[docs] + def check_token(self) -> bool: + """ + Checks if any token exists in the Django database + :return bool: True if it exists, False otherwise + """ + return self.token_model.objects.exists()
+
+ +
+ +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/docs/latest/_modules/O365/utils/utils.html b/docs/latest/_modules/O365/utils/utils.html new file mode 100644 index 00000000..f61f1529 --- /dev/null +++ b/docs/latest/_modules/O365/utils/utils.html @@ -0,0 +1,1416 @@ + + + + + + + + + + + O365.utils.utils — O365 documentation + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + +
+ +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+
+
+
+ +

Source code for O365.utils.utils

+import datetime as dt
+import logging
+from collections import OrderedDict
+from enum import Enum
+
+import pytz
+from dateutil.parser import parse
+from stringcase import snakecase
+
+from .windows_tz import get_iana_tz, get_windows_tz
+from .decorators import fluent
+
+ME_RESOURCE = 'me'
+USERS_RESOURCE = 'users'
+GROUPS_RESOURCE = 'groups'
+SITES_RESOURCE = 'sites'
+
+NEXT_LINK_KEYWORD = '@odata.nextLink'
+
+log = logging.getLogger(__name__)
+
+MAX_RECIPIENTS_PER_MESSAGE = 500  # Actual limit on Office 365
+
+
+
[docs]class CaseEnum(Enum): + """ A Enum that converts the value to a snake_case casing """ + + def __new__(cls, value): + obj = object.__new__(cls) + obj._value_ = snakecase(value) # value will be transformed to snake_case + return obj + +
[docs] @classmethod + def from_value(cls, value): + """ Gets a member by a snaked-case provided value""" + try: + return cls(snakecase(value)) + except ValueError: + return None
+ + +
[docs]class ImportanceLevel(CaseEnum): + Normal = 'normal' + Low = 'low' + High = 'high'
+ + +
[docs]class OutlookWellKnowFolderNames(Enum): + INBOX = 'Inbox' + JUNK = 'JunkEmail' + DELETED = 'DeletedItems' + DRAFTS = 'Drafts' + SENT = 'SentItems' + OUTBOX = 'Outbox' + ARCHIVE = 'Archive'
+ + +
[docs]class OneDriveWellKnowFolderNames(Enum): + DOCUMENTS = 'documents' + PHOTOS = 'photos' + CAMERA_ROLL = 'cameraroll' + APP_ROOT = 'approot' + MUSIC = 'music' + ATTACHMENTS = 'attachments'
+ + +
[docs]class ChainOperator(Enum): + AND = 'and' + OR = 'or'
+ + +
[docs]class TrackerSet(set): +
[docs] def __init__(self, *args, casing=None, **kwargs): + """ A Custom Set that changes the casing of it's keys + + :param func casing: a function to convert into specified case + """ + self.cc = casing + super().__init__(*args, **kwargs)
+ +
[docs] def add(self, value): + value = self.cc(value) + super().add(value)
+ +
[docs] def remove(self, value): + value = self.cc(value) + super().remove(value)
+ + +
[docs]class Recipient: + """ A single Recipient """ + +
[docs] def __init__(self, address=None, name=None, parent=None, field=None): + """ Create a recipient with provided information + + :param str address: email address of the recipient + :param str name: name of the recipient + :param HandleRecipientsMixin parent: parent recipients handler + :param str field: name of the field to update back + """ + self._address = address or '' + self._name = name or '' + self._parent = parent + self._field = field
+ + def __bool__(self): + return bool(self.address) + + def __str__(self): + return self.__repr__() + + def __repr__(self): + if self.name: + return '{} ({})'.format(self.name, self.address) + else: + return self.address + + # noinspection PyProtectedMember + def _track_changes(self): + """ Update the track_changes on the parent to reflect a + needed update on this field """ + if self._field and getattr(self._parent, '_track_changes', + None) is not None: + self._parent._track_changes.add(self._field) + + @property + def address(self): + """ Email address of the recipient + + :getter: Get the email address + :setter: Set and update the email address + :type: str + """ + return self._address + + @address.setter + def address(self, value): + self._address = value + self._track_changes() + + @property + def name(self): + """ Name of the recipient + + :getter: Get the name + :setter: Set and update the name + :type: str + """ + return self._name + + @name.setter + def name(self, value): + self._name = value + self._track_changes()
+ + +
[docs]class Recipients: + """ A Sequence of Recipients """ + +
[docs] def __init__(self, recipients=None, parent=None, field=None): + """ Recipients must be a list of either address strings or + tuples (name, address) or dictionary elements + + :param recipients: list of either address strings or + tuples (name, address) or dictionary elements + :type recipients: list[str] or list[tuple] or list[dict] + or list[Recipient] + :param HandleRecipientsMixin parent: parent recipients handler + :param str field: name of the field to update back + """ + self._parent = parent + self._field = field + self._recipients = [] + self.untrack = True + if recipients: + self.add(recipients) + self.untrack = False
+ + def __iter__(self): + return iter(self._recipients) + + def __getitem__(self, key): + return self._recipients[key] + + def __contains__(self, item): + return item in {recipient.address for recipient in self._recipients} + + def __bool__(self): + return bool(len(self._recipients)) + + def __len__(self): + return len(self._recipients) + + def __str__(self): + return self.__repr__() + + def __repr__(self): + return 'Recipients count: {}'.format(len(self._recipients)) + + # noinspection PyProtectedMember + def _track_changes(self): + """ Update the track_changes on the parent to reflect a + needed update on this field """ + if self._field and getattr(self._parent, '_track_changes', + None) is not None and self.untrack is False: + self._parent._track_changes.add(self._field) + +
[docs] def clear(self): + """ Clear the list of recipients """ + self._recipients = [] + self._track_changes()
+ +
[docs] def add(self, recipients): + """ Add the supplied recipients to the exiting list + + :param recipients: list of either address strings or + tuples (name, address) or dictionary elements + :type recipients: list[str] or list[tuple] or list[dict] + """ + + if recipients: + if isinstance(recipients, str): + self._recipients.append( + Recipient(address=recipients, parent=self._parent, + field=self._field)) + elif isinstance(recipients, Recipient): + self._recipients.append(recipients) + elif isinstance(recipients, tuple): + name, address = recipients + if address: + self._recipients.append( + Recipient(address=address, name=name, + parent=self._parent, field=self._field)) + elif isinstance(recipients, list): + for recipient in recipients: + self.add(recipient) + else: + raise ValueError('Recipients must be an address string, a ' + 'Recipient instance, a (name, address) ' + 'tuple or a list') + self._track_changes()
+ +
[docs] def remove(self, address): + """ Remove an address or multiple addresses + + :param address: list of addresses to remove + :type address: str or list[str] + """ + recipients = [] + if isinstance(address, str): + address = {address} # set + elif isinstance(address, (list, tuple)): + address = set(address) + + for recipient in self._recipients: + if recipient.address not in address: + recipients.append(recipient) + if len(recipients) != len(self._recipients): + self._track_changes() + self._recipients = recipients
+ +
[docs] def get_first_recipient_with_address(self): + """ Returns the first recipient found with a non blank address + + :return: First Recipient + :rtype: Recipient + """ + recipients_with_address = [recipient for recipient in self._recipients + if recipient.address] + if recipients_with_address: + return recipients_with_address[0] + else: + return None
+ + +
[docs]class HandleRecipientsMixin: + + def _recipients_from_cloud(self, recipients, field=None): + """ Transform a recipient from cloud data to object data """ + recipients_data = [] + for recipient in recipients: + recipients_data.append( + self._recipient_from_cloud(recipient, field=field)) + return Recipients(recipients_data, parent=self, field=field) + + def _recipient_from_cloud(self, recipient, field=None): + """ Transform a recipient from cloud data to object data """ + + if recipient: + recipient = recipient.get(self._cc('emailAddress'), + recipient if isinstance(recipient, + dict) else {}) + address = recipient.get(self._cc('address'), '') + name = recipient.get(self._cc('name'), '') + return Recipient(address=address, name=name, parent=self, + field=field) + else: + return Recipient() + + def _recipient_to_cloud(self, recipient): + """ Transforms a Recipient object to a cloud dict """ + data = None + if recipient: + data = {self._cc('emailAddress'): { + self._cc('address'): recipient.address}} + if recipient.name: + data[self._cc('emailAddress')][ + self._cc('name')] = recipient.name + return data
+ + +
[docs]class ApiComponent: + """ Base class for all object interactions with the Cloud Service API + + Exposes common access methods to the api protocol within all Api objects + """ + + _cloud_data_key = '__cloud_data__' # wraps cloud data with this dict key + _endpoints = {} # dict of all API service endpoints needed + +
[docs] def __init__(self, *, protocol=None, main_resource=None, **kwargs): + """ Object initialization + + :param Protocol protocol: A protocol class or instance to be used with + this connection + :param str main_resource: main_resource to be used in these API + communications + """ + self.protocol = protocol() if isinstance(protocol, type) else protocol + if self.protocol is None: + raise ValueError('Protocol not provided to Api Component') + mr, bu = self.build_base_url(main_resource) + self.main_resource = mr + self._base_url = bu + + super().__init__()
+ + def __str__(self): + return self.__repr__() + + def __repr__(self): + return 'Api Component on resource: {}'.format(self.main_resource) + + @staticmethod + def _parse_resource(resource): + """ Parses and completes resource information """ + resource = resource.strip() if resource else resource + if resource in {ME_RESOURCE, USERS_RESOURCE, GROUPS_RESOURCE, SITES_RESOURCE}: + return resource + elif resource.startswith('user:'): + # user resource shorthand + resource = resource.replace('user:', '', 1) + return '{}/{}'.format(USERS_RESOURCE, resource) + elif '@' in resource and not resource.startswith(USERS_RESOURCE): + # user resource backup + # when for example accessing a shared mailbox the + # resource is set to the email address. we have to prefix + # the email with the resource 'users/' so --> 'users/email_address' + return '{}/{}'.format(USERS_RESOURCE, resource) + elif resource.startswith('group:'): + # group resource shorthand + resource = resource.replace('group:', '', 1) + return '{}/{}'.format(GROUPS_RESOURCE, resource) + elif resource.startswith('site:'): + # sharepoint site resource shorthand + resource = resource.replace('site:', '', 1) + return '{}/{}'.format(SITES_RESOURCE, resource) + else: + return resource + +
[docs] def build_base_url(self, resource): + """ + Builds the base url of this ApiComponent + :param str resource: the resource to build the base url + """ + main_resource = self._parse_resource(resource if resource is not None else self.protocol.default_resource) + # noinspection PyUnresolvedReferences + base_url = '{}{}'.format(self.protocol.service_url, main_resource) + if base_url.endswith('/'): + # when self.main_resource is empty then remove the last slash. + base_url = base_url[:-1] + return main_resource, base_url
+ +
[docs] def set_base_url(self, resource): + """ + Sets the base urls for this ApiComponent + :param str resource: the resource to build the base url + """ + self.main_resource, self._base_url = self.build_base_url(resource)
+ +
[docs] def build_url(self, endpoint): + """ Returns a url for a given endpoint using the protocol + service url + + :param str endpoint: endpoint to build the url for + :return: final url + :rtype: str + """ + return '{}{}'.format(self._base_url, endpoint)
+ + def _gk(self, keyword): + """ Alias for protocol.get_service_keyword """ + return self.protocol.get_service_keyword(keyword) + + def _cc(self, dict_key): + """ Alias for protocol.convert_case """ + return self.protocol.convert_case(dict_key) + + def _parse_date_time_time_zone(self, date_time_time_zone): + """ Parses and convert to protocol timezone a dateTimeTimeZone resource + This resource is a dict with a date time and a windows timezone + This is a common structure on Microsoft apis so it's included here. + """ + if date_time_time_zone is None: + return None + + local_tz = self.protocol.timezone + if isinstance(date_time_time_zone, dict): + try: + timezone = pytz.timezone( + get_iana_tz(date_time_time_zone.get(self._cc('timeZone'), 'UTC'))) + except pytz.UnknownTimeZoneError: + timezone = local_tz + date_time = date_time_time_zone.get(self._cc('dateTime'), None) + try: + date_time = timezone.localize(parse(date_time)) if date_time else None + except OverflowError as e: + log.debug('Could not parse dateTimeTimeZone: {}. Error: {}'.format(date_time_time_zone, str(e))) + date_time = None + + if date_time and timezone != local_tz: + date_time = date_time.astimezone(local_tz) + else: + # Outlook v1.0 api compatibility (fallback to datetime string) + try: + date_time = local_tz.localize(parse(date_time_time_zone)) if date_time_time_zone else None + except Exception as e: + log.debug('Could not parse dateTimeTimeZone: {}. Error: {}'.format(date_time_time_zone, str(e))) + date_time = None + + return date_time + + def _build_date_time_time_zone(self, date_time): + """ Converts a datetime to a dateTimeTimeZone resource """ + timezone = date_time.tzinfo.zone if date_time.tzinfo is not None else None + return { + self._cc('dateTime'): date_time.strftime('%Y-%m-%dT%H:%M:%S'), + self._cc('timeZone'): get_windows_tz(timezone or self.protocol.timezone) + } + +
[docs] def new_query(self, attribute=None): + """ Create a new query to filter results + + :param str attribute: attribute to apply the query for + :return: new Query + :rtype: Query + """ + return Query(attribute=attribute, protocol=self.protocol)
+ + q = new_query # alias for new query
+ + + + + +
[docs]class Query: + """ Helper to conform OData filters """ + _mapping = { + 'from': 'from/emailAddress/address', + 'to': 'toRecipients/emailAddress/address', + 'start': 'start/DateTime', + 'end': 'end/DateTime', + 'flag': 'flag/flagStatus' + } + +
[docs] def __init__(self, attribute=None, *, protocol): + """ Build a query to apply OData filters + https://docs.microsoft.com/en-us/graph/query-parameters + + :param str attribute: attribute to apply the query for + :param Protocol protocol: protocol to use for connecting + """ + self.protocol = protocol() if isinstance(protocol, type) else protocol + self._attribute = None + self._chain = None + self.new(attribute) + self._negation = False + self._filters = [] # store all the filters + self._order_by = OrderedDict() + self._selects = set() + self._expands = set() + self._search = None + self._open_group_flag = [] # stores if the next attribute must be grouped + self._close_group_flag = [] # stores if the last attribute must be closing a group
+ + def __str__(self): + return 'Filter: {}\nOrder: {}\nSelect: {}\nExpand: {}\nSearch: {}'.format(self.get_filters(), + self.get_order(), + self.get_selects(), + self.get_expands(), + self._search) + + def __repr__(self): + return self.__str__() + +
[docs] @fluent + def select(self, *attributes): + """ Adds the attribute to the $select parameter + + :param str attributes: the attributes tuple to select. + If empty, the on_attribute previously set is added. + :rtype: Query + """ + if attributes: + for attribute in attributes: + attribute = self.protocol.convert_case( + attribute) if attribute and isinstance(attribute, + str) else None + if attribute: + if '/' in attribute: + # only parent attribute can be selected + attribute = attribute.split('/')[0] + self._selects.add(attribute) + else: + if self._attribute: + self._selects.add(self._attribute) + + return self
+ +
[docs] @fluent + def expand(self, *relationships): + """ Adds the relationships (e.g. "event" or "attachments") + that should be expanded with the $expand parameter + Important: The ApiComponent using this should know how to handle this relationships. + eg: Message knows how to handle attachments, and event (if it's an EventMessage). + Important: When using expand on multi-value relationships a max of 20 items will be returned. + :param str relationships: the relationships tuple to expand. + :rtype: Query + """ + + for relationship in relationships: + if relationship == 'event': + relationship = '{}/event'.format(self.protocol.get_service_keyword('event_message_type')) + self._expands.add(relationship) + + return self
+ +
[docs] @fluent + def search(self, text): + """ + Perform a search. + Not from graph docs: + You can currently search only message and person collections. + A $search request returns up to 250 results. + You cannot use $filter or $orderby in a search request. + :param str text: the text to search + :return: the Query instance + """ + if text is None: + self._search = None + else: + # filters an order are not allowed + self.clear_filters() + self.clear_order() + self._search = '"{}"'.format(text) + + return self
+ +
[docs] def as_params(self): + """ Returns the filters, orders, select, expands and search as query parameters + + :rtype: dict + """ + params = {} + if self.has_filters: + params['$filter'] = self.get_filters() + if self.has_order: + params['$orderby'] = self.get_order() + if self.has_expands and not self.has_selects: + params['$expand'] = self.get_expands() + if self.has_selects and not self.has_expands: + params['$select'] = self.get_selects() + if self.has_expands and self.has_selects: + params['$expand'] = '{}($select={})'.format(self.get_expands(), self.get_selects()) + if self._search: + params['$search'] = self._search + params.pop('$filter', None) + params.pop('$orderby', None) + return params
+ + @property + def has_filters(self): + """ Whether the query has filters or not + + :rtype: bool + """ + return bool(self._filters) + + @property + def has_order(self): + """ Whether the query has order_by or not + + :rtype: bool + """ + return bool(self._order_by) + + @property + def has_selects(self): + """ Whether the query has select filters or not + + :rtype: bool + """ + return bool(self._selects) + + @property + def has_expands(self): + """ Whether the query has relationships that should be expanded or not + + :rtype: bool + """ + return bool(self._expands) + +
[docs] def get_filters(self): + """ Returns the result filters + + :rtype: str or None + """ + if self._filters: + filters_list = self._filters + if isinstance(filters_list[-1], Enum): + filters_list = filters_list[:-1] + filters = ' '.join( + [fs.value if isinstance(fs, Enum) else fs[1] for fs in filters_list] + ).strip() + + # closing opened groups automatically + open_groups = len([x for x in self._open_group_flag if x is False]) + for i in range(open_groups - len(self._close_group_flag)): + filters += ')' + + return filters + else: + return None
+ +
[docs] def get_order(self): + """ Returns the result order by clauses + + :rtype: str or None + """ + # first get the filtered attributes in order as they must appear + # in the order_by first + if not self.has_order: + return None + + return ','.join(['{} {}'.format(attribute, direction or '').strip() + for attribute, direction in self._order_by.items()])
+ +
[docs] def get_selects(self): + """ Returns the result select clause + + :rtype: str or None + """ + if self._selects: + return ','.join(self._selects) + else: + return None
+ +
[docs] def get_expands(self): + """ Returns the result expand clause + + :rtype: str or None + """ + if self._expands: + return ','.join(self._expands) + else: + return None
+ + def _get_mapping(self, attribute): + if attribute: + mapping = self._mapping.get(attribute) + if mapping: + attribute = '/'.join( + [self.protocol.convert_case(step) for step in + mapping.split('/')]) + else: + attribute = self.protocol.convert_case(attribute) + return attribute + return None + +
[docs] @fluent + def new(self, attribute, operation=ChainOperator.AND): + """ Combine with a new query + + :param str attribute: attribute of new query + :param ChainOperator operation: operation to combine to new query + :rtype: Query + """ + if isinstance(operation, str): + operation = ChainOperator(operation) + self._chain = operation + self._attribute = self._get_mapping(attribute) if attribute else None + self._negation = False + return self
+ +
[docs] def clear_filters(self): + """ Clear filters """ + self._filters = []
+ +
[docs] def clear_order(self): + """ Clears any order commands """ + self._order_by = OrderedDict()
+ +
[docs] @fluent + def clear(self): + """ Clear everything + + :rtype: Query + """ + self._filters = [] + self._order_by = OrderedDict() + self._selects = set() + self._negation = False + self._attribute = None + self._chain = None + self._search = None + self._open_group_flag = [] + self._close_group_flag = [] + + return self
+ +
[docs] @fluent + def negate(self): + """ Apply a not operator + + :rtype: Query + """ + self._negation = not self._negation + return self
+ +
[docs] @fluent + def chain(self, operation=ChainOperator.AND): + """ Start a chain operation + + :param ChainOperator, str operation: how to combine with a new one + :rtype: Query + """ + if isinstance(operation, str): + operation = ChainOperator(operation) + self._chain = operation + return self
+ +
[docs] @fluent + def on_attribute(self, attribute): + """ Apply query on attribute, to be used along with chain() + + :param str attribute: attribute name + :rtype: Query + """ + self._attribute = self._get_mapping(attribute) + return self
+ +
[docs] @fluent + def on_list_field(self, field): + """ Apply query on a list field, to be used along with chain() + + :param str field: field name (note: name is case sensitive) + :rtype: Query + """ + self._attribute = 'fields/' + field + return self
+ +
[docs] def remove_filter(self, filter_attr): + """ Removes a filter given the attribute name """ + filter_attr = self._get_mapping(filter_attr) + new_filters = [] + remove_chain = False + + for flt in self._filters: + if isinstance(flt, list): + if flt[0] == filter_attr: + remove_chain = True + else: + new_filters.append(flt) + else: + # this is a ChainOperator + if remove_chain is False: + new_filters.append(flt) + else: + remove_chain = False + + self._filters = new_filters
+ + def _add_filter(self, *filter_data): + if self._attribute: + if self._filters and not isinstance(self._filters[-1], + ChainOperator): + self._filters.append(self._chain) + sentence, attrs = filter_data + for i, group in enumerate(self._open_group_flag): + if group is True: + # Open a group + sentence = '(' + sentence + self._open_group_flag[i] = False # set to done + self._filters.append([self._attribute, sentence, attrs]) + else: + raise ValueError( + 'Attribute property needed. call on_attribute(attribute) ' + 'or new(attribute)') + + def _parse_filter_word(self, word): + """ Converts the word parameter into the correct format """ + if isinstance(word, str): + word = "'{}'".format(word) + elif isinstance(word, dt.date): + if isinstance(word, dt.datetime): + if word.tzinfo is None: + # if it's a naive datetime, localize the datetime. + word = self.protocol.timezone.localize( + word) # localize datetime into local tz + if word.tzinfo != pytz.utc: + word = word.astimezone( + pytz.utc) # transform local datetime to utc + if '/' in self._attribute: + # TODO: this is a fix for the case when the parameter + # filtered is a string instead a dateTimeOffset + # but checking the '/' is not correct, but it will + # differentiate for now the case on events: + # start/dateTime (date is a string here) from + # the case on other dates such as + # receivedDateTime (date is a dateTimeOffset) + word = "'{}'".format( + word.isoformat()) # convert datetime to isoformat. + else: + word = "{}".format( + word.isoformat()) # convert datetime to isoformat + elif isinstance(word, bool): + word = str(word).lower() + elif word is None: + word = 'null' + return word + + @staticmethod + def _prepare_sentence(attribute, operation, word, negation=False): + negation = 'not' if negation else '' + attrs = (negation, attribute, operation, word) + sentence = '{} {} {} {}'.format(negation, attribute, operation, word).strip() + return sentence, attrs + +
[docs] @fluent + def logical_operator(self, operation, word): + """ Apply a logical operator + + :param str operation: how to combine with a new one + :param word: other parameter for the operation + (a = b) would be like a.logical_operator('eq', 'b') + :rtype: Query + """ + word = self._parse_filter_word(word) + self._add_filter( + *self._prepare_sentence(self._attribute, operation, word, + self._negation)) + return self
+ +
[docs] @fluent + def equals(self, word): + """ Add a equals check + + :param word: word to compare with + :rtype: Query + """ + return self.logical_operator('eq', word)
+ +
[docs] @fluent + def unequal(self, word): + """ Add a unequals check + + :param word: word to compare with + :rtype: Query + """ + return self.logical_operator('ne', word)
+ +
[docs] @fluent + def greater(self, word): + """ Add a greater than check + + :param word: word to compare with + :rtype: Query + """ + return self.logical_operator('gt', word)
+ +
[docs] @fluent + def greater_equal(self, word): + """ Add a greater than or equal to check + + :param word: word to compare with + :rtype: Query + """ + return self.logical_operator('ge', word)
+ +
[docs] @fluent + def less(self, word): + """ Add a less than check + + :param word: word to compare with + :rtype: Query + """ + return self.logical_operator('lt', word)
+ +
[docs] @fluent + def less_equal(self, word): + """ Add a less than or equal to check + + :param word: word to compare with + :rtype: Query + """ + return self.logical_operator('le', word)
+ + @staticmethod + def _prepare_function(function_name, attribute, word, negation=False): + negation = 'not' if negation else '' + attrs = (negation, attribute, function_name, word) + return "{} {}({}, {})".format(negation, function_name, attribute, word).strip(), attrs + +
[docs] @fluent + def function(self, function_name, word): + """ Apply a function on given word + + :param str function_name: function to apply + :param str word: word to apply function on + :rtype: Query + """ + word = self._parse_filter_word(word) + + self._add_filter( + *self._prepare_function(function_name, self._attribute, word, + self._negation)) + return self
+ +
[docs] @fluent + def contains(self, word): + """ Adds a contains word check + + :param str word: word to check + :rtype: Query + """ + return self.function('contains', word)
+ +
[docs] @fluent + def startswith(self, word): + """ Adds a startswith word check + + :param str word: word to check + :rtype: Query + """ + return self.function('startswith', word)
+ +
[docs] @fluent + def endswith(self, word): + """ Adds a endswith word check + + :param str word: word to check + :rtype: Query + """ + return self.function('endswith', word)
+ +
[docs] @fluent + def iterable(self, iterable_name, *, collection, word, attribute=None, func=None, + operation=None): + """ Performs a filter with the OData 'iterable_name' keyword + on the collection + + For example: + q.iterable('any', collection='email_addresses', attribute='address', + operation='eq', word='george@best.com') + + will transform to a filter such as: + emailAddresses/any(a:a/address eq 'george@best.com') + + :param str iterable_name: the OData name of the iterable + :param str collection: the collection to apply the any keyword on + :param str word: the word to check + :param str attribute: the attribute of the collection to check + :param str func: the logical function to apply to the attribute inside + the collection + :param str operation: the logical operation to apply to the attribute + inside the collection + :rtype: Query + """ + + if func is None and operation is None: + raise ValueError('Provide a function or an operation to apply') + elif func is not None and operation is not None: + raise ValueError( + 'Provide either a function or an operation but not both') + + current_att = self._attribute + self._attribute = iterable_name + + word = self._parse_filter_word(word) + collection = self._get_mapping(collection) + attribute = self._get_mapping(attribute) + + if attribute is None: + attribute = 'a' # it's the same iterated object + else: + attribute = 'a/{}'.format(attribute) + + if func is not None: + sentence = self._prepare_function(func, attribute, word) + else: + sentence = self._prepare_sentence(attribute, operation, word) + + filter_str, attrs = sentence + + filter_data = '{}/{}(a:{})'.format(collection, iterable_name, filter_str), attrs + self._add_filter(*filter_data) + + self._attribute = current_att + + return self
+ +
[docs] @fluent + def any(self, *, collection, word, attribute=None, func=None, operation=None): + """ Performs a filter with the OData 'any' keyword on the collection + + For example: + q.any(collection='email_addresses', attribute='address', + operation='eq', word='george@best.com') + + will transform to a filter such as: + + emailAddresses/any(a:a/address eq 'george@best.com') + + :param str collection: the collection to apply the any keyword on + :param str word: the word to check + :param str attribute: the attribute of the collection to check + :param str func: the logical function to apply to the attribute + inside the collection + :param str operation: the logical operation to apply to the + attribute inside the collection + :rtype: Query + """ + + return self.iterable('any', collection=collection, word=word, + attribute=attribute, func=func, operation=operation)
+ +
[docs] @fluent + def all(self, *, collection, word, attribute=None, func=None, operation=None): + """ Performs a filter with the OData 'all' keyword on the collection + + For example: + q.any(collection='email_addresses', attribute='address', + operation='eq', word='george@best.com') + + will transform to a filter such as: + + emailAddresses/all(a:a/address eq 'george@best.com') + + :param str collection: the collection to apply the any keyword on + :param str word: the word to check + :param str attribute: the attribute of the collection to check + :param str func: the logical function to apply to the attribute + inside the collection + :param str operation: the logical operation to apply to the + attribute inside the collection + :rtype: Query + """ + + return self.iterable('all', collection=collection, word=word, + attribute=attribute, func=func, operation=operation)
+ +
[docs] @fluent + def order_by(self, attribute=None, *, ascending=True): + """ Applies a order_by clause + + :param str attribute: attribute to apply on + :param bool ascending: should it apply ascending order or descending + :rtype: Query + """ + attribute = self._get_mapping(attribute) or self._attribute + if attribute: + self._order_by[attribute] = None if ascending else 'desc' + else: + raise ValueError( + 'Attribute property needed. call on_attribute(attribute) ' + 'or new(attribute)') + return self
+ +
[docs] def open_group(self): + """ Applies a precedence grouping in the next filters """ + self._open_group_flag.append(True) + return self
+ +
[docs] def close_group(self): + """ Closes a grouping for previous filters """ + if self._filters: + if len(self._open_group_flag) < (len(self._close_group_flag) + 1): + raise RuntimeError('Not enough open groups to close.') + if isinstance(self._filters[-1], ChainOperator): + flt_sentence = self._filters[-2] + else: + flt_sentence = self._filters[-1] + + flt_sentence[1] = flt_sentence[1] + ')' # closing the group + self._close_group_flag.append(False) # flag a close group was added + else: + raise RuntimeError("No filters present. Can't close a group") + return self
+
+ +
+ +
+ + +
+
+ +
+ +
+ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/latest/_modules/index.html b/docs/latest/_modules/index.html new file mode 100644 index 00000000..e482b714 --- /dev/null +++ b/docs/latest/_modules/index.html @@ -0,0 +1,125 @@ + + + + + + + + Overview: module code — O365 documentation + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+
    +
  • + +
  • +
  • +
+
+
+ + +
+
+
+
+ + + + \ No newline at end of file diff --git a/docs/latest/_sources/api.rst.txt b/docs/latest/_sources/api.rst.txt new file mode 100644 index 00000000..d558897d --- /dev/null +++ b/docs/latest/_sources/api.rst.txt @@ -0,0 +1,25 @@ +======== +O365 API +======== + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + + api/account + api/address_book + api/calendar + api/category + api/connection + api/directory + api/excel + api/group + api/mailbox + api/message + api/onedrive + api/planner + api/sharepoint + api/subscriptions + api/tasks + api/teams + api/utils diff --git a/docs/latest/_sources/api/account.rst.txt b/docs/latest/_sources/api/account.rst.txt new file mode 100644 index 00000000..63e1aec4 --- /dev/null +++ b/docs/latest/_sources/api/account.rst.txt @@ -0,0 +1,10 @@ +Account +----------- + +.. include:: global.rst + +.. automodule:: O365.account + :members: + :undoc-members: + :show-inheritance: + :member-order: groupwise \ No newline at end of file diff --git a/docs/latest/_sources/api/address_book.rst.txt b/docs/latest/_sources/api/address_book.rst.txt new file mode 100644 index 00000000..86936b0e --- /dev/null +++ b/docs/latest/_sources/api/address_book.rst.txt @@ -0,0 +1,10 @@ +Address Book +------------ + +.. include:: global.rst + +.. automodule:: O365.address_book + :members: + :undoc-members: + :show-inheritance: + :member-order: groupwise \ No newline at end of file diff --git a/docs/latest/_sources/api/attachment.rst.txt b/docs/latest/_sources/api/attachment.rst.txt new file mode 100644 index 00000000..68d06a61 --- /dev/null +++ b/docs/latest/_sources/api/attachment.rst.txt @@ -0,0 +1,7 @@ +Attachment +---------- + +.. automodule:: O365.utils.attachment + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/latest/_sources/api/calendar.rst.txt b/docs/latest/_sources/api/calendar.rst.txt new file mode 100644 index 00000000..f090c51f --- /dev/null +++ b/docs/latest/_sources/api/calendar.rst.txt @@ -0,0 +1,10 @@ +Calendar +-------- + +.. include:: global.rst + +.. automodule:: O365.calendar + :members: + :undoc-members: + :show-inheritance: + :member-order: groupwise \ No newline at end of file diff --git a/docs/latest/_sources/api/category.rst.txt b/docs/latest/_sources/api/category.rst.txt new file mode 100644 index 00000000..7a6b7b66 --- /dev/null +++ b/docs/latest/_sources/api/category.rst.txt @@ -0,0 +1,10 @@ +Category +-------- + +.. include:: global.rst + +.. automodule:: O365.category + :members: + :undoc-members: + :show-inheritance: + :member-order: groupwise \ No newline at end of file diff --git a/docs/latest/_sources/api/connection.rst.txt b/docs/latest/_sources/api/connection.rst.txt new file mode 100644 index 00000000..0c06534e --- /dev/null +++ b/docs/latest/_sources/api/connection.rst.txt @@ -0,0 +1,10 @@ +Connection +---------- + +.. include:: global.rst + +.. automodule:: O365.connection + :members: + :undoc-members: + :show-inheritance: + :member-order: groupwise \ No newline at end of file diff --git a/docs/latest/_sources/api/directory.rst.txt b/docs/latest/_sources/api/directory.rst.txt new file mode 100644 index 00000000..16ba58f2 --- /dev/null +++ b/docs/latest/_sources/api/directory.rst.txt @@ -0,0 +1,10 @@ +Directory +--------- + +.. include:: global.rst + +.. automodule:: O365.directory + :members: + :undoc-members: + :show-inheritance: + :member-order: groupwise \ No newline at end of file diff --git a/docs/latest/_sources/api/drive.rst.txt b/docs/latest/_sources/api/drive.rst.txt new file mode 100644 index 00000000..8e219b4c --- /dev/null +++ b/docs/latest/_sources/api/drive.rst.txt @@ -0,0 +1,7 @@ +One Drive +--------- + +.. automodule:: O365.drive + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/latest/_sources/api/excel.rst.txt b/docs/latest/_sources/api/excel.rst.txt new file mode 100644 index 00000000..8d9b9187 --- /dev/null +++ b/docs/latest/_sources/api/excel.rst.txt @@ -0,0 +1,9 @@ +Excel +----- + +.. include:: global.rst + +.. automodule:: O365.excel + :members: + :undoc-members: + :member-order: groupwise \ No newline at end of file diff --git a/docs/latest/_sources/api/global.rst.txt b/docs/latest/_sources/api/global.rst.txt new file mode 100644 index 00000000..9c7cad23 --- /dev/null +++ b/docs/latest/_sources/api/global.rst.txt @@ -0,0 +1,3 @@ +.. |br| raw:: html + +
   \ No newline at end of file diff --git a/docs/latest/_sources/api/group.rst.txt b/docs/latest/_sources/api/group.rst.txt new file mode 100644 index 00000000..5b603fbc --- /dev/null +++ b/docs/latest/_sources/api/group.rst.txt @@ -0,0 +1,10 @@ +Group +----- + +.. include:: global.rst + +.. automodule:: O365.groups + :members: + :undoc-members: + :show-inheritance: + :member-order: groupwise diff --git a/docs/latest/_sources/api/mailbox.rst.txt b/docs/latest/_sources/api/mailbox.rst.txt new file mode 100644 index 00000000..ae810da8 --- /dev/null +++ b/docs/latest/_sources/api/mailbox.rst.txt @@ -0,0 +1,10 @@ +Mailbox +------- + +.. include:: global.rst + +.. automodule:: O365.mailbox + :members: + :undoc-members: + :show-inheritance: + :member-order: groupwise \ No newline at end of file diff --git a/docs/latest/_sources/api/message.rst.txt b/docs/latest/_sources/api/message.rst.txt new file mode 100644 index 00000000..4d6279c1 --- /dev/null +++ b/docs/latest/_sources/api/message.rst.txt @@ -0,0 +1,10 @@ +Message +------- + +.. include:: global.rst + +.. automodule:: O365.message + :members: + :undoc-members: + :show-inheritance: + :member-order: groupwise \ No newline at end of file diff --git a/docs/latest/_sources/api/onedrive.rst.txt b/docs/latest/_sources/api/onedrive.rst.txt new file mode 100644 index 00000000..3451c866 --- /dev/null +++ b/docs/latest/_sources/api/onedrive.rst.txt @@ -0,0 +1,10 @@ +One Drive +--------- + +.. include:: global.rst + +.. automodule:: O365.drive + :members: + :undoc-members: + :show-inheritance: + :member-order: groupwise \ No newline at end of file diff --git a/docs/latest/_sources/api/planner.rst.txt b/docs/latest/_sources/api/planner.rst.txt new file mode 100644 index 00000000..efe2cad8 --- /dev/null +++ b/docs/latest/_sources/api/planner.rst.txt @@ -0,0 +1,10 @@ +Planner +------- + +.. include:: global.rst + +.. automodule:: O365.planner + :members: + :undoc-members: + :show-inheritance: + :member-order: groupwise \ No newline at end of file diff --git a/docs/latest/_sources/api/sharepoint.rst.txt b/docs/latest/_sources/api/sharepoint.rst.txt new file mode 100644 index 00000000..52f6273a --- /dev/null +++ b/docs/latest/_sources/api/sharepoint.rst.txt @@ -0,0 +1,10 @@ +Sharepoint +---------- + +.. include:: global.rst + +.. automodule:: O365.sharepoint + :members: + :undoc-members: + :show-inheritance: + :member-order: groupwise \ No newline at end of file diff --git a/docs/latest/_sources/api/subscriptions.rst.txt b/docs/latest/_sources/api/subscriptions.rst.txt new file mode 100644 index 00000000..c5e6eb73 --- /dev/null +++ b/docs/latest/_sources/api/subscriptions.rst.txt @@ -0,0 +1,10 @@ +Subscriptions +------------- + +.. include:: global.rst + +.. automodule:: O365.subscriptions + :members: + :undoc-members: + :show-inheritance: + :member-order: groupwise \ No newline at end of file diff --git a/docs/latest/_sources/api/tasks.rst.txt b/docs/latest/_sources/api/tasks.rst.txt new file mode 100644 index 00000000..39ccde00 --- /dev/null +++ b/docs/latest/_sources/api/tasks.rst.txt @@ -0,0 +1,10 @@ +Tasks +----- + +.. include:: global.rst + +.. automodule:: O365.tasks + :members: + :undoc-members: + :show-inheritance: + :member-order: groupwise \ No newline at end of file diff --git a/docs/latest/_sources/api/teams.rst.txt b/docs/latest/_sources/api/teams.rst.txt new file mode 100644 index 00000000..03c9ca71 --- /dev/null +++ b/docs/latest/_sources/api/teams.rst.txt @@ -0,0 +1,10 @@ +Teams +----- + +.. include:: global.rst + +.. automodule:: O365.teams + :members: + :undoc-members: + :show-inheritance: + :member-order: groupwise \ No newline at end of file diff --git a/docs/latest/_sources/api/utils.rst.txt b/docs/latest/_sources/api/utils.rst.txt new file mode 100644 index 00000000..dd3e5aa1 --- /dev/null +++ b/docs/latest/_sources/api/utils.rst.txt @@ -0,0 +1,12 @@ +===== +Utils +===== + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + + utils/attachment + utils/query + utils/token + utils/utils diff --git a/docs/latest/_sources/api/utils/attachment.rst.txt b/docs/latest/_sources/api/utils/attachment.rst.txt new file mode 100644 index 00000000..bf0ad331 --- /dev/null +++ b/docs/latest/_sources/api/utils/attachment.rst.txt @@ -0,0 +1,10 @@ +Attachment +---------- + +.. include:: ../global.rst + +.. automodule:: O365.utils.attachment + :members: + :undoc-members: + :show-inheritance: + :member-order: groupwise \ No newline at end of file diff --git a/docs/latest/_sources/api/utils/query.rst.txt b/docs/latest/_sources/api/utils/query.rst.txt new file mode 100644 index 00000000..e882a5b1 --- /dev/null +++ b/docs/latest/_sources/api/utils/query.rst.txt @@ -0,0 +1,10 @@ +Query +----- + +.. include:: ../global.rst + +.. automodule:: O365.utils.query + :members: + :undoc-members: + :show-inheritance: + :member-order: groupwise \ No newline at end of file diff --git a/docs/latest/_sources/api/utils/token.rst.txt b/docs/latest/_sources/api/utils/token.rst.txt new file mode 100644 index 00000000..875b78b3 --- /dev/null +++ b/docs/latest/_sources/api/utils/token.rst.txt @@ -0,0 +1,10 @@ +Token +----- + +.. include:: ../global.rst + +.. automodule:: O365.utils.token + :members: + :undoc-members: + :show-inheritance: + :member-order: groupwise \ No newline at end of file diff --git a/docs/latest/_sources/api/utils/utils.rst.txt b/docs/latest/_sources/api/utils/utils.rst.txt new file mode 100644 index 00000000..a26ef772 --- /dev/null +++ b/docs/latest/_sources/api/utils/utils.rst.txt @@ -0,0 +1,10 @@ +Utils +----- + +.. include:: ../global.rst + +.. automodule:: O365.utils.utils + :members: + :undoc-members: + :show-inheritance: + :member-order: groupwise \ No newline at end of file diff --git a/docs/latest/_sources/getting_started.rst.txt b/docs/latest/_sources/getting_started.rst.txt new file mode 100644 index 00000000..28c3d69f --- /dev/null +++ b/docs/latest/_sources/getting_started.rst.txt @@ -0,0 +1,521 @@ +############### +Getting Started +############### + +Installation +============ +Stable Version (PyPI) +--------------------- +The latest stable package is hosted on `PyPI `_. + +To install using pip, run: + +.. code-block:: console + + pip install o365 + +or use uv: + +.. code-block:: console + + uv add o365 + +Requirements: >= Python 3.10 + +Project dependencies installed by pip: + +* requests +* msal +* beatifulsoup4 +* python-dateutil +* tzlocal +* tzdata + +Latest Development Version (GitHub) +----------------------------------- +The latest development version is available on `GitHub `_. +This version may include new features but could be unstable. **Use at your own risk**. + +Using pip, run: + +.. code-block:: console + + pip install git+https://github.com/O365/python-o365.git + +Or with uv, run: + +.. code-block:: console + + uv add "o365 @ git+https://github.com/O365/python-o365" + +Basic Usage +=========== + +The first step to be able to work with this library is to register an application and retrieve the auth token. See :ref:`authentication`. + +With the access token retrieved and stored you will be able to perform api calls to the service. + +A common pattern to check for authentication and use the library is this one: + +.. code-block:: python + + requested_scopes = ['my_required_scopes'] # you can use scope helpers here (see Permissions and Scopes section) + + account = Account(credentials) + + if not account.is_authenticated: # will check if there is a token and has not expired + # ask for a login using console based authentication. See Authentication for other flows + if account.authenticate(requested_scopes=requeated_scopes) is False: + raise RuntimeError('Authentication Failed') + + # now we are authenticated + # use the library from now on + + # ... + +.. _authentication: + +Authentication +============== +Types +----- +You can only authenticate using OAuth authentication because Microsoft deprecated basic auth on November 1st 2018. + +.. important:: + + With version 2.1 old access tokens will not work and the library will require a new authentication flow to get new access and refresh tokens. + +There are currently three authentication methods: + +* `Authenticate on behalf of a user `_: Any user will give consent to the app to access its resources. This OAuth flow is called authorization code grant flow. This is the default authentication method used by this library. + +* `Authenticate on behalf of a user (public) `_: Same as the former but for public apps where the client secret can't be secured. Client secret is not required. + +* `Authenticate with your own identity `_: This will use your own identity (the app identity). This OAuth flow is called client credentials grant flow. + +.. note:: + + 'Authenticate with your own identity' is not an allowed method for Microsoft Personal accounts. + +When to use one or the other and requirements: + + + ++----------------------------+---------------------------------------------------------+-----------------------------------------------------------+----------------------------------------------------------+ +| Topic | On behalf of a user *(auth_flow_type=='authorization')* | On behalf of a user (public) *(auth_flow_type=='public')* | With your own identity *(auth_flow_type=='credentials')* | ++============================+=========================================================+===========================================================+==========================================================+ +| **Register the App** | Required | Required | Required | ++----------------------------+---------------------------------------------------------+-----------------------------------------------------------+----------------------------------------------------------+ +| **Requires Admin Consent** | Only on certain advanced permissions | Only on certain advanced permissions | Yes, for everything | ++----------------------------+---------------------------------------------------------+-----------------------------------------------------------+----------------------------------------------------------+ +| **App Permission Type** | Delegated Permissions (on behalf of the user) | Delegated Permissions (on behalf of the user) | Application Permissions | ++----------------------------+---------------------------------------------------------+-----------------------------------------------------------+----------------------------------------------------------+ +| **Auth requirements** | Client Id, Client Secret, Authorization Code | Client Id, Authorization Code | Client Id, Client Secret | ++----------------------------+---------------------------------------------------------+-----------------------------------------------------------+----------------------------------------------------------+ +| **Authentication** | 2 step authentication with user consent | 2 step authentication with user consent | 1 step authentication | ++----------------------------+---------------------------------------------------------+-----------------------------------------------------------+----------------------------------------------------------+ +| **Auth Scopes** | Required | Required | None | ++----------------------------+---------------------------------------------------------+-----------------------------------------------------------+----------------------------------------------------------+ +| **Token Expiration** | 60 Minutes without refresh token or 90 days* | 60 Minutes without refresh token or 90 days* | 60 Minutes* | ++----------------------------+---------------------------------------------------------+-----------------------------------------------------------+----------------------------------------------------------+ +| **Login Expiration** | Unlimited if there is a refresh token and as long as a | Unlimited if there is a refresh token and as long as a | Unlimited | +| | refresh is done within the 90 days | refresh is done within the 90 days | | ++----------------------------+---------------------------------------------------------+-----------------------------------------------------------+----------------------------------------------------------+ +| **Resources** | Access the user resources, and any shared resources | Access the user resources, and any shared resources | All Azure AD users the app has access to | ++----------------------------+---------------------------------------------------------+-----------------------------------------------------------+----------------------------------------------------------+ +| **Microsoft Account Type** | Any | Any | Not Allowed for Personal Accounts | ++----------------------------+---------------------------------------------------------+-----------------------------------------------------------+----------------------------------------------------------+ +| **Tenant ID Required** | Defaults to "common" | Defaults to "common" | Required (can't be "common") | ++----------------------------+---------------------------------------------------------+-----------------------------------------------------------+----------------------------------------------------------+ + +*Note: *O365 will automatically refresh the token for you on either authentication method. The refresh token lasts 90 days, but it's refreshed on each connection so as long as you connect within 90 days you can have unlimited access.* + +The Connection Class handles the authentication. + +With auth_flow_type 'credentials' you can authenticate using a certificate based authentication by just passing the client_secret like so: + +.. code-block:: python + + client_secret = { + "thumbprint": , + "private_key": + } + credentials = client_id, client_secret + account = Account(credentials) + + +OAuth Setup (Prerequisite) +-------------------------- + +Before you can use python-o365, you must register your application in the +`Microsoft Entra Admin Center `_. Follow the steps below: + +1. **Log in to the Microsoft Entra Admin Center** + + - Visit https://entra.microsoft.com/ and sign in. + +2. **Create a new application and note its App (client) ID** + + - In the left navigation bar, select **Applications** > **App registrations**. + - Click **+ New registration**. + - Provide a **Name** for the application and keep all defaults. + - From the **Overview** of your new application, copy the (client_id) **Application (client) ID** for later reference. + +3. **Generate a new password (client_secret)** + + - In the **Overview** window, select **Certificates & secrets**. + - Click **New client secret**. + - In the **Add a client secret** window, provide a Description and Expiration, then click **Add**. + - Save the (client_secret) **Value** for later reference. + +4. **Add redirect URIs** + + - In the **Overview** window, click **Add a redirect URI**. + - Click **+ Add a platform**, then select **Web**. + - Add ``https://login.microsoftonline.com/common/oauth2/nativeclient`` as the redirect URI. + - Click **Save**. + +5. **Add required permissions** + + - In the left navigation bar, select **API permissions**. + - Click **+ Add a permission**. + - Under **Microsoft Graph**, select **Delegated permissions**. + - Add the delegated permissions you plan to use (for example): + + - Mail.Read + - Mail.ReadWrite + - Mail.Send + - User.Read + - User.ReadBasic.All + - offline_access + + - Click **Add permissions**. + +.. important:: + + The offline_access permission is required for the refresh token to work. + +Examples +-------- +Then you need to log in for the first time to get the access token that will grant access to the user resources. + +To authenticate (login) you can use :ref:`different_interfaces`. On the following examples we will be using the Console Based Interface, but you can use any of them. + +.. important:: + + In case you can't secure the client secret you can use the auth flow type 'public' which only requires the client id. + +* When authenticating on behalf of a user: + + 1. Instantiate an `Account` object with the credentials (client id and client secret). + 2. Call `account.authenticate` and pass the scopes you want (the ones you previously added on the app registration portal). + + > Note: when using the "on behalf of a user" authentication, you can pass the scopes to either the `Account` init or to the authenticate method. Either way is correct. + + You can pass "protocol scopes" (like: "https://graph.microsoft.com/Calendars.ReadWrite") to the method or use "[scope helpers](https://github.com/O365/python-o365/blob/master/O365/connection.py#L34)" like ("message_all"). + If you pass protocol scopes, then the `account` instance must be initialized with the same protocol used by the scopes. By using scope helpers you can abstract the protocol from the scopes and let this library work for you. + Finally, you can mix and match "protocol scopes" with "scope helpers". + Go to the [procotol section](#protocols) to know more about them. + + For Example (following the previous permissions added): + + .. code-block:: python + + from O365 import Account + credentials = ('my_client_id', 'my_client_secret') + + # the default protocol will be Microsoft Graph + # the default authentication method will be "on behalf of a user" + + account = Account(credentials) + if account.authenticate(requested_scopes=['basic', 'message_all']): + print('Authenticated!') + + # 'basic' adds: 'https://graph.microsoft.com/User.Read' + # 'message_all' adds: 'https://graph.microsoft.com/Mail.ReadWrite' and 'https://graph.microsoft.com/Mail.Send' + + When using the "on behalf of the user" authentication method, this method call will print an url that the user must visit to give consent to the app on the required permissions. + + The user must then visit this url and give consent to the application. When consent is given, the page will rediret to: "https://login.microsoftonline.com/common/oauth2/nativeclient" by default (you can change this) with an url query param called 'code'. + + Then the user must copy the resulting page url and paste it back on the console. + The method will then return True if the login attempt was succesful. + +* When authenticating with your own identity: + + 1. Instantiate an `Account` object with the credentials (client id and client secret), specifying the parameter `auth_flow_type` to *"credentials"*. You also need to provide a 'tenant_id'. You don't need to specify any scopes. + 2. Call `account.authenticate`. This call will request a token for you and store it in the backend. No user interaction is needed. The method will store the token in the backend and return True if the authentication succeeded. + + For Example: + + .. code-block:: python + + from O365 import Account + + credentials = ('my_client_id', 'my_client_secret') + + # the default protocol will be Microsoft Graph + + account = Account(credentials, auth_flow_type='credentials', tenant_id='my-tenant-id') + if account.authenticate(): + print('Authenticated!') + +At this point you will have an access token stored that will provide valid credentials when using the api. + +The access token only lasts **60 minutes**, but the app will automatically request new access tokens if you added the 'offline access' permission. + +When using the "on behalf of a user" authentication method this is accomplished through the refresh tokens (if and only if you added the "offline_access" permission), but note that a refresh token only lasts for 90 days. So you must use it before, or you will need to request a new access token again (no new consent needed by the user, just a login). If your application needs to work for more than 90 days without user interaction and without interacting with the API, then you must implement a periodic call to Connection.refresh_token before the 90 days have passed. + +.. important:: + + Take care: the access (and refresh) token must remain protected from unauthorized users. + +.. _different_interfaces: + +Different interfaces +-------------------- +To accomplish the authentication you can basically use different approaches. The following apply to the "on behalf of a user" authentication method as this is 2-step authentication flow. For the "with your own identity" authentication method, you can just use account.authenticate as it's not going to require a console input. + +1. Console based authentication interface: + + You can authenticate using a console. The best way to achieve this is by using the authenticate method of the Account class. + + account = Account(credentials) + account.authenticate(requested_scopes=['basic', 'message_all']) + The authenticate method will print into the console an url that you will have to visit to achieve authentication. Then after visiting the link and authenticate you will have to paste back the resulting url into the console. The method will return True and print a message if it was succesful. + + **Tip:** When using macOS the console is limited to 1024 characters. If your url has multiple scopes it can exceed this limit. To solve this. Just import readline at the top of your script. + +2. Web app based authentication interface: + + You can authenticate your users in a web environment by following these steps: + + i. First ensure you are using an appropiate TokenBackend to store the auth tokens (See Token storage below). + ii. From a handler redirect the user to the Microsoft login url. Provide a callback. Store the flow dictionary. + iii. From the callback handler complete the authentication with the flow dict and other data. + + The following example is done using Flask. + + .. code-block:: python + + from flask import request + from O365 import Account + + + @route('/stepone') + def auth_step_one(): + # callback = absolute url to auth_step_two_callback() page, https://domain.tld/steptwo + callback = url_for('auth_step_two_callback', _external=True) # Flask example + + account = Account(credentials) + url, flow = account.con.get_authorization_url(requested_scopes=my_scopes, + redirect_uri=callback) + + flow_as_string = serialize(flow) # convert the dict into a string using json for example + # the flow must be saved somewhere as it will be needed later + my_db.store_flow(flow_as_string) # example... + + return redirect(url) + + @route('/steptwo') + def auth_step_two_callback(): + account = Account(credentials) + + # retrieve the state saved in auth_step_one + my_saved_flow_str = my_db.get_flow() # example... + my_saved_flow = deserialize(my_saved_flow_str) # convert from a string to a dict using json for example. + + # rebuild the redirect_uri used in auth_step_one + callback = 'my absolute url to auth_step_two_callback' + + # get the request URL of the page which will include additional auth information + # Example request: /steptwo?code=abc123&state=xyz456 + requested_url = request.url # uses Flask's request() method + + result = account.con.request_token(requested_url, + flow=my_saved_flow) + # if result is True, then authentication was successful + # and the auth token is stored in the token backend + if result: + return render_template('auth_complete.html') + # else .... + +3. Other authentication interfaces: + + Finally, you can configure any other flow by using ``connection.get_authorization_url`` and ``connection.request_token`` as you want. + +Permissions & Scopes +==================== +Permissions +----------- +When using oauth, you create an application and allow some resources to be accessed and used by its users. These resources are managed with permissions. These can either be delegated (on behalf of a user) or application permissions. The former are used when the authentication method is "on behalf of a user". Some of these require administrator consent. The latter when using the "with your own identity" authentication method. All of these require administrator consent. + +Scopes +------ +The scopes only matter when using the "on behalf of a user" authentication method. + +.. note:: + You only need the scopes when login as those are kept stored within the token on the token backend. + +The user of this library can then request access to one or more of these resources by providing scopes to the OAuth provider. + +.. note:: + If you later on change the scopes requested, the current token will be invalid, and you will have to re-authenticate. The user that logins will be asked for consent. + +For example your application can have Calendar.Read, Mail.ReadWrite and Mail.Send permissions, but the application can request access only to the Mail.ReadWrite and Mail.Send permission. This is done by providing scopes to the Account instance or account.authenticate method like so: + +.. code-block:: python + + from O365 import Account + + credentials = ('client_id', 'client_secret') + + requested_scopes = ['Mail.ReadWrite', 'Mail.Send'] + + account = Account(credentials, requested_scopes=requested_scopes) + account.authenticate() + + # The latter is exactly the same as passing scopes to the authenticate method like so: + # account = Account(credentials) + # account.authenticate(requested_scopes=requested_scopes) + +Scope implementation depends on the protocol used. So by using protocol data you can automatically set the scopes needed. This is implemented by using 'scope helpers'. Those are little helpers that group scope functionality and abstract the protocol used. + +======================= =============== +Scope Helper Scopes included +======================= =============== +basic 'User.Read' +mailbox 'Mail.Read' +mailbox_shared 'Mail.Read.Shared' +mailbox_settings 'MailboxSettings.ReadWrite' +message_send 'Mail.Send' +message_send_shared 'Mail.Send.Shared' +message_all 'Mail.ReadWrite' and 'Mail.Send' +message_all_shared 'Mail.ReadWrite.Shared' and 'Mail.Send.Shared' +address_book 'Contacts.Read' +address_book_shared 'Contacts.Read.Shared' +address_book_all 'Contacts.ReadWrite' +address_book_all_shared 'Contacts.ReadWrite.Shared' +calendar 'Calendars.Read' +calendar_shared 'Calendars.Read.Shared' +calendar_all 'Calendars.ReadWrite' +calendar_shared_all 'Calendars.ReadWrite.Shared' +users 'User.ReadBasic.All' +onedrive 'Files.Read.All' +onedrive_all 'Files.ReadWrite.All' +sharepoint 'Sites.Read.All' +sharepoint_dl 'Sites.ReadWrite.All' +tasks 'Tasks.Read' +tasks_all 'Tasks.ReadWrite' +presence 'Presence.Read' +======================= =============== + +You can get the same scopes as before using protocols and scope helpers like this: + +.. code-block:: python + + protocol_graph = MSGraphProtocol() + + scopes_graph = protocol.get_scopes_for('message_all') + # scopes here are: ['https://graph.microsoft.com/Mail.ReadWrite', 'https://graph.microsoft.com/Mail.Send'] + + account = Account(credentials, requested_scopes=scopes_graph) + +.. note:: + + When passing scopes at the Account initialization or on the account.authenticate method, the scope helpers are automatically converted to the protocol flavour. Those are the only places where you can use scope helpers. Any other object using scopes (such as the Connection object) expects scopes that are already set for the protocol. + +Token Storage +============= + +When authenticating you will retrieve OAuth tokens. If you don't want a one time access you will have to store the token somewhere. O365 makes no assumptions on where to store the token and tries to abstract this from the library usage point of view. + +You can choose where and how to store tokens by using the proper Token Backend. + +.. caution:: + + **The access (and refresh) token must remain protected from unauthorized users.** You can plug in a "cryptography_manager" (object that can call encrypt and decrypt) into TokenBackends "cryptography_manager" attribute. + +The library will call (at different stages) the token backend methods to load and save the token. + +Methods that load tokens: + +* ``account.is_authenticated`` property will try to load the token if is not already loaded. +* ``connection.get_session``: this method is called when there isn't a request session set. + +Methods that stores tokens: + +* ``connection.request_token``: by default will store the token, but you can set store_token=False to avoid it. +* ``connection.refresh_token``: by default will store the token. To avoid it change ``connection.store_token_after_refresh`` to False. This however it's a global setting (that only affects the ``refresh_token`` method). If you only want the next refresh operation to not store the token you will have to set it back to True afterward. + +To store the token you will have to provide a properly configured TokenBackend. + +There are a few ``TokenBackend`` classes implemented (and you can easily implement more like a CookieBackend, RedisBackend, etc.): + +* ``FileSystemTokenBackend`` (Default backend): Stores and retrieves tokens from the file system. Tokens are stored as text files. +* ``MemoryTokenBackend``: Stores the tokens in memory. Basically load_token and save_token does nothing. +* ``EnvTokenBackend``: Stores and retrieves tokens from environment variables. +* ``FirestoreTokenBackend``: Stores and retrieves tokens from a Google Firestore Datastore. Tokens are stored as documents within a collection. +* ``AWSS3Backend``: Stores and retrieves tokens from an AWS S3 bucket. Tokens are stored as a file within a S3 bucket. +* ``AWSSecretsBackend``: Stores and retrieves tokens from an AWS Secrets Management vault. +* ``BitwardenSecretsManagerBackend``: Stores and retrieves tokens from Bitwarden Secrets Manager. +* ``DjangoTokenBackend``: Stores and retrieves tokens using a Django model. + +For example using the FileSystem Token Backend: + +.. code-block:: python + + from O365 import Account, FileSystemTokenBackend + + credentials = ('id', 'secret') + + # this will store the token under: "my_project_folder/my_folder/my_token.txt". + # you can pass strings to token_path or Path instances from pathlib + token_backend = FileSystemTokenBackend(token_path='my_folder', token_filename='my_token.txt') + account = Account(credentials, token_backend=token_backend) + + # This account instance tokens will be stored on the token_backend configured before. + # You don't have to do anything more + # ... + +And now using the same example using FirestoreTokenBackend: + +.. code-block:: python + + from O365 import Account + from O365.utils import FirestoreBackend + from google.cloud import firestore + + credentials = ('id', 'secret') + + # this will store the token on firestore under the tokens collection on the defined doc_id. + # you can pass strings to token_path or Path instances from pathlib + user_id = 'whatever the user id is' # used to create the token document id + document_id = f"token_{user_id}" # used to uniquely store this token + token_backend = FirestoreBackend(client=firestore.Client(), collection='tokens', doc_id=document_id) + account = Account(credentials, token_backend=token_backend) + + # This account instance tokens will be stored on the token_backend configured before. + # You don't have to do anything more + # ... + +To implement a new TokenBackend: + +1. Subclass ``BaseTokenBackend`` + +2. Implement the following methods: + + * ``__init__`` (don't forget to call ``super().__init__``) + * ``load_token``: this should load the token from the desired backend and return a ``Token`` instance or None + * ``save_token``: this should store the ``self.token`` in the desired backend. + * Optionally you can implement: ``check_token``, ``delete_token`` and ``should_refresh_token`` + +The ``should_refresh_token`` method is intended to be implemented for environments where multiple Connection instances are running on parallel. This method should check if it's time to refresh the token or not. The chosen backend can store a flag somewhere to answer this question. This can avoid race conditions between different instances trying to refresh the token at once, when only one should make the refresh. The method should return three possible values: + +* **True**: then the Connection will refresh the token. +* **False**: then the Connection will NOT refresh the token. +* None: then this method already executed the refresh and therefore the Connection does not have to. + +By default, this always returns True as it's assuming there is are no parallel connections running at once. + +There are two examples of this method in the examples folder `here `_. \ No newline at end of file diff --git a/docs/latest/_sources/index.rst.txt b/docs/latest/_sources/index.rst.txt new file mode 100644 index 00000000..74f7dd71 --- /dev/null +++ b/docs/latest/_sources/index.rst.txt @@ -0,0 +1,18 @@ +Welcome to O365's documentation! +================================ + +.. toctree:: + :maxdepth: 3 + :caption: Contents: + + overview + getting_started + usage + api + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` diff --git a/docs/latest/_sources/overview.rst.txt b/docs/latest/_sources/overview.rst.txt new file mode 100644 index 00000000..032461b0 --- /dev/null +++ b/docs/latest/_sources/overview.rst.txt @@ -0,0 +1,71 @@ +######## +Overview +######## + +**O365 - Microsoft Graph API made easy** + +.. important:: + + With version 2.1 old access tokens will not work, and the library will require a new authentication flow to get new access and refresh tokens. + +This project aims to make interacting with Microsoft Graph easy to do in a Pythonic way. Access to Email, Calendar, Contacts, OneDrive, etc. Are easy to do in a way that feel easy and straight forward to beginners and feels just right to seasoned python programmer. + +The project is currently developed and maintained by `alejcas `_. + +Core developers +--------------- +* `Alejcas `_ +* `Toben Archer `_ +* `Geethanadh `_ + +We are always open to new pull requests! + +Quick example +------------- +Here is a simple example showing how to send an email using python-o365. +Create a Python file and add the following code: + +.. code-block:: python + + from O365 import Account + + credentials = ('client_id', 'client_secret') + account = Account(credentials) + + m = account.new_message() + m.to.add('to_example@example.com') + m.subject = 'Testing!' + m.body = "George Best quote: I've stopped drinking, but only while I'm asleep." + m.send() + + +Why choose O365? +---------------- +* Almost Full Support for MsGraph Rest Api. +* Full OAuth support with automatic handling of refresh tokens. +* Automatic handling between local datetimes and server datetimes. Work with your local datetime and let this library do the rest. +* Change between different resource with ease: access shared mailboxes, other users resources, SharePoint resources, etc. +* Pagination support through a custom iterator that handles future requests automatically. Request Infinite items! +* A query helper to help you build custom OData queries (filter, order, select and search). +* Modular ApiComponents can be created and built to achieve further functionality. + +---- + +This project was also a learning resource for us. This is a list of not so common python idioms used in this project: + +* New unpacking technics: ``def method(argument, *, with_name=None, **other_params)``: +* Enums: from enum import Enum +* Factory paradigm +* Package organization +* Timezone conversion and timezone aware datetimes +* Etc. (see the code!) + +Rebuilding HTML Docs +-------------------- +* Install ``sphinx`` python library: + +.. code-block:: console + + pip install sphinx sphinx-rtd-theme + +* Run the shell script ``build_docs.sh``, or copy the command from the file when using on Windows diff --git a/docs/latest/_sources/usage.rst.txt b/docs/latest/_sources/usage.rst.txt new file mode 100644 index 00000000..aaba6b9d --- /dev/null +++ b/docs/latest/_sources/usage.rst.txt @@ -0,0 +1,23 @@ +============== +Detailed Usage +============== + +.. toctree:: + :maxdepth: 3 + :caption: Contents: + + usage/connection + usage/account + usage/addressbook + usage/calendar + usage/directory + usage/excel + usage/group + usage/mailbox + usage/onedrive + usage/planner + usage/sharepoint + usage/subscriptions + usage/tasks + usage/teams + usage/utils diff --git a/docs/latest/_sources/usage/account.rst.txt b/docs/latest/_sources/usage/account.rst.txt new file mode 100644 index 00000000..fb21c4f5 --- /dev/null +++ b/docs/latest/_sources/usage/account.rst.txt @@ -0,0 +1,248 @@ +Account +======= + +Multi-user handling +^^^^^^^^^^^^^^^^^^^ +A single ``Account`` object can hold more than one user being authenticated. You can authenticate different users and the token backend +will hold each authentication. When using the library you can use the ``account.username`` property to get or set the current user. +If username is not provided, the username will be set automatically to the first authentication found in the token backend. Also, +whenever you perform a new call to request_token (manually or through a call to ``account.authenticate``), +the username will be set to the user performing the authentication. + +.. code-block:: python + + account.username = 'user1@domain.com' + # issue some calls to retrieve data using the auth of the user1 + account.username = 'user2@domain.com' + # now every call will use the auth of the user2 + +This is only possible in version 2.1. Before 2.1 you had to instantiate one Account for each user. +Account class represents a specific account you would like to connect + +Setting your Account Instance +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +Connecting to API Account +""""""""""""""""""""""""" +.. code-block:: python + + from O365 import Account + + account = Account(credentials=('my_client_id', 'my_client_secret')) + +Setting Proxy +""""""""""""" +.. code-block:: python + + # Option 1 + account = Account(credentials=('my_client_id', 'my_client_secret'), + proxy_server='myserver.com', proxy_port=8080, + proxy_username='username', proxy_password='password) + + # Option 2 + account = Account(credentials=('my_client_id', 'my_client_secret')) + account.connection.set('myserver.com',8080,'username', 'password') + +Using Different Resource +"""""""""""""""""""""""" +.. code-block:: python + + from O365 import Account + + account = Account(credentials=('my_client_id', 'my_client_secret'), main_resource='shared_mail@example.com') + +Setting Scopes +"""""""""""""" +- You can set a list of scopes that your like to use, a huge list is available on `Microsoft Documentation `_ +- We have built a custom list make this scopes easier + + ========================= ========================================= ================================================== + Short Scope Name Description Scopes Included + ========================= ========================================= ================================================== + basic Read User Info ['User.Read'] + mailbox Read your mail ['Mail.Read'] + mailbox_shared Read shared mailbox ['Mail.Read.Shared'] + mailbox_settings Manage mailbox settings ['MailboxSettings.ReadWrite'] + message_send Send from your mailbox ['Mail.Send'] + message_send_shared Send using shared mailbox ['Mail.Send.Shared'] + message_all Full access to your mailbox ['Mail.ReadWrite', 'Mail.Send'] + message_all_shared Full access to shared mailbox ['Mail.ReadWrite.Shared', 'Mail.Send.Shared'] + address_book Read your Contacts ['Contacts.Read'] + address_book_shared Read shared contacts ['Contacts.Read.Shared'] + address_book_all Read/Write your Contacts ['Contacts.ReadWrite'] + address_book_all_shared Read/Write your Contacts ['Contacts.ReadWrite.Shared'] + calendar Read your Calendars ['Calendars.Read'] + calendar_shared Read shared Calendars ['Calendars.Read.Shared'] + calendar_all Full access to your Calendars ['Calendars.ReadWrite'] + calendar_shared_all Full access to your shared Calendars ['Calendars.ReadWrite.Shared'] + users Read info of all users ['User.ReadBasic.All'] + onedrive Read access to OneDrive ['Files.Read.All'] + onedrive_all Full access to OneDrive ['Files.ReadWrite.All'] + sharepoint Read access to Sharepoint ['Sites.Read.All'] + sharepoint_all Full access to Sharepoint ['Sites.ReadWrite.All'] + tasks Read access to Tasks ['Tasks.Read'] + tasks_all Full access to Tasks ['Tasks.ReadWrite'] + presence Read access to Presence ['Presence.Read'] + ========================= ========================================= ================================================== + +.. code-block:: python + + # Full permission to your mail + account = Account(credentials=('my_client_id', 'my_client_secret'), + requested_scopes=['message_all']) + + # Why change every time, add all at a time :) + account = Account(credentials=('my_client_id', 'my_client_secret'), + requested_scopes=['message_all', 'message_all_shared', 'address_book_all', + 'address_book_all_shared', + 'calendar', 'users', 'onedrive', 'sharepoint_dl']) + + +Authenticating your Account +^^^^^^^^^^^^^^^^^^^^^^^^^^^ +.. code-block:: python + + account = Account(credentials=('my_client_id', 'my_client_secret')) + account.authenticate() + +.. warning:: The call to authenticate is only required when you haven't authenticated before. If you already did the token file would have been saved + +The authenticate() method forces an authentication flow, which prints out a url + +#. Open the printed url +#. Give consent(approve) to the application +#. You will be redirected out outlook home page, copy the resulting url + .. note:: If the url is simply https://outlook.office.com/owa/?realm=blahblah, and nothing else after that, then you are currently on new Outlook look, revert to old look and try the authentication flow again +#. Paste the resulting URL into the python console. +#. That's it, you don't need this hassle again unless you want to add more scopes than you approved for + + +Account Class and Modularity +============================ +Usually you will only need to work with the ``Account`` Class. This is a wrapper around all functionality. + +But you can also work only with the pieces you want. + +For example, instead of: + +.. code-block:: python + + from O365 import Account + + account = Account(('client_id', 'client_secret')) + message = account.new_message() + # ... + mailbox = account.mailbox() + # ... + +You can work only with the required pieces: + +.. code-block:: python + + from O365 import Connection, MSGraphProtocol + from O365.message import Message + from O365.mailbox import MailBox + + protocol = MSGraphProtocol() + requested_scopes = ['...'] + con = Connection(('client_id', 'client_secret'), requested_scopes=requested_scopes) + + message = Message(con=con, protocol=protocol) + # ... + mailbox = MailBox(con=con, protocol=protocol) + message2 = Message(parent=mailbox) # message will inherit the connection and protocol from mailbox when using parent. + # ... + +It's also easy to implement a custom Class. Just Inherit from ApiComponent, define the endpoints, and use the connection to make requests. If needed also inherit from Protocol to handle different communications aspects with the API server. + +.. code-block:: python + + from O365.utils import ApiComponent + + class CustomClass(ApiComponent): + _endpoints = {'my_url_key': '/customendpoint'} + + def __init__(self, *, parent=None, con=None, **kwargs): + # connection is only needed if you want to communicate with the api provider + self.con = parent.con if parent else con + protocol = parent.protocol if parent else kwargs.get('protocol') + main_resource = parent.main_resource + + super().__init__(protocol=protocol, main_resource=main_resource) + # ... + + def do_some_stuff(self): + + # self.build_url just merges the protocol service_url with the endpoint passed as a parameter + # to change the service_url implement your own protocol inheriting from Protocol Class + url = self.build_url(self._endpoints.get('my_url_key')) + + my_params = {'param1': 'param1'} + + response = self.con.get(url, params=my_params) # note the use of the connection here. + + # handle response and return to the user... + + # the use it as follows: + from O365 import Connection, MSGraphProtocol + + protocol = MSGraphProtocol() # or maybe a user defined protocol + con = Connection(('client_id', 'client_secret'), requested_scopes=protocol.get_scopes_for(['...'])) + custom_class = CustomClass(con=con, protocol=protocol) + + custom_class.do_some_stuff() + + +.. _accessing_services: + +.. Accessing Services +.. ^^^^^^^^^^^^^^^^^^ +.. Below are the currently supported services + +.. - Mailbox - Read, Reply or send new mails to others +.. .. code-block:: python + +.. # Access Mailbox +.. mailbox = account.mailbox() + +.. # Access mailbox of another resource +.. mailbox = account.mailbox(resource='someone@example.com') + +.. - Address Book - Read or add new contacts to your address book +.. .. code-block:: python + +.. # Access personal address book +.. contacts = account.address_book() + +.. # Access personal address book of another resource +.. contacts = account.mailbox(resource='someone@example.com') + +.. # Access global shared server address book (Global Address List) +.. contacts = account.mailbox(address_book='gal') + +.. - Calendar Scheduler - Read or add new events to your calendar +.. .. code-block:: python + +.. # Access scheduler +.. scheduler = account.schedule() + +.. # Access scheduler of another resource +.. scheduler = account.schedule(resource='someone@example.com') + +.. - One Drive or Sharepoint Storage - Manipulate and Organize your Storage Drives +.. .. code-block:: python + +.. # Access storage +.. storage = account.storage() + +.. # Access storage of another resource +.. storage = account.storage(resource='someone@example.com') + +.. - Sharepoint Sites - Read and access items in your sharepoint sites +.. .. code-block:: python + +.. # Access sharepoint +.. sharepoint = account.sharepoint() + +.. # Access sharepoint of another resource +.. sharepoint = account.sharepoint(resource='someone@example.com') + diff --git a/docs/latest/_sources/usage/addressbook.rst.txt b/docs/latest/_sources/usage/addressbook.rst.txt new file mode 100644 index 00000000..9cf529a6 --- /dev/null +++ b/docs/latest/_sources/usage/addressbook.rst.txt @@ -0,0 +1,103 @@ +Address Book +============ +AddressBook groups the functionality of both the Contact Folders and Contacts. Outlook Distribution Groups are not supported (By the Microsoft API's). + +These are the scopes needed to work with the ``AddressBook`` and ``Contact`` classes. + +========================= ======================================= ====================================== +Raw Scope Included in Scope Helper Description +========================= ======================================= ====================================== +Contacts.Read address_book To only read my personal contacts +Contacts.Read.Shared address_book_shared To only read another user / shared mailbox contacts +Contacts.ReadWrite address_book_all To read and save personal contacts +Contacts.ReadWrite.Shared address_book_all_shared To read and save contacts from another user / shared mailbox +User.ReadBasic.All users To only read basic properties from users of my organization (User.Read.All requires administrator consent). +========================= ======================================= ====================================== + +Contact Folders +--------------- +Represents a Folder within your Contacts Section in Office 365. AddressBook class represents the parent folder (it's a folder itself). + +You can get any folder in your address book by requesting child folders or filtering by name. + +.. code-block:: python + + address_book = account.address_book() + + contacts = address_book.get_contacts(limit=None) # get all the contacts in the Personal Contacts root folder + + work_contacts_folder = address_book.get_folder(folder_name='Work Contacts') # get a folder with 'Work Contacts' name + + message_to_all_contats_in_folder = work_contacts_folder.new_message() # creates a draft message with all the contacts as recipients + + message_to_all_contats_in_folder.subject = 'Hallo!' + message_to_all_contats_in_folder.body = """ + George Best quote: + + If you'd given me the choice of going out and beating four men and smashing a goal in + from thirty yards against Liverpool or going to bed with Miss World, + it would have been a difficult choice. Luckily, I had both. + """ + message_to_all_contats_in_folder.send() + + # querying folders is easy: + child_folders = address_book.get_folders(25) # get at most 25 child folders + + for folder in child_folders: + print(folder.name, folder.parent_id) + + # creating a contact folder: + address_book.create_child_folder('new folder') + +.. _global_address_list: + +Global Address List +------------------- +MS Graph API has no concept such as the Outlook Global Address List. +However you can use the `Users API `_ to access all the users within your organization. + +Without admin consent you can only access a few properties of each user such as name and email and little more. You can search by name or retrieve a contact specifying the complete email. + +* Basic Permission needed is Users.ReadBasic.All (limit info) +* Full Permission is Users.Read.All but needs admin consent. + +To search the Global Address List (Users API): + +.. code-block:: python + + global_address_list = account.directory() + + # for backwards compatibility only this also works and returns a Directory object: + # global_address_list = account.address_book(address_book='gal') + + # start a new query: + builder = global_address_list.new_query() + query = builder.startswith('display_name', 'George Best') + + for user in global_address_list.get_users(query=q): + print(user) + +To retrieve a contact by their email: + +.. code-block:: python + + contact = global_address_list.get_user('example@example.com') + Contacts + + Everything returned from an AddressBook instance is a Contact instance. Contacts have all the information stored as attributes + + Creating a contact from an AddressBook: + + new_contact = address_book.new_contact() + + new_contact.name = 'George Best' + new_contact.job_title = 'football player' + new_contact.emails.add('george@best.com') + + new_contact.save() # saved on the cloud + + message = new_contact.new_message() # Bonus: send a message to this contact + + # ... + + new_contact.delete() # Bonus: deleted from the cloud \ No newline at end of file diff --git a/docs/latest/_sources/usage/calendar.rst.txt b/docs/latest/_sources/usage/calendar.rst.txt new file mode 100644 index 00000000..971fe45a --- /dev/null +++ b/docs/latest/_sources/usage/calendar.rst.txt @@ -0,0 +1,86 @@ +Calendar +======== +The calendar and events functionality is group in a Schedule object. + +A ``Schedule`` instance can list and create calendars. It can also list or create events on the default user calendar. To use other calendars use a ``Calendar`` instance. + +These are the scopes needed to work with the ``Schedule``, ``Calendar`` and ``Event`` classes. + +========================== ======================================= ====================================== +Raw Scope Included in Scope Helper Description +========================== ======================================= ====================================== +Calendars.Read calendar To only read my personal calendars +Calendars.Read.Shared calendar_shared To only read another user / shared mailbox calendars +Calendars.ReadWrite calendar_all To read and save personal calendars +Calendars.ReadWrite.Shared calendar_shared_all To read and save calendars from another user / shared mailbox +========================== ======================================= ====================================== + +Working with the ``Schedule`` instance: + +.. code-block:: python + + import datetime as dt + + # ... + schedule = account.schedule() + + calendar = schedule.get_default_calendar() + new_event = calendar.new_event() # creates a new unsaved event + new_event.subject = 'Recruit George Best!' + new_event.location = 'England' + + # naive datetimes will automatically be converted to timezone aware datetime + # objects using the local timezone detected or the protocol provided timezone + + new_event.start = dt.datetime(2019, 9, 5, 19, 45) + # so new_event.start becomes: datetime.datetime(2018, 9, 5, 19, 45, tzinfo=) + + new_event.recurrence.set_daily(1, end=dt.datetime(2019, 9, 10)) + new_event.remind_before_minutes = 45 + + new_event.save() + +Working with Calendar instances: + +.. code-block:: python + + calendar = schedule.get_calendar(calendar_name='Birthdays') + + builder = calendar.new_query() + calendar.name = 'Football players birthdays' + calendar.update() + + + start_q = builder.greater_equal('start', dt.datetime(2018, 5, 20)) + end_q = builder.less_equal('start', dt.datetime(2018, 5, 24)) + + birthdays = calendar.get_events( + include_recurring=True, # include_recurring=True will include repeated events on the result set. + start_recurring=start_q, + end_recurring=end_q, + ) + + for event in birthdays: + if event.subject == 'George Best Birthday': + # He died in 2005... but we celebrate anyway! + event.accept("I'll attend!") # send a response accepting + else: + event.decline("No way I'm coming, I'll be in Spain", send_response=False) # decline the event but don't send a response to the organizer + +**Notes regarding Calendars and Events**: + +1. Include_recurring=True: + + It's important to know that when querying events with include_recurring=True (which is the default), + it is required that you must provide start and end parameters, these may be simple date strings, python dates or individual queries. + Unlike when using include_recurring=False those attributes will NOT filter the data based on the operations you set on the query (greater_equal, less, etc.) + but just filter the events start datetime between the provided start and end datetimes. + +2. Shared Calendars: + + There are some known issues when working with `shared calendars `_ in Microsoft Graph. + +3. Event attachments: + + For some unknown reason, Microsoft does not allow to upload an attachment at the event creation time (as opposed with message attachments). + See `this `_. So, to upload attachments to Events, first save the event, then attach the message and save again. \ No newline at end of file diff --git a/docs/latest/_sources/usage/connection.rst.txt b/docs/latest/_sources/usage/connection.rst.txt new file mode 100644 index 00000000..0a1dcbbf --- /dev/null +++ b/docs/latest/_sources/usage/connection.rst.txt @@ -0,0 +1,92 @@ +Protocols +========= +Protocols handles the aspects of communications between different APIs. This project uses the Microsoft Graph APIs. But, you can use many other Microsoft APIs as long as you implement the protocol needed. + +You can use: + +* MSGraphProtocol to use the `Microsoft Graph API `_ + +.. code-block:: python + + from O365 import Account + + credentials = ('client_id', 'client_secret') + + account = Account(credentials, auth_flow_type='credentials', tenant_id='my_tenant_id') + if account.authenticate(): + print('Authenticated!') + mailbox = account.mailbox('sender_email@my_domain.com') + m = mailbox.new_message() + m.to.add('to_example@example.com') + m.subject = 'Testing!' + m.body = "George Best quote: I've stopped drinking, but only while I'm asleep." + m.save_message() + m.attachment.add = 'filename.txt' + m.send() + +The default protocol used by the ``Account`` Class is ``MSGraphProtocol``. + +You can implement your own protocols by inheriting from Protocol to communicate with other Microsoft APIs. + +You can instantiate and use protocols like this: + +.. code-block:: python + + from O365 import Account, MSGraphProtocol # same as from O365.connection import MSGraphProtocol + + # ... + + # try the api version beta of the Microsoft Graph endpoint. + protocol = MSGraphProtocol(api_version='beta') # MSGraphProtocol defaults to v1.0 api version + account = Account(credentials, protocol=protocol) + + +Resources +========= +Each API endpoint requires a resource. This usually defines the owner of the data. Every protocol defaults to resource 'ME'. 'ME' is the user which has given consent, but you can change this behaviour by providing a different default resource to the protocol constructor. + +.. note:: + + When using the "with your own identity" authentication method the resource 'ME' is overwritten to be blank as the authentication method already states that you are login with your own identity. + +For example when accessing a shared mailbox: + +.. code-block:: python + + # ... + account = Account(credentials=my_credentials, main_resource='shared_mailbox@example.com') + # Any instance created using account will inherit the resource defined for account. + +This can be done however at any point. For example at the protocol level: + +.. code-block:: python + + # ... + protocol = MSGraphProtocol(default_resource='shared_mailbox@example.com') + + account = Account(credentials=my_credentials, protocol=protocol) + + # now account is accessing the shared_mailbox@example.com in every api call. + shared_mailbox_messages = account.mailbox().get_messages() + +Instead of defining the resource used at the account or protocol level, you can provide it per use case as follows: + +.. code-block:: python + + # ... + account = Account(credentials=my_credentials) # account defaults to 'ME' resource + + mailbox = account.mailbox('shared_mailbox@example.com') # mailbox is using 'shared_mailbox@example.com' resource instead of 'ME' + + # or: + + message = Message(parent=account, main_resource='shared_mailbox@example.com') # message is using 'shared_mailbox@example.com' resource + +Usually you will work with the default 'ME' resource, but you can also use one of the following: + +* 'me': the user which has given consent. The default for every protocol. Overwritten when using "with your own identity" authentication method (Only available on the authorization auth_flow_type). +* 'user:user@domain.com': a shared mailbox or a user account for which you have permissions. If you don't provide 'user:' it will be inferred anyway. +* 'site:sharepoint-site-id': a Sharepoint site id. +* 'group:group-site-id': an Microsoft 365 group id. + +By setting the resource prefix (such as 'user:' or 'group:') you help the library understand the type of resource. You can also pass it like 'users/example@exampl.com'. The same applies to the other resource prefixes. \ No newline at end of file diff --git a/docs/latest/_sources/usage/directory.rst.txt b/docs/latest/_sources/usage/directory.rst.txt new file mode 100644 index 00000000..8ba31933 --- /dev/null +++ b/docs/latest/_sources/usage/directory.rst.txt @@ -0,0 +1,32 @@ + +Directory and Users +=================== +The Directory object can retrieve users. + +A User instance contains by default the `basic properties of the user `_. If you want to include more, you will have to select the desired properties manually. + +Check :ref:`global_address_list` for further information. + +These are the scopes needed to work with the Directory class. + +========================= ======================================= ====================================== +Raw Scope Included in Scope Helper Description +========================= ======================================= ====================================== +User.ReadBasic.All users To read a basic set of profile properties of other users in your organization on behalf of the signed-in user. This includes display name, first and last name, email address, open extensions and photo. Also allows the app to read the full profile of the signed-in user. +User.Read.All — To read the full set of profile properties, reports, and managers of other users in your organization, on behalf of the signed-in user. +User.ReadWrite.All — To read and write the full set of profile properties, reports, and managers of other users in your organization, on behalf of the signed-in user. Also allows the app to create and delete users as well as reset user passwords on behalf of the signed-in user. +Directory.Read.All — To read data in your organization's directory, such as users, groups and apps, without a signed-in user. +Directory.ReadWrite.All — To read and write data in your organization's directory, such as users, and groups, without a signed-in user. Does not allow user or group deletion. +========================= ======================================= ====================================== + +.. note:: + + To get authorized with the above scopes you need a work or school account, it doesn't work with personal account. + +Working with the ``Directory`` instance to read the active directory users: + +.. code-block:: python + + directory = account.directory() + for user in directory.get_users(): + print(user) diff --git a/docs/latest/_sources/usage/excel.rst.txt b/docs/latest/_sources/usage/excel.rst.txt new file mode 100644 index 00000000..902528fc --- /dev/null +++ b/docs/latest/_sources/usage/excel.rst.txt @@ -0,0 +1,71 @@ +Excel +===== +You can interact with new Excel files (.xlsx) stored in OneDrive or a SharePoint Document Library. You can retrieve workbooks, worksheets, tables, and even cell data. You can also write to any excel online. + +To work with Excel files, first you have to retrieve a ``File`` instance using the OneDrive or SharePoint functionality. + +The scopes needed to work with the ``WorkBook`` and Excel related classes are the same used by OneDrive. + +This is how you update a cell value: + +.. code-block:: python + + from O365.excel import WorkBook + + # given a File instance that is a xlsx file ... + excel_file = WorkBook(my_file_instance) # my_file_instance should be an instance of File. + + ws = excel_file.get_worksheet('my_worksheet') + cella1 = ws.get_range('A1') + cella1.values = 35 + cella1.update() + +Workbook Sessions +----------------- + +When interacting with Excel, you can use a workbook session to efficiently make changes in a persistent or nonpersistent way. These sessions become usefull if you perform numerous changes to the Excel file. + +The default is to use a session in a persistent way. Sessions expire after some time of inactivity. When working with persistent sessions, new sessions will automatically be created when old ones expire. + +You can however change this when creating the ``Workbook`` instance: + +.. code-block:: python + + excel_file = WorkBook(my_file_instance, use_session=False, persist=False) + +Available Objects +----------------- + +After creating the ``WorkBook`` instance you will have access to the following objects: + +* WorkSheet +* Range and NamedRange +* Table, TableColumn and TableRow +* RangeFormat (to format ranges) +* Charts (not available for now) + +Some examples: + +Set format for a given range + +.. code-block:: python + + # ... + my_range = ws.get_range('B2:C10') + fmt = myrange.get_format() + fmt.font.bold = True + fmt.update() + +Autofit Columns: + +.. code-block:: python + + ws.get_range('B2:C10').get_format().auto_fit_columns() + +Get values from Table: + +.. code-block:: python + + table = ws.get_table('my_table') + column = table.get_column_at_index(1) + values = column.values[0] # values returns a two-dimensional array. diff --git a/docs/latest/_sources/usage/group.rst.txt b/docs/latest/_sources/usage/group.rst.txt new file mode 100644 index 00000000..54fbd4a9 --- /dev/null +++ b/docs/latest/_sources/usage/group.rst.txt @@ -0,0 +1,36 @@ +Group +===== +Groups enables viewing of groups + +These are the scopes needed to work with the ``Group`` classes. + +========================= ======================================= ====================================== +Raw Scope Included in Scope Helper Description +========================= ======================================= ====================================== +Group.Read.All — To read groups +========================= ======================================= ====================================== + +Assuming an authenticated account and a previously created group, create a Plan instance. + +.. code-block:: python + + #Create a plan instance + from O365 import Account + account = Account(('app_id', 'app_pw')) + groups = account.groups() + + # To retrieve the list of groups + group_list = groups.list_groups() + + # Or to retrieve a list of groups for a given user + user_groups = groups.get_user_groups(user_id="object_id") + + # To retrieve a group by an identifier + group = groups.get_group_by_id(group_id="object_id") + group = groups.get_group_by_mail(group_mail="john@doe.com") + + + # To retrieve the owners and members of a group + owners = group.get_group_owners() + members = group.get_group_members() + diff --git a/docs/latest/_sources/usage/mailbox.rst.txt b/docs/latest/_sources/usage/mailbox.rst.txt new file mode 100644 index 00000000..dc71711b --- /dev/null +++ b/docs/latest/_sources/usage/mailbox.rst.txt @@ -0,0 +1,295 @@ +Mailbox +======= +Mailbox groups the functionality of both the messages and the email folders. + +These are the scopes needed to work with the ``MailBox`` and ``Message`` classes. + +========================= ======================================= ====================================== +Raw Scope Included in Scope Helper Description +========================= ======================================= ====================================== +Mail.Read mailbox To only read my mailbox +Mail.Read.Shared mailbox_shared To only read another user / shared mailboxes +Mail.Send message_send, message_all To only send message +Mail.Send.Shared message_send_shared, message_all_shared To only send message as another user / shared mailbox +Mail.ReadWrite message_all To read and save messages in my mailbox +MailboxSettings.ReadWrite mailbox_settings To read and write user mailbox settings +========================= ======================================= ====================================== + +.. Useful Methods +.. ^^^^^^^^^^^^^^^^^^^^^^^^^ +.. `get_folder()` and `get_folders()` are useful to fetch folders that are available under the current instance + +.. Get Single Folder +.. """"""""""""""""" +.. **Using Name** + +.. Using name to get a folder will only search the folders directly under the current folder or root + +.. .. code-block:: python + +.. # By Name - Will only find direct child folder +.. mail_folder = mailbox.get_folder(folder_name='Todo') + +.. # By Name - If Todo folder is under Inbox folder +.. mail_folder = (mailbox.get_folder(folder_name='Inbox') +.. .get_folder(folder_name='Todo')) + +.. **Using ID** + +.. As opposed to getting folder by name, using the id you can fetch folder from any child + +.. .. code-block:: python + +.. # Assuming we are getting folder Todo under Inbox +.. mail_folder = mailbox.get_folder(folder_id='some_id_you_may_have_obtained') + +.. **Well Known Folders** + +.. There are few well know folders like **Inbox**, **Drafts**, etc.. +.. As they are generally used we have added functions to quickly access them + +.. .. code-block:: python + +.. # Inbox +.. mail_folder = mailbox.inbox_folder() + +.. # DeletedItems +.. mail_folder = mailbox.deleted_folder() + +.. # Drafts +.. mail_folder = mailbox.drafts_folder() + +.. # Junk +.. mail_folder = mailbox.junk_folder() + +.. # Outbox +.. mail_folder = mailbox.outbox_folder() + +.. Get Child Folders +.. """"""""""""""""" +.. **All or Some Child Folders** + +.. .. code-block:: python + +.. # All child folders under root +.. mail_folders = mailbox.get_folders() + +.. # All child folders under Inbox +.. mail_folders = mailbox.inbox_folder().get_folders() + +.. # Limit the number or results, will get the top x results +.. mail_folders = mailbox.get_folders(limit=7) + +.. **Filter the results** + +.. Query is a class available, that lets you filter results + +.. .. code-block:: python + +.. # All child folders whose name startswith 'Top' +.. mail_folders = mailbox.get_folders(query=mailbox.new_query().startswith('display_name', 'Top')) + +Mailbox and Messages +"""""""""""""""""""" + +.. code-block:: python + + mailbox = account.mailbox() + + inbox = mailbox.inbox_folder() + + for message in inbox.get_messages(): + print(message) + + sent_folder = mailbox.sent_folder() + + for message in sent_folder.get_messages(): + print(message) + + m = mailbox.new_message() + + m.to.add('to_example@example.com') + m.body = 'George Best quote: In 1969 I gave up women and alcohol - it was the worst 20 minutes of my life.' + m.save_draft() + + +Email Folder +"""""""""""" + +Represents a Folder within your email mailbox. + +You can get any folder in your mailbox by requesting child folders or filtering by name. + +.. code-block:: python + + mailbox = account.mailbox() + + archive = mailbox.get_folder(folder_name='archive') # get a folder with 'archive' name + + child_folders = archive.get_folders(25) # get at most 25 child folders of 'archive' folder + + for folder in child_folders: + print(folder.name, folder.parent_id) + + new_folder = archive.create_child_folder('George Best Quotes') + +Message +""""""" + +**An email object with all its data and methods** + +Creating a draft message is as easy as this: + +.. code-block:: python + + message = mailbox.new_message() + message.to.add(['example1@example.com', 'example2@example.com']) + message.sender.address = 'my_shared_account@example.com' # changing the from address + message.body = 'George Best quote: I might go to Alcoholics Anonymous, but I think it would be difficult for me to remain anonymous' + message.attachments.add('george_best_quotes.txt') + message.save_draft() # save the message on the cloud as a draft in the drafts folder + +**Working with saved emails is also easy** + +.. code-block:: python + + builder = mailbox.new_query() + query = builder.chain_or(builder.contains('subject', 'george best'), builder.startswith('subject', 'quotes') # see Query object in Utils + messages = mailbox.get_messages(limit=25, query=query) + + message = messages[0] # get the first one + + message.mark_as_read() + reply_msg = message.reply() + + if 'example@example.com' in reply_msg.to: # magic methods implemented + reply_msg.body = 'George Best quote: I spent a lot of money on booze, birds and fast cars. The rest I just squandered.' + else: + reply_msg.body = 'George Best quote: I used to go missing a lot... Miss Canada, Miss United Kingdom, Miss World.' + + reply_msg.send() + +**Sending Inline Images** + +You can send inline images by doing this: + +.. code-block:: python + + # ... + msg = account.new_message() + msg.to.add('george@best.com') + msg.attachments.add('my_image.png') + att = msg.attachments[0] # get the attachment object + + # this is super important for this to work. + att.is_inline = True + att.content_id = 'image.png' + + # notice we insert an image tag with source to: "cid:{content_id}" + body = """ + + + There should be an image here: +

+ +

+ + + """ + msg.body = body + msg.send() + +**Retrieving Message Headers** + +You can retrieve message headers by doing this: + +.. code-block:: python + + # ... + mb = account.mailbox() + msg = mb.get_message(query=mb.q().select('internet_message_headers')) + print(msg.message_headers) # returns a list of dicts. + +Note that only message headers and other properties added to the select statement will be present. + +**Saving as EML** + +Messages and attached messages can be saved as ``*.eml``. + +Save message as "eml": + +.. code-block:: python + + msg.save_as_eml(to_path=Path('my_saved_email.eml')) + +**Save attached message as "eml"** + +Careful: there's no way to identify that an attachment is in fact a message. You can only check if the attachment.attachment_type == 'item'. if is of type "item" then it can be a message (or an event, etc...). You will have to determine this yourself. + +.. code-block:: python + + msg_attachment = msg.attachments[0] # the first attachment is attachment.attachment_type == 'item' and I know it's a message. + msg.attachments.save_as_eml(msg_attachment, to_path=Path('my_saved_email.eml')) + +Mailbox Settings +"""""""""""""""" +The mailbox settings and associated methods. + +Retrieve and update mailbox auto reply settings: + +.. code-block:: python + + from O365.mailbox import AutoReplyStatus, ExternalAudience + + mailboxsettings = mailbox.get_settings() + ars = mailboxsettings.automaticrepliessettings + + ars.scheduled_startdatetime = start # Sets the start date/time + ars.scheduled_enddatetime = end # Sets the end date/time + ars.status = AutoReplyStatus.SCHEDULED # DISABLED/SCHEDULED/ALWAYSENABLED - Uses start/end date/time if scheduled. + ars.external_audience = ExternalAudience.NONE # NONE/CONTACTSONLY/ALL + ars.internal_reply_message = "ARS Internal" # Internal message + ars.external_reply_message = "ARS External" # External message + mailboxsettings.save() + Alternatively to enable and disable + + mailboxsettings.save() + + mailbox.set_automatic_reply( + "Internal", + "External", + scheduled_start_date_time=start, # Status will be 'scheduled' if start/end supplied, otherwise 'alwaysEnabled' + scheduled_end_date_time=end, + externalAudience=ExternalAudience.NONE, # Defaults to ALL + ) + mailbox.set_disable_reply() + + +Outlook Categories +"""""""""""""""""" +You can retrieve, update, create and delete outlook categories. These categories can be used to categorize Messages, Events and Contacts. + +These are the scopes needed to work with the SharePoint and Site classes. + +========================= ======================================= ====================================== +Raw Scope Included in Scope Helper Description +========================= ======================================= ====================================== +MailboxSettings.Read — To only read outlook settings +MailboxSettings.ReadWrite mailbox_settings To read and write outlook settings +========================= ======================================= ====================================== + +Example: + +.. code-block:: python + + from O365.category import CategoryColor + + oc = account.outlook_categories() + categories = oc.get_categories() + for category in categories: + print(category.name, category.color) + + my_category = oc.create_category('Important Category', color=CategoryColor.RED) + my_category.update_color(CategoryColor.DARKGREEN) + + my_category.delete() # oops! diff --git a/docs/latest/_sources/usage/onedrive.rst.txt b/docs/latest/_sources/usage/onedrive.rst.txt new file mode 100644 index 00000000..a3b34adc --- /dev/null +++ b/docs/latest/_sources/usage/onedrive.rst.txt @@ -0,0 +1,109 @@ +OneDrive +======== +The ``Storage`` class handles all functionality around One Drive and Document Library Storage in SharePoint. + +The ``Storage`` instance allows retrieval of ``Drive`` instances which handles all the Files +and Folders from within the selected ``Storage``. Usually you will only need to work with the +default drive. But the ``Storage`` instances can handle multiple drives. + +A ``Drive`` will allow you to work with Folders and Files. + +These are the scopes needed to work with the ``Storage``, ``Drive`` and ``DriveItem`` classes. + +========================= ======================================= ====================================== +Raw Scope Included in Scope Helper Description +========================= ======================================= ====================================== +Files.Read — To only read my files +Files.Read.All onedrive To only read all the files the user has access +Files.ReadWrite — To read and save my files +Files.ReadWrite.All onedrive_all To read and save all the files the user has access +========================= ======================================= ====================================== + +.. code-block:: python + + account = Account(credentials=my_credentials) + + storage = account.storage() # here we get the storage instance that handles all the storage options. + + # list all the drives: + drives = storage.get_drives() + + # get the default drive + my_drive = storage.get_default_drive() # or get_drive('drive-id') + + # get some folders: + root_folder = my_drive.get_root_folder() + attachments_folder = my_drive.get_special_folder('attachments') + + # iterate over the first 25 items on the root folder + for item in root_folder.get_items(limit=25): + if item.is_folder: + print(list(item.get_items(2))) # print the first to element on this folder. + elif item.is_file: + if item.is_photo: + print(item.camera_model) # print some metadata of this photo + elif item.is_image: + print(item.dimensions) # print the image dimensions + else: + # regular file: + print(item.mime_type) # print the mime type + +Both Files and Folders are DriveItems. Both Image and Photo are Files, but Photo is also an Image. All have some different methods and properties. Take care when using 'is_xxxx'. + +When copying 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. + +.. code-block:: python + + # copy a file to the documents special folder + + documents_folder = my_drive.get_special_folder('documents') + + files = my_drive.search('george best quotes', limit=1) + + if files: + george_best_quotes = files[0] + operation = george_best_quotes.copy(target=documents_folder) # operation here is an instance of CopyOperation + + # to check for the result just loop over check_status. + # check_status is a generator that will yield a new status and progress until the file is finally copied + for status, progress in operation.check_status(): # if it's an async operations, this will request to the api for the status in every loop + print(f"{status} - {progress}") # prints 'in progress - 77.3' until finally completed: 'completed - 100.0' + copied_item = operation.get_item() # the copy operation is completed so you can get the item. + if copied_item: + copied_item.delete() # ... oops! + +You can also work with share permissions: + +.. code-block:: python + + current_permisions = file.get_permissions() # get all the current permissions on this drive_item (some may be inherited) + + # share with link + permission = file.share_with_link(share_type='edit') + if permission: + print(permission.share_link) # the link you can use to share this drive item + # share with invite + permission = file.share_with_invite(recipients='george_best@best.com', send_email=True, message='Greetings!!', share_type='edit') + if permission: + print(permission.granted_to) # the person you share this item with + +You can also: + +.. code-block:: python + + # download files: + file.download(to_path='/quotes/') + + # upload files: + + # if the uploaded file is bigger than 4MB the file will be uploaded in chunks of 5 MB until completed. + # this can take several requests and can be time consuming. + uploaded_file = folder.upload_file(item='path_to_my_local_file') + + # restore versions: + versions = file.get_versions() + for version in versions: + if version.name == '2.0': + version.restore() # restore the version 2.0 of this file + + # ... and much more ... \ No newline at end of file diff --git a/docs/latest/_sources/usage/planner.rst.txt b/docs/latest/_sources/usage/planner.rst.txt new file mode 100644 index 00000000..c563051e --- /dev/null +++ b/docs/latest/_sources/usage/planner.rst.txt @@ -0,0 +1,56 @@ +Planner +======= +Planner enables the creation and maintenance of plans, buckets and tasks + +These are the scopes needed to work with the ``Planner`` classes. + +========================= ======================================= ====================================== +Raw Scope Included in Scope Helper Description +========================= ======================================= ====================================== +Group.Read.All — To only read plans +Group.ReadWrite.All — To create and maintain a plan +========================= ======================================= ====================================== + +Assuming an authenticated account and a previously created group, create a Plan instance. + +.. code-block:: python + + #Create a plan instance + from O365 import Account + account = Account(('app_id', 'app_pw')) + planner = account.planner() + plan = planner.create_plan( + owner="group_object_id", title="Test Plan" + ) + +| Common commands for :code:`planner` include :code:`.create_plan()`, :code:`.get_bucket_by_id()`, :code:`.get_my_tasks()`, :code:`.list_group_plans()`, :code:`.list_group_tasks()` and :code:`.delete()`. +| Common commands for :code:`plan` include :code:`.create_bucket()`, :code:`.get_details()`, :code:`.list_buckets()`, :code:`.list_tasks()` and :code:`.delete()`. + +Then to create a bucket within a plan. + +.. code-block:: python + + #Create a bucket instance in a plan + bucket = plan.create_bucket(name="Test Bucket") + +Common commands for :code:`bucket` include :code:`.list_tasks()` and :code:`.delete()`. + +Then to create a task, assign it to a user, set it to 50% completed and add a description. + +.. code-block:: python + + #Create a task in a bucket + assignments = { + "user_object_id: { + "@odata.type": "microsoft.graph.plannerAssignment", + "orderHint": "1 !", + } + } + task = bucket.create_task(title="Test Task", assignments=assignments) + + task.update(percent_complete=50) + + task_details = task.get_details() + task_details.update(description="Test Description") + +Common commands for :code:`task` include :code:`.get_details()`, :code:`.update()` and :code:`.delete()`. \ No newline at end of file diff --git a/docs/latest/_sources/usage/query.rst.txt b/docs/latest/_sources/usage/query.rst.txt new file mode 100644 index 00000000..98f9be8d --- /dev/null +++ b/docs/latest/_sources/usage/query.rst.txt @@ -0,0 +1,4 @@ +Query +===== +**Query** class helps with creating filters for the results (It can be either filtering event or email messages or any function that accepts query attribute) + diff --git a/docs/latest/_sources/usage/sharepoint.rst.txt b/docs/latest/_sources/usage/sharepoint.rst.txt new file mode 100644 index 00000000..62a727c3 --- /dev/null +++ b/docs/latest/_sources/usage/sharepoint.rst.txt @@ -0,0 +1,115 @@ +Sharepoint +========== + +These are the scopes needed to work with the SharePoint and Site classes. + +========================= ======================================= ======================================= +Raw Scope Included in Scope Helper Description +========================= ======================================= ======================================= +Sites.Selected *None* Sites a permission was granted by admins (see https://github.com/O365/python-o365/issues/1122) +Sites.Read.All sharepoint To only read sites, lists and items +Sites.ReadWrite.All sharepoint_dl To read and save sites, lists and items +========================= ======================================= ======================================= + +Note that using the :code:`.All` scopes is way less secure than granting permissions to specific sites and using +:code:`Sites.Selected` scope. + +Assuming an authenticated account, create a Sharepoint instance, and connect +to a Sharepoint site. + +.. code-block:: python + + #Create Sharepoint instance and connect to a site + from O365 import Account + acct = Account(('app_id', 'app_pw')) + sp_site = acct.sharepoint().get_site('root', 'path/tosite') + +Common commands for :code:`sp_site` include :code:`.display_name`, +:code:`.get_document_library()`, :code:`.get_subsites()`, :code:`.get_lists()`, +and :code:`.get_list_by_name('list_name')`. + +**Accessing Subsites** + +If a Sharepoint site contains subsites they can be returned as a list of +Sharepoint sites by the :code:`.get_subsites()` function. + +.. code-block:: python + + #Return a List of subsites + sp_site_subsites = sp_site.get_subsites() + print(sp_sites_subsites) + [Site: subsitename1, Site: subsitename2] + + #Make another Site object from a desired subsite + new_sp_site = sp_site_subsites[0] #return the first subsite + +Sharepoint Lists +^^^^^^^^^^^^^^^^ + +Sharepoint Lists are accessible from their Sharepoint site using :code:`.get_lists()` which +returns a Python list of Sharepoint list objects. A known list can be accessed +by providing a :code:`list_name` to :code:`.get_list_by_name('list_name')` which will return +the requested list as a :code:`sharepointlist` object. + +.. code-block:: python + + #Return a list of sharepoint lists + sp_site_lists = sp_site.get_lists() + + #Return a specific list by name + sp_list = sp_site.get_list_by_name('list_name') + + +Commmon functions on a Sharepoint list include :code:`.get_list_columns()`, +:code:`.get_items()`, :code:`.get_item_by_id()`, :code:`.create_list_item()`, +:code:`.delete_list_item()`. + + +Sharepoint List Items +""""""""""""""""""""" + +Accessing a list item from a Sharepoint list is done by utilizing :code:`.get_items()`, +or :code:`.get_item_by_id(item_id)`. + +.. code-block:: python + + #Return a list of sharepoint list Items + sp_list_items = sp_list.get_items() + + #Return a specific sharepoint list item by its object ID + sp_list_item = sp_list.get_item_by_id(item_id) + + +**Creating & Deleting Sharepoint Items** + +A Sharepoint list item can be created by passing the new data in a dictionary +consisting of :code:`{'column_name': 'new_data'}`. Not all columns in the Sharepoint list have to +be accounted for in the dictionary, any Sharepoint List column not in the dictionary +will be filled with a blank. The `column_name` must be the internal column name +of the sharepoint list. :code:`.column_name_cw` of a sharepoint list will provide a +dictionary of :code:`{'Display Name': 'Internal Name'}` if needed. + +.. code-block:: python + + #Create a new sharepoint list item + new_item = sp_list.create_list_item({'col1': 'New Data Col 1', + 'col2': 'New Data Col 2'}) + + #Delete the item just created + sp_list.delete_list_item(new_item.object_id) #Pass the item ID to be deleted + +**Updating a Sharepoint List Item** + +Sharepoint list items can be updated by passing a dictionary of +:code:`{'column_name': 'Updated Data'}` to the :code:`.update_fields()` function of a +Sharepoint list item. The `column_name` keys of the dictionary must again refer +to the internal column name, otherwise an error will occur. + +.. code-block:: python + + #Update a Sharepoint List item + new_item.update_fields({'col1': 'Updated Data Col1', + 'col2': 'Updated Data Col2'}) + + #Once done updating a sharepoint item save changes to the cloud + new_item.save_updates() #Returns True if successful diff --git a/docs/latest/_sources/usage/subscriptions.rst.txt b/docs/latest/_sources/usage/subscriptions.rst.txt new file mode 100644 index 00000000..5f507032 --- /dev/null +++ b/docs/latest/_sources/usage/subscriptions.rst.txt @@ -0,0 +1,218 @@ +Subscriptions +============= + +Subscriptions provides the ability to create and manage webhook subscriptions for change notifications against Microsoft Graph. Read here for more details on MS Graph subscriptions + +- https://learn.microsoft.com/en-us/graph/api/resources/subscription?view=graph-rest-1.0 +- https://learn.microsoft.com/en-us/graph/change-notifications-delivery-webhooks?tabs=http + +Create a Subscription +^^^^^^^^^^^^^^^^^^^^^ + +Assuming a web host (example uses `flask`) and an authenticated account, create a subscription to be notified about new emails. + +.. code-block:: python + + from flask import Flask, abort, jsonify, request + + RESOURCE = "/me/mailFolders('inbox')/messages" + DEFAULT_EXPIRATION_MINUTES = 10069 # Maximum expiration is 10,070 in the future for Outlook message. + + app = Flask(__name__) + + @app.get("/subscriptions") + def create_subscription(): + """Create a subscription.""" + notification_url = request.args.get("notification_url") + if not notification_url: + abort(400, description="notification_url is required") + + expiration_minutes = int(request.args.get("expiration_minutes", DEFAULT_EXPIRATION_MINUTES)) + client_state = request.args.get("client_state") + resource = request.args.get("resource", RESOURCE) + + subscription = account.subscriptions().create_subscription( + notification_url=notification_url, + resource=resource, + change_type="created", + expiration_minutes=expiration_minutes, + client_state=client_state, + ) + return jsonify(subscription), 201 + + @app.post("/webhook") + def webhook_handler(): + """Handle Microsoft Graph webhook calls. + + - During subscription validation, Graph sends POST with ?validationToken=... . + We must echo the token as plain text within 10 seconds. + - For change notifications, Graph posts JSON; we just log/ack. + """ + validation_token = request.args.get("validationToken") + if validation_token: + # Echo back token exactly as plain text with HTTP 200. + return validation_token, 200, {"Content-Type": "text/plain"} + + # Change notifications: inspect or log as needed. + payload = request.get_json(silent=True) or {} + print("Received notification payload:", payload) + return ("", 202) + +Use this url: + + ``https:///subscriptions?notification_url=https%3A%2F%2F%2Fwebhook&client_state=abc123`` + +HTTP status 201 and the following should be returned: + +.. code-block:: JSON + + { + "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#subscriptions/$entity", + "applicationId": "12345678-bad9-4c34-94d6-f9a1388522f8", + "changeType": "created", + "clientState": "abc123", + "creatorId": "12345678-a5c7-46da-8107-b25090a1ed66", + "encryptionCertificate": null, + "encryptionCertificateId": null, + "expirationDateTime": "2026-01-07T11:20:42.305776Z", + "id": "548355f8-c2c0-47ae-aac7-3ad02b2dfdb1", + "includeResourceData": null, + "latestSupportedTlsVersion": "v1_2", + "lifecycleNotificationUrl": null, + "notificationQueryOptions": null, + "notificationUrl": "https:///webhook", + "notificationUrlAppId": null, + "resource": "/me/mailFolders('inbox')/messages" + } + +List Subscriptions +^^^^^^^^^^^^^^^^^^ + +.. code-block:: python + + @app.get("/subscriptions/list") + def list_subscriptions(): + """List all subscriptions.""" + limit = int(request.args.get("limit")) + subscriptions = account.subscriptions().list_subscriptions(limit=limit) + return jsonify(list(subscriptions)), 200 + +Use this url: + + ``https:///subscriptions/list`` + +HTTP status 200 and the following should be returned: + +.. code-block:: JSON + + [ + { + "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#subscriptions/$entity", + "applicationId": "12345678-bad9-4c34-94d6-f9a1388522f8", + "changeType": "created", + "clientState": "abc123", + "creatorId": "12345678-a5c7-46da-8107-b25090a1ed66", + "encryptionCertificate": null, + "encryptionCertificateId": null, + "expirationDateTime": "2026-01-07T11:20:42.305776Z", + "id": "548355f8-c2c0-47ae-aac7-3ad02b2dfdb1", + "includeResourceData": null, + "latestSupportedTlsVersion": "v1_2", + "lifecycleNotificationUrl": null, + "notificationQueryOptions": null, + "notificationUrl": "https:///webhook", + "notificationUrlAppId": null, + "resource": "/me/mailFolders('inbox')/messages" + } + ] + +Renew a Subscription +^^^^^^^^^^^^^^^^^^^^ + +.. code-block:: python + + @app.get("/subscriptions//renew") + def renew_subscription(subscription_id: str): + """Renew a subscription.""" + expiration_minutes = int(request.args.get("expiration_minutes", DEFAULT_EXPIRATION_MINUTES)) + updated = account.subscriptions().renew_subscription( + subscription_id, + expiration_minutes=expiration_minutes, + ) + return jsonify(updated), 200 + +Use this url: + + ``http:///subscriptions/548355f8-c2c0-47ae-aac7-3ad02b2dfdb1/renew?expiration_minutes=10069`` + +HTTP status 200 and the following should be returned: + +.. code-block:: JSON + + { + "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#subscriptions/$entity", + "applicationId": "12345678-bad9-4c34-94d6-f9a1388522f8", + "changeType": "created", + "clientState": "abc123", + "creatorId": "12345678-a5c7-46da-8107-b25090a1ed66", + "encryptionCertificate": null, + "encryptionCertificateId": null, + "expirationDateTime": "2026-01-07T11:35:40.301594Z", + "id": "548355f8-c2c0-47ae-aac7-3ad02b2dfdb1", + "includeResourceData": null, + "latestSupportedTlsVersion": "v1_2", + "lifecycleNotificationUrl": null, + "notificationQueryOptions": null, + "notificationUrl": "https:///webhook", + "notificationUrlAppId": null, + "resource": "/me/mailFolders('inbox')/messages" + } + +Delete a Subscription +^^^^^^^^^^^^^^^^^^^^^ + +.. code-block:: python + + @app.get("/subscriptions//delete") + def delete_subscription(subscription_id: str): + """Delete a subscription.""" + deleted = account.subscriptions().delete_subscription(subscription_id) + if not deleted: + abort(404, description="Subscription not found") + return ("", 204) + +Use this url: + + ``http:///subscriptions/548355f8-c2c0-47ae-aac7-3ad02b2dfdb1/delete`` + +HTTP status 204 should be returned. + +Webhook +^^^^^^^ + +With a subscription as described above and an email sent to the inbox, a webhook will be received as below: + +.. code-block:: python + + { + 'value': [ + { + 'subscriptionId': '548355f8-c2c0-47ae-aac7-3ad02b2dfdb12', + 'subscriptionExpirationDateTime': '2026-01-07T11:35:40.301594+00:00', + 'changeType': 'created', + 'resource': 'Users/12345678-a5c7-46da-8107-b25090a1ed66/Messages/=', + 'resourceData': { + '@odata.type': '#Microsoft.Graph.Message', + '@odata.id': 'Users/12345678-a5c7-46da-8107-b25090a1ed66/Messages/=', + '@odata.etag': 'W/"CQAAABYACCCoiRErLbiNRJDCFyMjq4khBBnH4N7A"', + 'id': '=' + }, + 'clientState': 'abc123', + 'tenantId': '12345678-abcd-1234-abcd-1234567890ab' + } + ] + } + +The client state should be validated for accuracy and if correct, the message can be acted upon as approriate for the type of subscription. + +An example application can be found in the examples directory here - https://github.com/O365/python-o365/blob/master/examples/subscriptions_example.py \ No newline at end of file diff --git a/docs/latest/_sources/usage/tasks.rst.txt b/docs/latest/_sources/usage/tasks.rst.txt new file mode 100644 index 00000000..9d6856fb --- /dev/null +++ b/docs/latest/_sources/usage/tasks.rst.txt @@ -0,0 +1,57 @@ +Tasks +===== +The tasks functionality is grouped in a ToDo object. + +A ToDo instance can list and create task folders. It can also list or create tasks on the default user folder. To use other folders use a Folder instance. + +These are the scopes needed to work with the ToDo, Folder and Task classes. + +========================= ======================================= ====================================== +Raw Scope Included in Scope Helper Description +========================= ======================================= ====================================== +Tasks.Read tasks To only read my personal tasks +Tasks.ReadWrite tasks_all To read and save personal calendars +========================= ======================================= ====================================== + +Working with the `ToDo`` instance: + +.. code-block:: python + + import datetime as dt + + # ... + todo = account.tasks() + + #list current tasks + folder = todo.get_default_folder() + new_task = folder.new_task() # creates a new unsaved task + new_task.subject = 'Send contract to George Best' + new_task.due = dt.datetime(2020, 9, 25, 18, 30) + new_task.save() + + #some time later.... + + new_task.mark_completed() + new_task.save() + + # naive datetimes will automatically be converted to timezone aware datetime + # objects using the local timezone detected or the protocol provided timezone + # as with the Calendar functionality + +Working with Folder instances: + +.. code-block:: python + + #create a new folder + new_folder = todo.new_folder('Defenders') + + #rename a folder + folder = todo.get_folder(folder_name='Strikers') + folder.name = 'Forwards' + folder.update() + + #list current tasks + task_list = folder.get_tasks() + for task in task_list: + print(task) + print('') \ No newline at end of file diff --git a/docs/latest/_sources/usage/teams.rst.txt b/docs/latest/_sources/usage/teams.rst.txt new file mode 100644 index 00000000..97288b3d --- /dev/null +++ b/docs/latest/_sources/usage/teams.rst.txt @@ -0,0 +1,109 @@ +Teams +===== +Teams enables the communications via Teams Chat, plus Presence management + +These are the scopes needed to work with the ``Teams`` classes. + +========================= ======================================= ====================================== +Raw Scope Included in Scope Helper Description +========================= ======================================= ====================================== +Channel.ReadBasic.All — To read basic channel information +ChannelMessage.Read.All — To read channel messages +ChannelMessage.Send — To send messages to a channel +Chat.Read — To read users chat +Chat.ReadWrite — To read users chat and send chat messages +Presence.Read presence To read users presence status +Presence.Read.All — To read any users presence status +Presence.ReadWrite — To update users presence status +Team.ReadBasic.All — To read only the basic properties for all my teams +User.ReadBasic.All users To only read basic properties from users of my organization (User.Read.All requires administrator consent) +========================= ======================================= ====================================== + +Presence +-------- +Assuming an authenticated account. + +.. code-block:: python + + # Retrieve logged-in user's presence + from O365 import Account + account = Account(('app_id', 'app_pw')) + teams = account.teams() + presence = teams.get_my_presence() + + # Retrieve another user's presence + user = account.directory().get_user("john@doe.com") + presence2 = teams.get_user_presence(user.object_id) + +To set a users status or preferred status: + +.. code-block:: python + + # Set user's presence + from O365.teams import Activity, Availability, PreferredActivity, PreferredAvailability + + status = teams.set_my_presence(CLIENT_ID, Availability.BUSY, Activity.INACALL, "1H") + + # or set User's preferred presence (which is more likely the one you want) + + status = teams.set_my_user_preferred_presence(PreferredAvailability.OFFLINE, PreferredActivity.OFFWORK, "1H") + + +Chat +---- +Assuming an authenticated account. + +.. code-block:: python + + # Retrieve logged-in user's chats + from O365 import Account + account = Account(('app_id', 'app_pw')) + teams = account.teams() + chats = teams.get_my_chats() + + # Then to retrieve chat messages and chat members + for chat in chats: + if chat.chat_type != "unknownFutureValue": + message = chat.get_messages(limit=10) + memberlist = chat.get_members() + + + # And to send a chat message + + chat.send_message(content="Hello team!", content_type="text") + +| Common commands for :code:`Chat` include :code:`.get_member()` and :code:`.get_message()` + + +Team +---- +Assuming an authenticated account. + +.. code-block:: python + + # Retrieve logged-in user's teams + from O365 import Account + account = Account(('app_id', 'app_pw')) + teams = account.teams() + my_teams = teams.get_my_teams() + + # Then to retrieve team channels and messages + for team in my_teams: + channels = team.get_channels() + for channel in channels: + messages = channel.get_messages(limit=10) + for channelmessage in messages: + print(channelmessage) + + + # To send a message to a team channel + channel.send_message("Hello team") + + # To send a reply to a message + channelmessage.send_message("Hello team leader") + +| Common commands for :code:`Teams` include :code:`.create_channel()`, :code:`.get_apps_in_channel()` and :code:`.get_channel()` +| Common commands for :code:`Team` include :code:`.get_channel()` +| Common commands for :code:`Channel` include :code:`.get_message()` +| Common commands for :code:`ChannelMessage` include :code:`.get_replies()` and :code:`.get_reply()` + diff --git a/docs/latest/_sources/usage/utils.rst.txt b/docs/latest/_sources/usage/utils.rst.txt new file mode 100644 index 00000000..8c307e19 --- /dev/null +++ b/docs/latest/_sources/usage/utils.rst.txt @@ -0,0 +1,11 @@ +===== +Utils +===== + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + + utils/query + utils/token + utils/utils diff --git a/docs/latest/_sources/usage/utils/query.rst.txt b/docs/latest/_sources/usage/utils/query.rst.txt new file mode 100644 index 00000000..13d497b8 --- /dev/null +++ b/docs/latest/_sources/usage/utils/query.rst.txt @@ -0,0 +1,52 @@ +Query +===== + +.. _query_builder: + +Query Builder +------------- + +A query can be created for every ``ApiComponent`` (such as ``MailBox``). The ``Query`` can be used to handle the filtering, sorting, selecting, expanding and search very easily. + +For example: + +.. code-block:: python + + builder = mailbox.new_query() + + query = builder.chain_or(builder.contains('subject', 'george best'), builder.startswith('subject', 'quotes') + + # 'created_date_time' will automatically be converted to the protocol casing. + # For example when using MS Graph this will become 'createdDateTime'. + + query = query & builder.greater('created_date_time', datetime(2018, 3, 21)) + + print(query) + + # contains(subject, 'george best') or startswith(subject, 'quotes') and createdDateTime gt '2018-03-21T00:00:00Z' + # note you can pass naive datetimes and those will be converted to you local timezone and then send to the api as UTC in iso8601 format + + # To use Query objects just pass it to the query parameter: + filtered_messages = mailbox.get_messages(query=query) + +You can also specify specific data to be retrieved with "select": + +.. code-block:: python + + # select only some properties for the retrieved messages: + query = builder.select('subject', 'to_recipients', 'created_date_time) + + messages_with_selected_properties = mailbox.get_messages(query=query) + +You can also search content. As said in the graph docs: + + You can currently search only message and person collections. A $search request returns up to 250 results. You cannot use $filter or $orderby in a search request. + + If you do a search on messages and specify only a value without specific message properties, the search is carried out on the default search properties of from, subject, and body. + + .. code-block:: python + + # searching is the easy part ;) + query = builder.search('george best is da boss') + messages = mailbox.get_messages(query=query) + diff --git a/docs/latest/_sources/usage/utils/token.rst.txt b/docs/latest/_sources/usage/utils/token.rst.txt new file mode 100644 index 00000000..fffac541 --- /dev/null +++ b/docs/latest/_sources/usage/utils/token.rst.txt @@ -0,0 +1,34 @@ +Token +===== + +When initiating the account connection you may wish to store the token for ongoing usage, removing the need to re-authenticate every time. There are a variety of storage mechanisms available which are shown in the detailed api. + +FileSystemTokenBackend +---------------------- +To store the token in your local file system, you can use the ``FileSystemTokenBackend``. This takes a path and a file name as parameters. + +For example: + +.. code-block:: python + + from O365 import Account, FileSystemTokenBackend + + token_backend = FileSystemTokenBackend(token_path=token_path, token_filename=token_filename) + + account = Account(credentials=('my_client_id', 'my_client_secret'), token_backend=token_backend) + +The methods are similar for the other token backends. + +You can also pass in a cryptography manager to the token backend so encrypt the token in the store, and to decrypt on retrieval. The cryptography manager must support the ``encrypt`` and ``decrypt`` methods. + +.. code-block:: python + + from O365 import Account, FileSystemTokenBackend + from xxx import CryptoManager + + key = "my really secret key" + mycryptomanager = CryptoManager(key) + + token_backend = FileSystemTokenBackend(token_path=token_path, token_filename=token_filename, cryptography_manager=mycryptomanager) + + account = Account(credentials=('my_client_id', 'my_client_secret'), token_backend=token_backend) \ No newline at end of file diff --git a/docs/latest/_sources/usage/utils/utils.rst.txt b/docs/latest/_sources/usage/utils/utils.rst.txt new file mode 100644 index 00000000..5007b340 --- /dev/null +++ b/docs/latest/_sources/usage/utils/utils.rst.txt @@ -0,0 +1,95 @@ +Utils +===== +Pagination +---------- +When using certain methods, it is possible that you request more items than the api can return in a single api call. In this case the Api, returns a "next link" url where you can pull more data. + +When this is the case, the methods in this library will return a ``Pagination`` object which abstracts all this into a single iterator. The pagination object will request "next links" as soon as they are needed. + +For example: + +.. code-block:: python + + mailbox = account.mailbox() + + messages = mailbox.get_messages(limit=1500) # the MS Graph API have a 999 items limit returned per api call. + + # Here messages is a Pagination instance. It's an Iterator so you can iterate over. + + # The first 999 iterations will be normal list iterations, returning one item at a time. + # When the iterator reaches the 1000 item, the Pagination instance will call the api again requesting exactly 500 items + # or the items specified in the batch parameter (see later). + + for message in messages: + print(message.subject) + +When using certain methods you will have the option to specify not only a limit option (the number of items to be returned) but a batch option. This option will indicate the method to request data to the api in batches until the limit is reached or the data consumed. This is useful when you want to optimize memory or network latency. + +For example: + +.. code-block:: python + + messages = mailbox.get_messages(limit=100, batch=25) + + # messages here is a Pagination instance + # when iterating over it will call the api 4 times (each requesting 25 items). + + for message in messages: # 100 loops with 4 requests to the api server + print(message.subject) + +Query helper +------------ +.. note:: + + This method of creating queries is now deprecated, queries shoould now be created using the ExperimentalQuery methods - :ref:`query_builder` + +Every ``ApiComponent`` (such as ``MailBox``) implements a new_query method that will return a ``Query`` instance. This ``Query`` instance can handle the filtering, sorting, selecting, expanding and search very easily. + +For example: + +.. code-block:: python + + builder = mailbox.new_query() # you can use the shorthand: mailbox.q() + + query = builder.chain_or(builder.contains('subject', 'george best'), builder.startswith('subject', 'quotes') + + # 'created_date_time' will automatically be converted to the protocol casing. + # For example when using MS Graph this will become 'createdDateTime'. + + query = query & builder.greater('created_date_time', datetime(2018, 3, 21)) + + print(query) + + # contains(subject, 'george best') or startswith(subject, 'quotes') and createdDateTime gt '2018-03-21T00:00:00Z' + # note you can pass naive datetimes and those will be converted to you local timezone and then send to the api as UTC in iso8601 format + + # To use Query objetcs just pass it to the query parameter: + filtered_messages = mailbox.get_messages(query=query) + +You can also specify specific data to be retrieved with "select": + +.. code-block:: python + + # select only some properties for the retrieved messages: + query = mailbox.new_query().select('subject', 'to_recipients', 'created_date_time') + + messages_with_selected_properties = mailbox.get_messages(query=query) + +You can also search content. As said in the graph docs: + + You can currently search only message and person collections. A $search request returns up to 250 results. You cannot use $filter or $orderby in a search request. + + If you do a search on messages and specify only a value without specific message properties, the search is carried out on the default search properties of from, subject, and body. + + .. code-block:: python + + # searching is the easy part ;) + query = mailbox.q().search('george best is da boss') + messages = mailbox.get_messages(query=query) + +Request Error Handling +---------------------- +Whenever a Request error raises, the connection object will raise an exception. Then the exception will be captured and logged it to the stdout with its message, and return Falsy (None, False, [], etc...) + +HttpErrors 4xx (Bad Request) and 5xx (Internal Server Error) are considered exceptions and +raised also by the connection. You can tell the ``Connection`` to not raise http errors by passing ``raise_http_errors=False`` (defaults to True). \ No newline at end of file diff --git a/docs/latest/_static/_sphinx_javascript_frameworks_compat.js b/docs/latest/_static/_sphinx_javascript_frameworks_compat.js new file mode 100644 index 00000000..81415803 --- /dev/null +++ b/docs/latest/_static/_sphinx_javascript_frameworks_compat.js @@ -0,0 +1,123 @@ +/* Compatability shim for jQuery and underscores.js. + * + * Copyright Sphinx contributors + * Released under the two clause BSD licence + */ + +/** + * small helper function to urldecode strings + * + * See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/decodeURIComponent#Decoding_query_parameters_from_a_URL + */ +jQuery.urldecode = function(x) { + if (!x) { + return x + } + return decodeURIComponent(x.replace(/\+/g, ' ')); +}; + +/** + * small helper function to urlencode strings + */ +jQuery.urlencode = encodeURIComponent; + +/** + * This function returns the parsed url parameters of the + * current request. Multiple values per key are supported, + * it will always return arrays of strings for the value parts. + */ +jQuery.getQueryParameters = function(s) { + if (typeof s === 'undefined') + s = document.location.search; + var parts = s.substr(s.indexOf('?') + 1).split('&'); + var result = {}; + for (var i = 0; i < parts.length; i++) { + var tmp = parts[i].split('=', 2); + var key = jQuery.urldecode(tmp[0]); + var value = jQuery.urldecode(tmp[1]); + if (key in result) + result[key].push(value); + else + result[key] = [value]; + } + return result; +}; + +/** + * highlight a given string on a jquery object by wrapping it in + * span elements with the given class name. + */ +jQuery.fn.highlightText = function(text, className) { + function highlight(node, addItems) { + if (node.nodeType === 3) { + var val = node.nodeValue; + var pos = val.toLowerCase().indexOf(text); + if (pos >= 0 && + !jQuery(node.parentNode).hasClass(className) && + !jQuery(node.parentNode).hasClass("nohighlight")) { + var span; + var isInSVG = jQuery(node).closest("body, svg, foreignObject").is("svg"); + if (isInSVG) { + span = document.createElementNS("http://www.w3.org/2000/svg", "tspan"); + } else { + span = document.createElement("span"); + span.className = className; + } + span.appendChild(document.createTextNode(val.substr(pos, text.length))); + node.parentNode.insertBefore(span, node.parentNode.insertBefore( + document.createTextNode(val.substr(pos + text.length)), + node.nextSibling)); + node.nodeValue = val.substr(0, pos); + if (isInSVG) { + var rect = document.createElementNS("http://www.w3.org/2000/svg", "rect"); + var bbox = node.parentElement.getBBox(); + rect.x.baseVal.value = bbox.x; + rect.y.baseVal.value = bbox.y; + rect.width.baseVal.value = bbox.width; + rect.height.baseVal.value = bbox.height; + rect.setAttribute('class', className); + addItems.push({ + "parent": node.parentNode, + "target": rect}); + } + } + } + else if (!jQuery(node).is("button, select, textarea")) { + jQuery.each(node.childNodes, function() { + highlight(this, addItems); + }); + } + } + var addItems = []; + var result = this.each(function() { + highlight(this, addItems); + }); + for (var i = 0; i < addItems.length; ++i) { + jQuery(addItems[i].parent).before(addItems[i].target); + } + return result; +}; + +/* + * backward compatibility for jQuery.browser + * This will be supported until firefox bug is fixed. + */ +if (!jQuery.browser) { + jQuery.uaMatch = function(ua) { + ua = ua.toLowerCase(); + + var match = /(chrome)[ \/]([\w.]+)/.exec(ua) || + /(webkit)[ \/]([\w.]+)/.exec(ua) || + /(opera)(?:.*version|)[ \/]([\w.]+)/.exec(ua) || + /(msie) ([\w.]+)/.exec(ua) || + ua.indexOf("compatible") < 0 && /(mozilla)(?:.*? rv:([\w.]+)|)/.exec(ua) || + []; + + return { + browser: match[ 1 ] || "", + version: match[ 2 ] || "0" + }; + }; + jQuery.browser = {}; + jQuery.browser[jQuery.uaMatch(navigator.userAgent).browser] = true; +} diff --git a/docs/latest/_static/base-stemmer.js b/docs/latest/_static/base-stemmer.js new file mode 100644 index 00000000..e6fa0c49 --- /dev/null +++ b/docs/latest/_static/base-stemmer.js @@ -0,0 +1,476 @@ +// @ts-check + +/**@constructor*/ +BaseStemmer = function() { + /** @protected */ + this.current = ''; + this.cursor = 0; + this.limit = 0; + this.limit_backward = 0; + this.bra = 0; + this.ket = 0; + + /** + * @param {string} value + */ + this.setCurrent = function(value) { + this.current = value; + this.cursor = 0; + this.limit = this.current.length; + this.limit_backward = 0; + this.bra = this.cursor; + this.ket = this.limit; + }; + + /** + * @return {string} + */ + this.getCurrent = function() { + return this.current; + }; + + /** + * @param {BaseStemmer} other + */ + this.copy_from = function(other) { + /** @protected */ + this.current = other.current; + this.cursor = other.cursor; + this.limit = other.limit; + this.limit_backward = other.limit_backward; + this.bra = other.bra; + this.ket = other.ket; + }; + + /** + * @param {number[]} s + * @param {number} min + * @param {number} max + * @return {boolean} + */ + this.in_grouping = function(s, min, max) { + /** @protected */ + if (this.cursor >= this.limit) return false; + var ch = this.current.charCodeAt(this.cursor); + if (ch > max || ch < min) return false; + ch -= min; + if ((s[ch >>> 3] & (0x1 << (ch & 0x7))) == 0) return false; + this.cursor++; + return true; + }; + + /** + * @param {number[]} s + * @param {number} min + * @param {number} max + * @return {boolean} + */ + this.go_in_grouping = function(s, min, max) { + /** @protected */ + while (this.cursor < this.limit) { + var ch = this.current.charCodeAt(this.cursor); + if (ch > max || ch < min) + return true; + ch -= min; + if ((s[ch >>> 3] & (0x1 << (ch & 0x7))) == 0) + return true; + this.cursor++; + } + return false; + }; + + /** + * @param {number[]} s + * @param {number} min + * @param {number} max + * @return {boolean} + */ + this.in_grouping_b = function(s, min, max) { + /** @protected */ + if (this.cursor <= this.limit_backward) return false; + var ch = this.current.charCodeAt(this.cursor - 1); + if (ch > max || ch < min) return false; + ch -= min; + if ((s[ch >>> 3] & (0x1 << (ch & 0x7))) == 0) return false; + this.cursor--; + return true; + }; + + /** + * @param {number[]} s + * @param {number} min + * @param {number} max + * @return {boolean} + */ + this.go_in_grouping_b = function(s, min, max) { + /** @protected */ + while (this.cursor > this.limit_backward) { + var ch = this.current.charCodeAt(this.cursor - 1); + if (ch > max || ch < min) return true; + ch -= min; + if ((s[ch >>> 3] & (0x1 << (ch & 0x7))) == 0) return true; + this.cursor--; + } + return false; + }; + + /** + * @param {number[]} s + * @param {number} min + * @param {number} max + * @return {boolean} + */ + this.out_grouping = function(s, min, max) { + /** @protected */ + if (this.cursor >= this.limit) return false; + var ch = this.current.charCodeAt(this.cursor); + if (ch > max || ch < min) { + this.cursor++; + return true; + } + ch -= min; + if ((s[ch >>> 3] & (0X1 << (ch & 0x7))) == 0) { + this.cursor++; + return true; + } + return false; + }; + + /** + * @param {number[]} s + * @param {number} min + * @param {number} max + * @return {boolean} + */ + this.go_out_grouping = function(s, min, max) { + /** @protected */ + while (this.cursor < this.limit) { + var ch = this.current.charCodeAt(this.cursor); + if (ch <= max && ch >= min) { + ch -= min; + if ((s[ch >>> 3] & (0X1 << (ch & 0x7))) != 0) { + return true; + } + } + this.cursor++; + } + return false; + }; + + /** + * @param {number[]} s + * @param {number} min + * @param {number} max + * @return {boolean} + */ + this.out_grouping_b = function(s, min, max) { + /** @protected */ + if (this.cursor <= this.limit_backward) return false; + var ch = this.current.charCodeAt(this.cursor - 1); + if (ch > max || ch < min) { + this.cursor--; + return true; + } + ch -= min; + if ((s[ch >>> 3] & (0x1 << (ch & 0x7))) == 0) { + this.cursor--; + return true; + } + return false; + }; + + /** + * @param {number[]} s + * @param {number} min + * @param {number} max + * @return {boolean} + */ + this.go_out_grouping_b = function(s, min, max) { + /** @protected */ + while (this.cursor > this.limit_backward) { + var ch = this.current.charCodeAt(this.cursor - 1); + if (ch <= max && ch >= min) { + ch -= min; + if ((s[ch >>> 3] & (0x1 << (ch & 0x7))) != 0) { + return true; + } + } + this.cursor--; + } + return false; + }; + + /** + * @param {string} s + * @return {boolean} + */ + this.eq_s = function(s) + { + /** @protected */ + if (this.limit - this.cursor < s.length) return false; + if (this.current.slice(this.cursor, this.cursor + s.length) != s) + { + return false; + } + this.cursor += s.length; + return true; + }; + + /** + * @param {string} s + * @return {boolean} + */ + this.eq_s_b = function(s) + { + /** @protected */ + if (this.cursor - this.limit_backward < s.length) return false; + if (this.current.slice(this.cursor - s.length, this.cursor) != s) + { + return false; + } + this.cursor -= s.length; + return true; + }; + + /** + * @param {Among[]} v + * @return {number} + */ + this.find_among = function(v) + { + /** @protected */ + var i = 0; + var j = v.length; + + var c = this.cursor; + var l = this.limit; + + var common_i = 0; + var common_j = 0; + + var first_key_inspected = false; + + while (true) + { + var k = i + ((j - i) >>> 1); + var diff = 0; + var common = common_i < common_j ? common_i : common_j; // smaller + // w[0]: string, w[1]: substring_i, w[2]: result, w[3]: function (optional) + var w = v[k]; + var i2; + for (i2 = common; i2 < w[0].length; i2++) + { + if (c + common == l) + { + diff = -1; + break; + } + diff = this.current.charCodeAt(c + common) - w[0].charCodeAt(i2); + if (diff != 0) break; + common++; + } + if (diff < 0) + { + j = k; + common_j = common; + } + else + { + i = k; + common_i = common; + } + if (j - i <= 1) + { + if (i > 0) break; // v->s has been inspected + if (j == i) break; // only one item in v + + // - but now we need to go round once more to get + // v->s inspected. This looks messy, but is actually + // the optimal approach. + + if (first_key_inspected) break; + first_key_inspected = true; + } + } + do { + var w = v[i]; + if (common_i >= w[0].length) + { + this.cursor = c + w[0].length; + if (w.length < 4) return w[2]; + var res = w[3](this); + this.cursor = c + w[0].length; + if (res) return w[2]; + } + i = w[1]; + } while (i >= 0); + return 0; + }; + + // find_among_b is for backwards processing. Same comments apply + /** + * @param {Among[]} v + * @return {number} + */ + this.find_among_b = function(v) + { + /** @protected */ + var i = 0; + var j = v.length + + var c = this.cursor; + var lb = this.limit_backward; + + var common_i = 0; + var common_j = 0; + + var first_key_inspected = false; + + while (true) + { + var k = i + ((j - i) >> 1); + var diff = 0; + var common = common_i < common_j ? common_i : common_j; + var w = v[k]; + var i2; + for (i2 = w[0].length - 1 - common; i2 >= 0; i2--) + { + if (c - common == lb) + { + diff = -1; + break; + } + diff = this.current.charCodeAt(c - 1 - common) - w[0].charCodeAt(i2); + if (diff != 0) break; + common++; + } + if (diff < 0) + { + j = k; + common_j = common; + } + else + { + i = k; + common_i = common; + } + if (j - i <= 1) + { + if (i > 0) break; + if (j == i) break; + if (first_key_inspected) break; + first_key_inspected = true; + } + } + do { + var w = v[i]; + if (common_i >= w[0].length) + { + this.cursor = c - w[0].length; + if (w.length < 4) return w[2]; + var res = w[3](this); + this.cursor = c - w[0].length; + if (res) return w[2]; + } + i = w[1]; + } while (i >= 0); + return 0; + }; + + /* to replace chars between c_bra and c_ket in this.current by the + * chars in s. + */ + /** + * @param {number} c_bra + * @param {number} c_ket + * @param {string} s + * @return {number} + */ + this.replace_s = function(c_bra, c_ket, s) + { + /** @protected */ + var adjustment = s.length - (c_ket - c_bra); + this.current = this.current.slice(0, c_bra) + s + this.current.slice(c_ket); + this.limit += adjustment; + if (this.cursor >= c_ket) this.cursor += adjustment; + else if (this.cursor > c_bra) this.cursor = c_bra; + return adjustment; + }; + + /** + * @return {boolean} + */ + this.slice_check = function() + { + /** @protected */ + if (this.bra < 0 || + this.bra > this.ket || + this.ket > this.limit || + this.limit > this.current.length) + { + return false; + } + return true; + }; + + /** + * @param {number} c_bra + * @return {boolean} + */ + this.slice_from = function(s) + { + /** @protected */ + var result = false; + if (this.slice_check()) + { + this.replace_s(this.bra, this.ket, s); + result = true; + } + return result; + }; + + /** + * @return {boolean} + */ + this.slice_del = function() + { + /** @protected */ + return this.slice_from(""); + }; + + /** + * @param {number} c_bra + * @param {number} c_ket + * @param {string} s + */ + this.insert = function(c_bra, c_ket, s) + { + /** @protected */ + var adjustment = this.replace_s(c_bra, c_ket, s); + if (c_bra <= this.bra) this.bra += adjustment; + if (c_bra <= this.ket) this.ket += adjustment; + }; + + /** + * @return {string} + */ + this.slice_to = function() + { + /** @protected */ + var result = ''; + if (this.slice_check()) + { + result = this.current.slice(this.bra, this.ket); + } + return result; + }; + + /** + * @return {string} + */ + this.assign_to = function() + { + /** @protected */ + return this.current.slice(0, this.limit); + }; +}; diff --git a/docs/latest/_static/basic.css b/docs/latest/_static/basic.css new file mode 100644 index 00000000..4738b2ed --- /dev/null +++ b/docs/latest/_static/basic.css @@ -0,0 +1,906 @@ +/* + * Sphinx stylesheet -- basic theme. + */ + +/* -- main layout ----------------------------------------------------------- */ + +div.clearer { + clear: both; +} + +div.section::after { + display: block; + content: ''; + clear: left; +} + +/* -- relbar ---------------------------------------------------------------- */ + +div.related { + width: 100%; + font-size: 90%; +} + +div.related h3 { + display: none; +} + +div.related ul { + margin: 0; + padding: 0 0 0 10px; + list-style: none; +} + +div.related li { + display: inline; +} + +div.related li.right { + float: right; + margin-right: 5px; +} + +/* -- sidebar --------------------------------------------------------------- */ + +div.sphinxsidebarwrapper { + padding: 10px 5px 0 10px; +} + +div.sphinxsidebar { + float: left; + width: 230px; + margin-left: -100%; + font-size: 90%; + word-wrap: break-word; + overflow-wrap : break-word; +} + +div.sphinxsidebar ul { + list-style: none; +} + +div.sphinxsidebar ul ul, +div.sphinxsidebar ul.want-points { + margin-left: 20px; + list-style: square; +} + +div.sphinxsidebar ul ul { + margin-top: 0; + margin-bottom: 0; +} + +div.sphinxsidebar form { + margin-top: 10px; +} + +div.sphinxsidebar input { + border: 1px solid #98dbcc; + font-family: sans-serif; + font-size: 1em; +} + +div.sphinxsidebar #searchbox form.search { + overflow: hidden; +} + +div.sphinxsidebar #searchbox input[type="text"] { + float: left; + width: 80%; + padding: 0.25em; + box-sizing: border-box; +} + +div.sphinxsidebar #searchbox input[type="submit"] { + float: left; + width: 20%; + border-left: none; + padding: 0.25em; + box-sizing: border-box; +} + + +img { + border: 0; + max-width: 100%; +} + +/* -- search page ----------------------------------------------------------- */ + +ul.search { + margin-top: 10px; +} + +ul.search li { + padding: 5px 0; +} + +ul.search li a { + font-weight: bold; +} + +ul.search li p.context { + color: #888; + margin: 2px 0 0 30px; + text-align: left; +} + +ul.keywordmatches li.goodmatch a { + font-weight: bold; +} + +/* -- index page ------------------------------------------------------------ */ + +table.contentstable { + width: 90%; + margin-left: auto; + margin-right: auto; +} + +table.contentstable p.biglink { + line-height: 150%; +} + +a.biglink { + font-size: 1.3em; +} + +span.linkdescr { + font-style: italic; + padding-top: 5px; + font-size: 90%; +} + +/* -- general index --------------------------------------------------------- */ + +table.indextable { + width: 100%; +} + +table.indextable td { + text-align: left; + vertical-align: top; +} + +table.indextable ul { + margin-top: 0; + margin-bottom: 0; + list-style-type: none; +} + +table.indextable > tbody > tr > td > ul { + padding-left: 0em; +} + +table.indextable tr.pcap { + height: 10px; +} + +table.indextable tr.cap { + margin-top: 10px; + background-color: #f2f2f2; +} + +img.toggler { + margin-right: 3px; + margin-top: 3px; + cursor: pointer; +} + +div.modindex-jumpbox { + border-top: 1px solid #ddd; + border-bottom: 1px solid #ddd; + margin: 1em 0 1em 0; + padding: 0.4em; +} + +div.genindex-jumpbox { + border-top: 1px solid #ddd; + border-bottom: 1px solid #ddd; + margin: 1em 0 1em 0; + padding: 0.4em; +} + +/* -- domain module index --------------------------------------------------- */ + +table.modindextable td { + padding: 2px; + border-collapse: collapse; +} + +/* -- general body styles --------------------------------------------------- */ + +div.body { + min-width: 360px; + max-width: 800px; +} + +div.body p, div.body dd, div.body li, div.body blockquote { + -moz-hyphens: auto; + -ms-hyphens: auto; + -webkit-hyphens: auto; + hyphens: auto; +} + +a.headerlink { + visibility: hidden; +} + +a:visited { + color: #551A8B; +} + +h1:hover > a.headerlink, +h2:hover > a.headerlink, +h3:hover > a.headerlink, +h4:hover > a.headerlink, +h5:hover > a.headerlink, +h6:hover > a.headerlink, +dt:hover > a.headerlink, +caption:hover > a.headerlink, +p.caption:hover > a.headerlink, +div.code-block-caption:hover > a.headerlink { + visibility: visible; +} + +div.body p.caption { + text-align: inherit; +} + +div.body td { + text-align: left; +} + +.first { + margin-top: 0 !important; +} + +p.rubric { + margin-top: 30px; + font-weight: bold; +} + +img.align-left, figure.align-left, .figure.align-left, object.align-left { + clear: left; + float: left; + margin-right: 1em; +} + +img.align-right, figure.align-right, .figure.align-right, object.align-right { + clear: right; + float: right; + margin-left: 1em; +} + +img.align-center, figure.align-center, .figure.align-center, object.align-center { + display: block; + margin-left: auto; + margin-right: auto; +} + +img.align-default, figure.align-default, .figure.align-default { + display: block; + margin-left: auto; + margin-right: auto; +} + +.align-left { + text-align: left; +} + +.align-center { + text-align: center; +} + +.align-default { + text-align: center; +} + +.align-right { + text-align: right; +} + +/* -- sidebars -------------------------------------------------------------- */ + +div.sidebar, +aside.sidebar { + margin: 0 0 0.5em 1em; + border: 1px solid #ddb; + padding: 7px; + background-color: #ffe; + width: 40%; + float: right; + clear: right; + overflow-x: auto; +} + +p.sidebar-title { + font-weight: bold; +} + +nav.contents, +aside.topic, +div.admonition, div.topic, blockquote { + clear: left; +} + +/* -- topics ---------------------------------------------------------------- */ + +nav.contents, +aside.topic, +div.topic { + border: 1px solid #ccc; + padding: 7px; + margin: 10px 0 10px 0; +} + +p.topic-title { + font-size: 1.1em; + font-weight: bold; + margin-top: 10px; +} + +/* -- admonitions ----------------------------------------------------------- */ + +div.admonition { + margin-top: 10px; + margin-bottom: 10px; + padding: 7px; +} + +div.admonition dt { + font-weight: bold; +} + +p.admonition-title { + margin: 0px 10px 5px 0px; + font-weight: bold; +} + +div.body p.centered { + text-align: center; + margin-top: 25px; +} + +/* -- content of sidebars/topics/admonitions -------------------------------- */ + +div.sidebar > :last-child, +aside.sidebar > :last-child, +nav.contents > :last-child, +aside.topic > :last-child, +div.topic > :last-child, +div.admonition > :last-child { + margin-bottom: 0; +} + +div.sidebar::after, +aside.sidebar::after, +nav.contents::after, +aside.topic::after, +div.topic::after, +div.admonition::after, +blockquote::after { + display: block; + content: ''; + clear: both; +} + +/* -- tables ---------------------------------------------------------------- */ + +table.docutils { + margin-top: 10px; + margin-bottom: 10px; + border: 0; + border-collapse: collapse; +} + +table.align-center { + margin-left: auto; + margin-right: auto; +} + +table.align-default { + margin-left: auto; + margin-right: auto; +} + +table caption span.caption-number { + font-style: italic; +} + +table caption span.caption-text { +} + +table.docutils td, table.docutils th { + padding: 1px 8px 1px 5px; + border-top: 0; + border-left: 0; + border-right: 0; + border-bottom: 1px solid #aaa; +} + +th { + text-align: left; + padding-right: 5px; +} + +table.citation { + border-left: solid 1px gray; + margin-left: 1px; +} + +table.citation td { + border-bottom: none; +} + +th > :first-child, +td > :first-child { + margin-top: 0px; +} + +th > :last-child, +td > :last-child { + margin-bottom: 0px; +} + +/* -- figures --------------------------------------------------------------- */ + +div.figure, figure { + margin: 0.5em; + padding: 0.5em; +} + +div.figure p.caption, figcaption { + padding: 0.3em; +} + +div.figure p.caption span.caption-number, +figcaption span.caption-number { + font-style: italic; +} + +div.figure p.caption span.caption-text, +figcaption span.caption-text { +} + +/* -- field list styles ----------------------------------------------------- */ + +table.field-list td, table.field-list th { + border: 0 !important; +} + +.field-list ul { + margin: 0; + padding-left: 1em; +} + +.field-list p { + margin: 0; +} + +.field-name { + -moz-hyphens: manual; + -ms-hyphens: manual; + -webkit-hyphens: manual; + hyphens: manual; +} + +/* -- hlist styles ---------------------------------------------------------- */ + +table.hlist { + margin: 1em 0; +} + +table.hlist td { + vertical-align: top; +} + +/* -- object description styles --------------------------------------------- */ + +.sig { + font-family: 'Consolas', 'Menlo', 'DejaVu Sans Mono', 'Bitstream Vera Sans Mono', monospace; +} + +.sig-name, code.descname { + background-color: transparent; + font-weight: bold; +} + +.sig-name { + font-size: 1.1em; +} + +code.descname { + font-size: 1.2em; +} + +.sig-prename, code.descclassname { + background-color: transparent; +} + +.optional { + font-size: 1.3em; +} + +.sig-paren { + font-size: larger; +} + +.sig-param.n { + font-style: italic; +} + +/* C++ specific styling */ + +.sig-inline.c-texpr, +.sig-inline.cpp-texpr { + font-family: unset; +} + +.sig.c .k, .sig.c .kt, +.sig.cpp .k, .sig.cpp .kt { + color: #0033B3; +} + +.sig.c .m, +.sig.cpp .m { + color: #1750EB; +} + +.sig.c .s, .sig.c .sc, +.sig.cpp .s, .sig.cpp .sc { + color: #067D17; +} + + +/* -- other body styles ----------------------------------------------------- */ + +ol.arabic { + list-style: decimal; +} + +ol.loweralpha { + list-style: lower-alpha; +} + +ol.upperalpha { + list-style: upper-alpha; +} + +ol.lowerroman { + list-style: lower-roman; +} + +ol.upperroman { + list-style: upper-roman; +} + +:not(li) > ol > li:first-child > :first-child, +:not(li) > ul > li:first-child > :first-child { + margin-top: 0px; +} + +:not(li) > ol > li:last-child > :last-child, +:not(li) > ul > li:last-child > :last-child { + margin-bottom: 0px; +} + +ol.simple ol p, +ol.simple ul p, +ul.simple ol p, +ul.simple ul p { + margin-top: 0; +} + +ol.simple > li:not(:first-child) > p, +ul.simple > li:not(:first-child) > p { + margin-top: 0; +} + +ol.simple p, +ul.simple p { + margin-bottom: 0; +} + +aside.footnote > span, +div.citation > span { + float: left; +} +aside.footnote > span:last-of-type, +div.citation > span:last-of-type { + padding-right: 0.5em; +} +aside.footnote > p { + margin-left: 2em; +} +div.citation > p { + margin-left: 4em; +} +aside.footnote > p:last-of-type, +div.citation > p:last-of-type { + margin-bottom: 0em; +} +aside.footnote > p:last-of-type:after, +div.citation > p:last-of-type:after { + content: ""; + clear: both; +} + +dl.field-list { + display: grid; + grid-template-columns: fit-content(30%) auto; +} + +dl.field-list > dt { + font-weight: bold; + word-break: break-word; + padding-left: 0.5em; + padding-right: 5px; +} + +dl.field-list > dd { + padding-left: 0.5em; + margin-top: 0em; + margin-left: 0em; + margin-bottom: 0em; +} + +dl { + margin-bottom: 15px; +} + +dd > :first-child { + margin-top: 0px; +} + +dd ul, dd table { + margin-bottom: 10px; +} + +dd { + margin-top: 3px; + margin-bottom: 10px; + margin-left: 30px; +} + +.sig dd { + margin-top: 0px; + margin-bottom: 0px; +} + +.sig dl { + margin-top: 0px; + margin-bottom: 0px; +} + +dl > dd:last-child, +dl > dd:last-child > :last-child { + margin-bottom: 0; +} + +dt:target, span.highlighted { + background-color: #fbe54e; +} + +rect.highlighted { + fill: #fbe54e; +} + +dl.glossary dt { + font-weight: bold; + font-size: 1.1em; +} + +.versionmodified { + font-style: italic; +} + +.system-message { + background-color: #fda; + padding: 5px; + border: 3px solid red; +} + +.footnote:target { + background-color: #ffa; +} + +.line-block { + display: block; + margin-top: 1em; + margin-bottom: 1em; +} + +.line-block .line-block { + margin-top: 0; + margin-bottom: 0; + margin-left: 1.5em; +} + +.guilabel, .menuselection { + font-family: sans-serif; +} + +.accelerator { + text-decoration: underline; +} + +.classifier { + font-style: oblique; +} + +.classifier:before { + font-style: normal; + margin: 0 0.5em; + content: ":"; + display: inline-block; +} + +abbr, acronym { + border-bottom: dotted 1px; + cursor: help; +} + +/* -- code displays --------------------------------------------------------- */ + +pre { + overflow: auto; + overflow-y: hidden; /* fixes display issues on Chrome browsers */ +} + +pre, div[class*="highlight-"] { + clear: both; +} + +span.pre { + -moz-hyphens: none; + -ms-hyphens: none; + -webkit-hyphens: none; + hyphens: none; + white-space: nowrap; +} + +div[class*="highlight-"] { + margin: 1em 0; +} + +td.linenos pre { + border: 0; + background-color: transparent; + color: #aaa; +} + +table.highlighttable { + display: block; +} + +table.highlighttable tbody { + display: block; +} + +table.highlighttable tr { + display: flex; +} + +table.highlighttable td { + margin: 0; + padding: 0; +} + +table.highlighttable td.linenos { + padding-right: 0.5em; +} + +table.highlighttable td.code { + flex: 1; + overflow: hidden; +} + +.highlight .hll { + display: block; +} + +div.highlight pre, +table.highlighttable pre { + margin: 0; +} + +div.code-block-caption + div { + margin-top: 0; +} + +div.code-block-caption { + margin-top: 1em; + padding: 2px 5px; + font-size: small; +} + +div.code-block-caption code { + background-color: transparent; +} + +table.highlighttable td.linenos, +span.linenos, +div.highlight span.gp { /* gp: Generic.Prompt */ + user-select: none; + -webkit-user-select: text; /* Safari fallback only */ + -webkit-user-select: none; /* Chrome/Safari */ + -moz-user-select: none; /* Firefox */ + -ms-user-select: none; /* IE10+ */ +} + +div.code-block-caption span.caption-number { + padding: 0.1em 0.3em; + font-style: italic; +} + +div.code-block-caption span.caption-text { +} + +div.literal-block-wrapper { + margin: 1em 0; +} + +code.xref, a code { + background-color: transparent; + font-weight: bold; +} + +h1 code, h2 code, h3 code, h4 code, h5 code, h6 code { + background-color: transparent; +} + +.viewcode-link { + float: right; +} + +.viewcode-back { + float: right; + font-family: sans-serif; +} + +div.viewcode-block:target { + margin: -1px -10px; + padding: 0 10px; +} + +/* -- math display ---------------------------------------------------------- */ + +img.math { + vertical-align: middle; +} + +div.body div.math p { + text-align: center; +} + +span.eqno { + float: right; +} + +span.eqno a.headerlink { + position: absolute; + z-index: 1; +} + +div.math:hover a.headerlink { + visibility: visible; +} + +/* -- printout stylesheet --------------------------------------------------- */ + +@media print { + div.document, + div.documentwrapper, + div.bodywrapper { + margin: 0 !important; + width: 100%; + } + + div.sphinxsidebar, + div.related, + div.footer, + #top-link { + display: none; + } +} \ No newline at end of file diff --git a/docs/latest/_static/css/badge_only.css b/docs/latest/_static/css/badge_only.css new file mode 100644 index 00000000..88ba55b9 --- /dev/null +++ b/docs/latest/_static/css/badge_only.css @@ -0,0 +1 @@ +.clearfix{*zoom:1}.clearfix:after,.clearfix:before{display:table;content:""}.clearfix:after{clear:both}@font-face{font-family:FontAwesome;font-style:normal;font-weight:400;src:url(fonts/fontawesome-webfont.eot?674f50d287a8c48dc19ba404d20fe713?#iefix) format("embedded-opentype"),url(fonts/fontawesome-webfont.woff2?af7ae505a9eed503f8b8e6982036873e) format("woff2"),url(fonts/fontawesome-webfont.woff?fee66e712a8a08eef5805a46892932ad) format("woff"),url(fonts/fontawesome-webfont.ttf?b06871f281fee6b241d60582ae9369b9) format("truetype"),url(fonts/fontawesome-webfont.svg?912ec66d7572ff821749319396470bde#FontAwesome) format("svg")}.fa:before{font-family:FontAwesome;font-style:normal;font-weight:400;line-height:1}.fa:before,a .fa{text-decoration:inherit}.fa:before,a .fa,li .fa{display:inline-block}li .fa-large:before{width:1.875em}ul.fas{list-style-type:none;margin-left:2em;text-indent:-.8em}ul.fas li .fa{width:.8em}ul.fas li .fa-large:before{vertical-align:baseline}.fa-book:before,.icon-book:before{content:"\f02d"}.fa-caret-down:before,.icon-caret-down:before{content:"\f0d7"}.fa-caret-up:before,.icon-caret-up:before{content:"\f0d8"}.fa-caret-left:before,.icon-caret-left:before{content:"\f0d9"}.fa-caret-right:before,.icon-caret-right:before{content:"\f0da"}.rst-versions{position:fixed;bottom:0;left:0;width:300px;color:#fcfcfc;background:#1f1d1d;font-family:Lato,proxima-nova,Helvetica Neue,Arial,sans-serif;z-index:400}.rst-versions a{color:#2980b9;text-decoration:none}.rst-versions .rst-badge-small{display:none}.rst-versions .rst-current-version{padding:12px;background-color:#272525;display:block;text-align:right;font-size:90%;cursor:pointer;color:#27ae60}.rst-versions .rst-current-version:after{clear:both;content:"";display:block}.rst-versions .rst-current-version .fa{color:#fcfcfc}.rst-versions .rst-current-version .fa-book,.rst-versions .rst-current-version .icon-book{float:left}.rst-versions .rst-current-version.rst-out-of-date{background-color:#e74c3c;color:#fff}.rst-versions .rst-current-version.rst-active-old-version{background-color:#f1c40f;color:#000}.rst-versions.shift-up{height:auto;max-height:100%;overflow-y:scroll}.rst-versions.shift-up .rst-other-versions{display:block}.rst-versions .rst-other-versions{font-size:90%;padding:12px;color:grey;display:none}.rst-versions .rst-other-versions hr{display:block;height:1px;border:0;margin:20px 0;padding:0;border-top:1px solid #413d3d}.rst-versions .rst-other-versions dd{display:inline-block;margin:0}.rst-versions .rst-other-versions dd a{display:inline-block;padding:6px;color:#fcfcfc}.rst-versions .rst-other-versions .rtd-current-item{font-weight:700}.rst-versions.rst-badge{width:auto;bottom:20px;right:20px;left:auto;border:none;max-width:300px;max-height:90%}.rst-versions.rst-badge .fa-book,.rst-versions.rst-badge .icon-book{float:none;line-height:30px}.rst-versions.rst-badge.shift-up .rst-current-version{text-align:right}.rst-versions.rst-badge.shift-up .rst-current-version .fa-book,.rst-versions.rst-badge.shift-up .rst-current-version .icon-book{float:left}.rst-versions.rst-badge>.rst-current-version{width:auto;height:30px;line-height:30px;padding:0 6px;display:block;text-align:center}@media screen and (max-width:768px){.rst-versions{width:85%;display:none}.rst-versions.shift{display:block}}#flyout-search-form{padding:6px} \ No newline at end of file diff --git a/docs/latest/_static/css/fonts/Roboto-Slab-Bold.woff b/docs/latest/_static/css/fonts/Roboto-Slab-Bold.woff new file mode 100644 index 00000000..6cb60000 Binary files /dev/null and b/docs/latest/_static/css/fonts/Roboto-Slab-Bold.woff differ diff --git a/docs/latest/_static/css/fonts/Roboto-Slab-Bold.woff2 b/docs/latest/_static/css/fonts/Roboto-Slab-Bold.woff2 new file mode 100644 index 00000000..7059e231 Binary files /dev/null and b/docs/latest/_static/css/fonts/Roboto-Slab-Bold.woff2 differ diff --git a/docs/latest/_static/css/fonts/Roboto-Slab-Regular.woff b/docs/latest/_static/css/fonts/Roboto-Slab-Regular.woff new file mode 100644 index 00000000..f815f63f Binary files /dev/null and b/docs/latest/_static/css/fonts/Roboto-Slab-Regular.woff differ diff --git a/docs/latest/_static/css/fonts/Roboto-Slab-Regular.woff2 b/docs/latest/_static/css/fonts/Roboto-Slab-Regular.woff2 new file mode 100644 index 00000000..f2c76e5b Binary files /dev/null and b/docs/latest/_static/css/fonts/Roboto-Slab-Regular.woff2 differ diff --git a/docs/latest/_static/css/fonts/fontawesome-webfont.eot b/docs/latest/_static/css/fonts/fontawesome-webfont.eot new file mode 100644 index 00000000..e9f60ca9 Binary files /dev/null and b/docs/latest/_static/css/fonts/fontawesome-webfont.eot differ diff --git a/docs/latest/_static/css/fonts/fontawesome-webfont.svg b/docs/latest/_static/css/fonts/fontawesome-webfont.svg new file mode 100644 index 00000000..855c845e --- /dev/null +++ b/docs/latest/_static/css/fonts/fontawesome-webfont.svg @@ -0,0 +1,2671 @@ + + + + +Created by FontForge 20120731 at Mon Oct 24 17:37:40 2016 + By ,,, +Copyright Dave Gandy 2016. All rights reserved. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/latest/_static/css/fonts/fontawesome-webfont.ttf b/docs/latest/_static/css/fonts/fontawesome-webfont.ttf new file mode 100644 index 00000000..35acda2f Binary files /dev/null and b/docs/latest/_static/css/fonts/fontawesome-webfont.ttf differ diff --git a/docs/latest/_static/css/fonts/fontawesome-webfont.woff b/docs/latest/_static/css/fonts/fontawesome-webfont.woff new file mode 100644 index 00000000..400014a4 Binary files /dev/null and b/docs/latest/_static/css/fonts/fontawesome-webfont.woff differ diff --git a/docs/latest/_static/css/fonts/fontawesome-webfont.woff2 b/docs/latest/_static/css/fonts/fontawesome-webfont.woff2 new file mode 100644 index 00000000..4d13fc60 Binary files /dev/null and b/docs/latest/_static/css/fonts/fontawesome-webfont.woff2 differ diff --git a/docs/latest/_static/css/fonts/lato-bold-italic.woff b/docs/latest/_static/css/fonts/lato-bold-italic.woff new file mode 100644 index 00000000..88ad05b9 Binary files /dev/null and b/docs/latest/_static/css/fonts/lato-bold-italic.woff differ diff --git a/docs/latest/_static/css/fonts/lato-bold-italic.woff2 b/docs/latest/_static/css/fonts/lato-bold-italic.woff2 new file mode 100644 index 00000000..c4e3d804 Binary files /dev/null and b/docs/latest/_static/css/fonts/lato-bold-italic.woff2 differ diff --git a/docs/latest/_static/css/fonts/lato-bold.woff b/docs/latest/_static/css/fonts/lato-bold.woff new file mode 100644 index 00000000..c6dff51f Binary files /dev/null and b/docs/latest/_static/css/fonts/lato-bold.woff differ diff --git a/docs/latest/_static/css/fonts/lato-bold.woff2 b/docs/latest/_static/css/fonts/lato-bold.woff2 new file mode 100644 index 00000000..bb195043 Binary files /dev/null and b/docs/latest/_static/css/fonts/lato-bold.woff2 differ diff --git a/docs/latest/_static/css/fonts/lato-normal-italic.woff b/docs/latest/_static/css/fonts/lato-normal-italic.woff new file mode 100644 index 00000000..76114bc0 Binary files /dev/null and b/docs/latest/_static/css/fonts/lato-normal-italic.woff differ diff --git a/docs/latest/_static/css/fonts/lato-normal-italic.woff2 b/docs/latest/_static/css/fonts/lato-normal-italic.woff2 new file mode 100644 index 00000000..3404f37e Binary files /dev/null and b/docs/latest/_static/css/fonts/lato-normal-italic.woff2 differ diff --git a/docs/latest/_static/css/fonts/lato-normal.woff b/docs/latest/_static/css/fonts/lato-normal.woff new file mode 100644 index 00000000..ae1307ff Binary files /dev/null and b/docs/latest/_static/css/fonts/lato-normal.woff differ diff --git a/docs/latest/_static/css/fonts/lato-normal.woff2 b/docs/latest/_static/css/fonts/lato-normal.woff2 new file mode 100644 index 00000000..3bf98433 Binary files /dev/null and b/docs/latest/_static/css/fonts/lato-normal.woff2 differ diff --git a/docs/latest/_static/css/style.css b/docs/latest/_static/css/style.css new file mode 100644 index 00000000..f4bf9abf --- /dev/null +++ b/docs/latest/_static/css/style.css @@ -0,0 +1,15 @@ +.wy-nav-content { + max-width: none; +} +/* override table no-wrap */ +.wy-table-responsive table td, .wy-table-responsive table th { + white-space: normal; +} + +/* +Fix for horizontal stacking weirdness in the RTD theme with Python properties: +https://github.com/readthedocs/sphinx_rtd_theme/issues/1301 +*/ +.py.property { + display: block !important; + } \ No newline at end of file diff --git a/docs/latest/_static/css/theme.css b/docs/latest/_static/css/theme.css new file mode 100644 index 00000000..a88467c1 --- /dev/null +++ b/docs/latest/_static/css/theme.css @@ -0,0 +1,4 @@ +html{box-sizing:border-box}*,:after,:before{box-sizing:inherit}article,aside,details,figcaption,figure,footer,header,hgroup,nav,section{display:block}audio,canvas,video{display:inline-block;*display:inline;*zoom:1}[hidden],audio:not([controls]){display:none}*{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}html{font-size:100%;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%}body{margin:0}a:active,a:hover{outline:0}abbr[title]{border-bottom:1px dotted}b,strong{font-weight:700}blockquote{margin:0}dfn{font-style:italic}ins{background:#ff9;text-decoration:none}ins,mark{color:#000}mark{background:#ff0;font-style:italic;font-weight:700}.rst-content code,.rst-content tt,code,kbd,pre,samp{font-family:monospace,serif;_font-family:courier new,monospace;font-size:1em}pre{white-space:pre}q{quotes:none}q:after,q:before{content:"";content:none}small{font-size:85%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sup{top:-.5em}sub{bottom:-.25em}dl,ol,ul{margin:0;padding:0;list-style:none;list-style-image:none}li{list-style:none}dd{margin:0}img{border:0;-ms-interpolation-mode:bicubic;vertical-align:middle;max-width:100%}svg:not(:root){overflow:hidden}figure,form{margin:0}label{cursor:pointer}button,input,select,textarea{font-size:100%;margin:0;vertical-align:baseline;*vertical-align:middle}button,input{line-height:normal}button,input[type=button],input[type=reset],input[type=submit]{cursor:pointer;-webkit-appearance:button;*overflow:visible}button[disabled],input[disabled]{cursor:default}input[type=search]{-webkit-appearance:textfield;-moz-box-sizing:content-box;-webkit-box-sizing:content-box;box-sizing:content-box}textarea{resize:vertical}table{border-collapse:collapse;border-spacing:0}td{vertical-align:top}.chromeframe{margin:.2em 0;background:#ccc;color:#000;padding:.2em 0}.ir{display:block;border:0;text-indent:-999em;overflow:hidden;background-color:transparent;background-repeat:no-repeat;text-align:left;direction:ltr;*line-height:0}.ir br{display:none}.hidden{display:none!important;visibility:hidden}.visuallyhidden{border:0;clip:rect(0 0 0 0);height:1px;margin:-1px;overflow:hidden;padding:0;position:absolute;width:1px}.visuallyhidden.focusable:active,.visuallyhidden.focusable:focus{clip:auto;height:auto;margin:0;overflow:visible;position:static;width:auto}.invisible{visibility:hidden}.relative{position:relative}big,small{font-size:100%}@media print{body,html,section{background:none!important}*{box-shadow:none!important;text-shadow:none!important;filter:none!important;-ms-filter:none!important}a,a:visited{text-decoration:underline}.ir a:after,a[href^="#"]:after,a[href^="javascript:"]:after{content:""}blockquote,pre{page-break-inside:avoid}thead{display:table-header-group}img,tr{page-break-inside:avoid}img{max-width:100%!important}@page{margin:.5cm}.rst-content .toctree-wrapper>p.caption,h2,h3,p{orphans:3;widows:3}.rst-content .toctree-wrapper>p.caption,h2,h3{page-break-after:avoid}}.btn,.fa:before,.icon:before,.rst-content .admonition,.rst-content .admonition-title:before,.rst-content .admonition-todo,.rst-content .attention,.rst-content .caution,.rst-content .code-block-caption .headerlink:before,.rst-content .danger,.rst-content .eqno .headerlink:before,.rst-content .error,.rst-content .hint,.rst-content .important,.rst-content .note,.rst-content .seealso,.rst-content .tip,.rst-content .warning,.rst-content code.download span:first-child:before,.rst-content dl dt .headerlink:before,.rst-content h1 .headerlink:before,.rst-content h2 .headerlink:before,.rst-content h3 .headerlink:before,.rst-content h4 .headerlink:before,.rst-content h5 .headerlink:before,.rst-content h6 .headerlink:before,.rst-content p.caption .headerlink:before,.rst-content p .headerlink:before,.rst-content table>caption .headerlink:before,.rst-content tt.download span:first-child:before,.wy-alert,.wy-dropdown .caret:before,.wy-inline-validate.wy-inline-validate-danger .wy-input-context:before,.wy-inline-validate.wy-inline-validate-info .wy-input-context:before,.wy-inline-validate.wy-inline-validate-success .wy-input-context:before,.wy-inline-validate.wy-inline-validate-warning .wy-input-context:before,.wy-menu-vertical li.current>a button.toctree-expand:before,.wy-menu-vertical li.on a button.toctree-expand:before,.wy-menu-vertical li button.toctree-expand:before,input[type=color],input[type=date],input[type=datetime-local],input[type=datetime],input[type=email],input[type=month],input[type=number],input[type=password],input[type=search],input[type=tel],input[type=text],input[type=time],input[type=url],input[type=week],select,textarea{-webkit-font-smoothing:antialiased}.clearfix{*zoom:1}.clearfix:after,.clearfix:before{display:table;content:""}.clearfix:after{clear:both}/*! + * Font Awesome 4.7.0 by @davegandy - http://fontawesome.io - @fontawesome + * License - http://fontawesome.io/license (Font: SIL OFL 1.1, CSS: MIT License) + */@font-face{font-family:FontAwesome;src:url(fonts/fontawesome-webfont.eot?674f50d287a8c48dc19ba404d20fe713);src:url(fonts/fontawesome-webfont.eot?674f50d287a8c48dc19ba404d20fe713?#iefix&v=4.7.0) format("embedded-opentype"),url(fonts/fontawesome-webfont.woff2?af7ae505a9eed503f8b8e6982036873e) format("woff2"),url(fonts/fontawesome-webfont.woff?fee66e712a8a08eef5805a46892932ad) format("woff"),url(fonts/fontawesome-webfont.ttf?b06871f281fee6b241d60582ae9369b9) format("truetype"),url(fonts/fontawesome-webfont.svg?912ec66d7572ff821749319396470bde#fontawesomeregular) format("svg");font-weight:400;font-style:normal}.fa,.icon,.rst-content .admonition-title,.rst-content .code-block-caption .headerlink,.rst-content .eqno .headerlink,.rst-content code.download span:first-child,.rst-content dl dt .headerlink,.rst-content h1 .headerlink,.rst-content h2 .headerlink,.rst-content h3 .headerlink,.rst-content h4 .headerlink,.rst-content h5 .headerlink,.rst-content h6 .headerlink,.rst-content p.caption .headerlink,.rst-content p .headerlink,.rst-content table>caption .headerlink,.rst-content tt.download span:first-child,.wy-menu-vertical li.current>a button.toctree-expand,.wy-menu-vertical li.on a button.toctree-expand,.wy-menu-vertical li button.toctree-expand{display:inline-block;font:normal normal normal 14px/1 FontAwesome;font-size:inherit;text-rendering:auto;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.fa-lg{font-size:1.33333em;line-height:.75em;vertical-align:-15%}.fa-2x{font-size:2em}.fa-3x{font-size:3em}.fa-4x{font-size:4em}.fa-5x{font-size:5em}.fa-fw{width:1.28571em;text-align:center}.fa-ul{padding-left:0;margin-left:2.14286em;list-style-type:none}.fa-ul>li{position:relative}.fa-li{position:absolute;left:-2.14286em;width:2.14286em;top:.14286em;text-align:center}.fa-li.fa-lg{left:-1.85714em}.fa-border{padding:.2em .25em .15em;border:.08em solid #eee;border-radius:.1em}.fa-pull-left{float:left}.fa-pull-right{float:right}.fa-pull-left.icon,.fa.fa-pull-left,.rst-content .code-block-caption .fa-pull-left.headerlink,.rst-content .eqno .fa-pull-left.headerlink,.rst-content .fa-pull-left.admonition-title,.rst-content code.download span.fa-pull-left:first-child,.rst-content dl dt .fa-pull-left.headerlink,.rst-content h1 .fa-pull-left.headerlink,.rst-content h2 .fa-pull-left.headerlink,.rst-content h3 .fa-pull-left.headerlink,.rst-content h4 .fa-pull-left.headerlink,.rst-content h5 .fa-pull-left.headerlink,.rst-content h6 .fa-pull-left.headerlink,.rst-content p .fa-pull-left.headerlink,.rst-content table>caption .fa-pull-left.headerlink,.rst-content tt.download span.fa-pull-left:first-child,.wy-menu-vertical li.current>a button.fa-pull-left.toctree-expand,.wy-menu-vertical li.on a button.fa-pull-left.toctree-expand,.wy-menu-vertical li button.fa-pull-left.toctree-expand{margin-right:.3em}.fa-pull-right.icon,.fa.fa-pull-right,.rst-content .code-block-caption .fa-pull-right.headerlink,.rst-content .eqno .fa-pull-right.headerlink,.rst-content .fa-pull-right.admonition-title,.rst-content code.download span.fa-pull-right:first-child,.rst-content dl dt .fa-pull-right.headerlink,.rst-content h1 .fa-pull-right.headerlink,.rst-content h2 .fa-pull-right.headerlink,.rst-content h3 .fa-pull-right.headerlink,.rst-content h4 .fa-pull-right.headerlink,.rst-content h5 .fa-pull-right.headerlink,.rst-content h6 .fa-pull-right.headerlink,.rst-content p .fa-pull-right.headerlink,.rst-content table>caption .fa-pull-right.headerlink,.rst-content tt.download span.fa-pull-right:first-child,.wy-menu-vertical li.current>a button.fa-pull-right.toctree-expand,.wy-menu-vertical li.on a button.fa-pull-right.toctree-expand,.wy-menu-vertical li button.fa-pull-right.toctree-expand{margin-left:.3em}.pull-right{float:right}.pull-left{float:left}.fa.pull-left,.pull-left.icon,.rst-content .code-block-caption .pull-left.headerlink,.rst-content .eqno .pull-left.headerlink,.rst-content .pull-left.admonition-title,.rst-content code.download span.pull-left:first-child,.rst-content dl dt .pull-left.headerlink,.rst-content h1 .pull-left.headerlink,.rst-content h2 .pull-left.headerlink,.rst-content h3 .pull-left.headerlink,.rst-content h4 .pull-left.headerlink,.rst-content h5 .pull-left.headerlink,.rst-content h6 .pull-left.headerlink,.rst-content p .pull-left.headerlink,.rst-content table>caption .pull-left.headerlink,.rst-content tt.download span.pull-left:first-child,.wy-menu-vertical li.current>a button.pull-left.toctree-expand,.wy-menu-vertical li.on a button.pull-left.toctree-expand,.wy-menu-vertical li button.pull-left.toctree-expand{margin-right:.3em}.fa.pull-right,.pull-right.icon,.rst-content .code-block-caption .pull-right.headerlink,.rst-content .eqno .pull-right.headerlink,.rst-content .pull-right.admonition-title,.rst-content code.download span.pull-right:first-child,.rst-content dl dt .pull-right.headerlink,.rst-content h1 .pull-right.headerlink,.rst-content h2 .pull-right.headerlink,.rst-content h3 .pull-right.headerlink,.rst-content h4 .pull-right.headerlink,.rst-content h5 .pull-right.headerlink,.rst-content h6 .pull-right.headerlink,.rst-content p .pull-right.headerlink,.rst-content table>caption .pull-right.headerlink,.rst-content tt.download span.pull-right:first-child,.wy-menu-vertical li.current>a button.pull-right.toctree-expand,.wy-menu-vertical li.on a button.pull-right.toctree-expand,.wy-menu-vertical li button.pull-right.toctree-expand{margin-left:.3em}.fa-spin{-webkit-animation:fa-spin 2s linear infinite;animation:fa-spin 2s linear infinite}.fa-pulse{-webkit-animation:fa-spin 1s steps(8) infinite;animation:fa-spin 1s steps(8) infinite}@-webkit-keyframes fa-spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}to{-webkit-transform:rotate(359deg);transform:rotate(359deg)}}@keyframes fa-spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}to{-webkit-transform:rotate(359deg);transform:rotate(359deg)}}.fa-rotate-90{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=1)";-webkit-transform:rotate(90deg);-ms-transform:rotate(90deg);transform:rotate(90deg)}.fa-rotate-180{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=2)";-webkit-transform:rotate(180deg);-ms-transform:rotate(180deg);transform:rotate(180deg)}.fa-rotate-270{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=3)";-webkit-transform:rotate(270deg);-ms-transform:rotate(270deg);transform:rotate(270deg)}.fa-flip-horizontal{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=0, mirror=1)";-webkit-transform:scaleX(-1);-ms-transform:scaleX(-1);transform:scaleX(-1)}.fa-flip-vertical{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=2, mirror=1)";-webkit-transform:scaleY(-1);-ms-transform:scaleY(-1);transform:scaleY(-1)}:root .fa-flip-horizontal,:root .fa-flip-vertical,:root .fa-rotate-90,:root .fa-rotate-180,:root .fa-rotate-270{filter:none}.fa-stack{position:relative;display:inline-block;width:2em;height:2em;line-height:2em;vertical-align:middle}.fa-stack-1x,.fa-stack-2x{position:absolute;left:0;width:100%;text-align:center}.fa-stack-1x{line-height:inherit}.fa-stack-2x{font-size:2em}.fa-inverse{color:#fff}.fa-glass:before{content:""}.fa-music:before{content:""}.fa-search:before,.icon-search:before{content:""}.fa-envelope-o:before{content:""}.fa-heart:before{content:""}.fa-star:before{content:""}.fa-star-o:before{content:""}.fa-user:before{content:""}.fa-film:before{content:""}.fa-th-large:before{content:""}.fa-th:before{content:""}.fa-th-list:before{content:""}.fa-check:before{content:""}.fa-close:before,.fa-remove:before,.fa-times:before{content:""}.fa-search-plus:before{content:""}.fa-search-minus:before{content:""}.fa-power-off:before{content:""}.fa-signal:before{content:""}.fa-cog:before,.fa-gear:before{content:""}.fa-trash-o:before{content:""}.fa-home:before,.icon-home:before{content:""}.fa-file-o:before{content:""}.fa-clock-o:before{content:""}.fa-road:before{content:""}.fa-download:before,.rst-content code.download span:first-child:before,.rst-content tt.download span:first-child:before{content:""}.fa-arrow-circle-o-down:before{content:""}.fa-arrow-circle-o-up:before{content:""}.fa-inbox:before{content:""}.fa-play-circle-o:before{content:""}.fa-repeat:before,.fa-rotate-right:before{content:""}.fa-refresh:before{content:""}.fa-list-alt:before{content:""}.fa-lock:before{content:""}.fa-flag:before{content:""}.fa-headphones:before{content:""}.fa-volume-off:before{content:""}.fa-volume-down:before{content:""}.fa-volume-up:before{content:""}.fa-qrcode:before{content:""}.fa-barcode:before{content:""}.fa-tag:before{content:""}.fa-tags:before{content:""}.fa-book:before,.icon-book:before{content:""}.fa-bookmark:before{content:""}.fa-print:before{content:""}.fa-camera:before{content:""}.fa-font:before{content:""}.fa-bold:before{content:""}.fa-italic:before{content:""}.fa-text-height:before{content:""}.fa-text-width:before{content:""}.fa-align-left:before{content:""}.fa-align-center:before{content:""}.fa-align-right:before{content:""}.fa-align-justify:before{content:""}.fa-list:before{content:""}.fa-dedent:before,.fa-outdent:before{content:""}.fa-indent:before{content:""}.fa-video-camera:before{content:""}.fa-image:before,.fa-photo:before,.fa-picture-o:before{content:""}.fa-pencil:before{content:""}.fa-map-marker:before{content:""}.fa-adjust:before{content:""}.fa-tint:before{content:""}.fa-edit:before,.fa-pencil-square-o:before{content:""}.fa-share-square-o:before{content:""}.fa-check-square-o:before{content:""}.fa-arrows:before{content:""}.fa-step-backward:before{content:""}.fa-fast-backward:before{content:""}.fa-backward:before{content:""}.fa-play:before{content:""}.fa-pause:before{content:""}.fa-stop:before{content:""}.fa-forward:before{content:""}.fa-fast-forward:before{content:""}.fa-step-forward:before{content:""}.fa-eject:before{content:""}.fa-chevron-left:before{content:""}.fa-chevron-right:before{content:""}.fa-plus-circle:before{content:""}.fa-minus-circle:before{content:""}.fa-times-circle:before,.wy-inline-validate.wy-inline-validate-danger .wy-input-context:before{content:""}.fa-check-circle:before,.wy-inline-validate.wy-inline-validate-success .wy-input-context:before{content:""}.fa-question-circle:before{content:""}.fa-info-circle:before{content:""}.fa-crosshairs:before{content:""}.fa-times-circle-o:before{content:""}.fa-check-circle-o:before{content:""}.fa-ban:before{content:""}.fa-arrow-left:before{content:""}.fa-arrow-right:before{content:""}.fa-arrow-up:before{content:""}.fa-arrow-down:before{content:""}.fa-mail-forward:before,.fa-share:before{content:""}.fa-expand:before{content:""}.fa-compress:before{content:""}.fa-plus:before{content:""}.fa-minus:before{content:""}.fa-asterisk:before{content:""}.fa-exclamation-circle:before,.rst-content .admonition-title:before,.wy-inline-validate.wy-inline-validate-info .wy-input-context:before,.wy-inline-validate.wy-inline-validate-warning .wy-input-context:before{content:""}.fa-gift:before{content:""}.fa-leaf:before{content:""}.fa-fire:before,.icon-fire:before{content:""}.fa-eye:before{content:""}.fa-eye-slash:before{content:""}.fa-exclamation-triangle:before,.fa-warning:before{content:""}.fa-plane:before{content:""}.fa-calendar:before{content:""}.fa-random:before{content:""}.fa-comment:before{content:""}.fa-magnet:before{content:""}.fa-chevron-up:before{content:""}.fa-chevron-down:before{content:""}.fa-retweet:before{content:""}.fa-shopping-cart:before{content:""}.fa-folder:before{content:""}.fa-folder-open:before{content:""}.fa-arrows-v:before{content:""}.fa-arrows-h:before{content:""}.fa-bar-chart-o:before,.fa-bar-chart:before{content:""}.fa-twitter-square:before{content:""}.fa-facebook-square:before{content:""}.fa-camera-retro:before{content:""}.fa-key:before{content:""}.fa-cogs:before,.fa-gears:before{content:""}.fa-comments:before{content:""}.fa-thumbs-o-up:before{content:""}.fa-thumbs-o-down:before{content:""}.fa-star-half:before{content:""}.fa-heart-o:before{content:""}.fa-sign-out:before{content:""}.fa-linkedin-square:before{content:""}.fa-thumb-tack:before{content:""}.fa-external-link:before{content:""}.fa-sign-in:before{content:""}.fa-trophy:before{content:""}.fa-github-square:before{content:""}.fa-upload:before{content:""}.fa-lemon-o:before{content:""}.fa-phone:before{content:""}.fa-square-o:before{content:""}.fa-bookmark-o:before{content:""}.fa-phone-square:before{content:""}.fa-twitter:before{content:""}.fa-facebook-f:before,.fa-facebook:before{content:""}.fa-github:before,.icon-github:before{content:""}.fa-unlock:before{content:""}.fa-credit-card:before{content:""}.fa-feed:before,.fa-rss:before{content:""}.fa-hdd-o:before{content:""}.fa-bullhorn:before{content:""}.fa-bell:before{content:""}.fa-certificate:before{content:""}.fa-hand-o-right:before{content:""}.fa-hand-o-left:before{content:""}.fa-hand-o-up:before{content:""}.fa-hand-o-down:before{content:""}.fa-arrow-circle-left:before,.icon-circle-arrow-left:before{content:""}.fa-arrow-circle-right:before,.icon-circle-arrow-right:before{content:""}.fa-arrow-circle-up:before{content:""}.fa-arrow-circle-down:before{content:""}.fa-globe:before{content:""}.fa-wrench:before{content:""}.fa-tasks:before{content:""}.fa-filter:before{content:""}.fa-briefcase:before{content:""}.fa-arrows-alt:before{content:""}.fa-group:before,.fa-users:before{content:""}.fa-chain:before,.fa-link:before,.icon-link:before{content:""}.fa-cloud:before{content:""}.fa-flask:before{content:""}.fa-cut:before,.fa-scissors:before{content:""}.fa-copy:before,.fa-files-o:before{content:""}.fa-paperclip:before{content:""}.fa-floppy-o:before,.fa-save:before{content:""}.fa-square:before{content:""}.fa-bars:before,.fa-navicon:before,.fa-reorder:before{content:""}.fa-list-ul:before{content:""}.fa-list-ol:before{content:""}.fa-strikethrough:before{content:""}.fa-underline:before{content:""}.fa-table:before{content:""}.fa-magic:before{content:""}.fa-truck:before{content:""}.fa-pinterest:before{content:""}.fa-pinterest-square:before{content:""}.fa-google-plus-square:before{content:""}.fa-google-plus:before{content:""}.fa-money:before{content:""}.fa-caret-down:before,.icon-caret-down:before,.wy-dropdown .caret:before{content:""}.fa-caret-up:before{content:""}.fa-caret-left:before{content:""}.fa-caret-right:before{content:""}.fa-columns:before{content:""}.fa-sort:before,.fa-unsorted:before{content:""}.fa-sort-desc:before,.fa-sort-down:before{content:""}.fa-sort-asc:before,.fa-sort-up:before{content:""}.fa-envelope:before{content:""}.fa-linkedin:before{content:""}.fa-rotate-left:before,.fa-undo:before{content:""}.fa-gavel:before,.fa-legal:before{content:""}.fa-dashboard:before,.fa-tachometer:before{content:""}.fa-comment-o:before{content:""}.fa-comments-o:before{content:""}.fa-bolt:before,.fa-flash:before{content:""}.fa-sitemap:before{content:""}.fa-umbrella:before{content:""}.fa-clipboard:before,.fa-paste:before{content:""}.fa-lightbulb-o:before{content:""}.fa-exchange:before{content:""}.fa-cloud-download:before{content:""}.fa-cloud-upload:before{content:""}.fa-user-md:before{content:""}.fa-stethoscope:before{content:""}.fa-suitcase:before{content:""}.fa-bell-o:before{content:""}.fa-coffee:before{content:""}.fa-cutlery:before{content:""}.fa-file-text-o:before{content:""}.fa-building-o:before{content:""}.fa-hospital-o:before{content:""}.fa-ambulance:before{content:""}.fa-medkit:before{content:""}.fa-fighter-jet:before{content:""}.fa-beer:before{content:""}.fa-h-square:before{content:""}.fa-plus-square:before{content:""}.fa-angle-double-left:before{content:""}.fa-angle-double-right:before{content:""}.fa-angle-double-up:before{content:""}.fa-angle-double-down:before{content:""}.fa-angle-left:before{content:""}.fa-angle-right:before{content:""}.fa-angle-up:before{content:""}.fa-angle-down:before{content:""}.fa-desktop:before{content:""}.fa-laptop:before{content:""}.fa-tablet:before{content:""}.fa-mobile-phone:before,.fa-mobile:before{content:""}.fa-circle-o:before{content:""}.fa-quote-left:before{content:""}.fa-quote-right:before{content:""}.fa-spinner:before{content:""}.fa-circle:before{content:""}.fa-mail-reply:before,.fa-reply:before{content:""}.fa-github-alt:before{content:""}.fa-folder-o:before{content:""}.fa-folder-open-o:before{content:""}.fa-smile-o:before{content:""}.fa-frown-o:before{content:""}.fa-meh-o:before{content:""}.fa-gamepad:before{content:""}.fa-keyboard-o:before{content:""}.fa-flag-o:before{content:""}.fa-flag-checkered:before{content:""}.fa-terminal:before{content:""}.fa-code:before{content:""}.fa-mail-reply-all:before,.fa-reply-all:before{content:""}.fa-star-half-empty:before,.fa-star-half-full:before,.fa-star-half-o:before{content:""}.fa-location-arrow:before{content:""}.fa-crop:before{content:""}.fa-code-fork:before{content:""}.fa-chain-broken:before,.fa-unlink:before{content:""}.fa-question:before{content:""}.fa-info:before{content:""}.fa-exclamation:before{content:""}.fa-superscript:before{content:""}.fa-subscript:before{content:""}.fa-eraser:before{content:""}.fa-puzzle-piece:before{content:""}.fa-microphone:before{content:""}.fa-microphone-slash:before{content:""}.fa-shield:before{content:""}.fa-calendar-o:before{content:""}.fa-fire-extinguisher:before{content:""}.fa-rocket:before{content:""}.fa-maxcdn:before{content:""}.fa-chevron-circle-left:before{content:""}.fa-chevron-circle-right:before{content:""}.fa-chevron-circle-up:before{content:""}.fa-chevron-circle-down:before{content:""}.fa-html5:before{content:""}.fa-css3:before{content:""}.fa-anchor:before{content:""}.fa-unlock-alt:before{content:""}.fa-bullseye:before{content:""}.fa-ellipsis-h:before{content:""}.fa-ellipsis-v:before{content:""}.fa-rss-square:before{content:""}.fa-play-circle:before{content:""}.fa-ticket:before{content:""}.fa-minus-square:before{content:""}.fa-minus-square-o:before,.wy-menu-vertical li.current>a button.toctree-expand:before,.wy-menu-vertical li.on a button.toctree-expand:before{content:""}.fa-level-up:before{content:""}.fa-level-down:before{content:""}.fa-check-square:before{content:""}.fa-pencil-square:before{content:""}.fa-external-link-square:before{content:""}.fa-share-square:before{content:""}.fa-compass:before{content:""}.fa-caret-square-o-down:before,.fa-toggle-down:before{content:""}.fa-caret-square-o-up:before,.fa-toggle-up:before{content:""}.fa-caret-square-o-right:before,.fa-toggle-right:before{content:""}.fa-eur:before,.fa-euro:before{content:""}.fa-gbp:before{content:""}.fa-dollar:before,.fa-usd:before{content:""}.fa-inr:before,.fa-rupee:before{content:""}.fa-cny:before,.fa-jpy:before,.fa-rmb:before,.fa-yen:before{content:""}.fa-rouble:before,.fa-rub:before,.fa-ruble:before{content:""}.fa-krw:before,.fa-won:before{content:""}.fa-bitcoin:before,.fa-btc:before{content:""}.fa-file:before{content:""}.fa-file-text:before{content:""}.fa-sort-alpha-asc:before{content:""}.fa-sort-alpha-desc:before{content:""}.fa-sort-amount-asc:before{content:""}.fa-sort-amount-desc:before{content:""}.fa-sort-numeric-asc:before{content:""}.fa-sort-numeric-desc:before{content:""}.fa-thumbs-up:before{content:""}.fa-thumbs-down:before{content:""}.fa-youtube-square:before{content:""}.fa-youtube:before{content:""}.fa-xing:before{content:""}.fa-xing-square:before{content:""}.fa-youtube-play:before{content:""}.fa-dropbox:before{content:""}.fa-stack-overflow:before{content:""}.fa-instagram:before{content:""}.fa-flickr:before{content:""}.fa-adn:before{content:""}.fa-bitbucket:before,.icon-bitbucket:before{content:""}.fa-bitbucket-square:before{content:""}.fa-tumblr:before{content:""}.fa-tumblr-square:before{content:""}.fa-long-arrow-down:before{content:""}.fa-long-arrow-up:before{content:""}.fa-long-arrow-left:before{content:""}.fa-long-arrow-right:before{content:""}.fa-apple:before{content:""}.fa-windows:before{content:""}.fa-android:before{content:""}.fa-linux:before{content:""}.fa-dribbble:before{content:""}.fa-skype:before{content:""}.fa-foursquare:before{content:""}.fa-trello:before{content:""}.fa-female:before{content:""}.fa-male:before{content:""}.fa-gittip:before,.fa-gratipay:before{content:""}.fa-sun-o:before{content:""}.fa-moon-o:before{content:""}.fa-archive:before{content:""}.fa-bug:before{content:""}.fa-vk:before{content:""}.fa-weibo:before{content:""}.fa-renren:before{content:""}.fa-pagelines:before{content:""}.fa-stack-exchange:before{content:""}.fa-arrow-circle-o-right:before{content:""}.fa-arrow-circle-o-left:before{content:""}.fa-caret-square-o-left:before,.fa-toggle-left:before{content:""}.fa-dot-circle-o:before{content:""}.fa-wheelchair:before{content:""}.fa-vimeo-square:before{content:""}.fa-try:before,.fa-turkish-lira:before{content:""}.fa-plus-square-o:before,.wy-menu-vertical li button.toctree-expand:before{content:""}.fa-space-shuttle:before{content:""}.fa-slack:before{content:""}.fa-envelope-square:before{content:""}.fa-wordpress:before{content:""}.fa-openid:before{content:""}.fa-bank:before,.fa-institution:before,.fa-university:before{content:""}.fa-graduation-cap:before,.fa-mortar-board:before{content:""}.fa-yahoo:before{content:""}.fa-google:before{content:""}.fa-reddit:before{content:""}.fa-reddit-square:before{content:""}.fa-stumbleupon-circle:before{content:""}.fa-stumbleupon:before{content:""}.fa-delicious:before{content:""}.fa-digg:before{content:""}.fa-pied-piper-pp:before{content:""}.fa-pied-piper-alt:before{content:""}.fa-drupal:before{content:""}.fa-joomla:before{content:""}.fa-language:before{content:""}.fa-fax:before{content:""}.fa-building:before{content:""}.fa-child:before{content:""}.fa-paw:before{content:""}.fa-spoon:before{content:""}.fa-cube:before{content:""}.fa-cubes:before{content:""}.fa-behance:before{content:""}.fa-behance-square:before{content:""}.fa-steam:before{content:""}.fa-steam-square:before{content:""}.fa-recycle:before{content:""}.fa-automobile:before,.fa-car:before{content:""}.fa-cab:before,.fa-taxi:before{content:""}.fa-tree:before{content:""}.fa-spotify:before{content:""}.fa-deviantart:before{content:""}.fa-soundcloud:before{content:""}.fa-database:before{content:""}.fa-file-pdf-o:before{content:""}.fa-file-word-o:before{content:""}.fa-file-excel-o:before{content:""}.fa-file-powerpoint-o:before{content:""}.fa-file-image-o:before,.fa-file-photo-o:before,.fa-file-picture-o:before{content:""}.fa-file-archive-o:before,.fa-file-zip-o:before{content:""}.fa-file-audio-o:before,.fa-file-sound-o:before{content:""}.fa-file-movie-o:before,.fa-file-video-o:before{content:""}.fa-file-code-o:before{content:""}.fa-vine:before{content:""}.fa-codepen:before{content:""}.fa-jsfiddle:before{content:""}.fa-life-bouy:before,.fa-life-buoy:before,.fa-life-ring:before,.fa-life-saver:before,.fa-support:before{content:""}.fa-circle-o-notch:before{content:""}.fa-ra:before,.fa-rebel:before,.fa-resistance:before{content:""}.fa-empire:before,.fa-ge:before{content:""}.fa-git-square:before{content:""}.fa-git:before{content:""}.fa-hacker-news:before,.fa-y-combinator-square:before,.fa-yc-square:before{content:""}.fa-tencent-weibo:before{content:""}.fa-qq:before{content:""}.fa-wechat:before,.fa-weixin:before{content:""}.fa-paper-plane:before,.fa-send:before{content:""}.fa-paper-plane-o:before,.fa-send-o:before{content:""}.fa-history:before{content:""}.fa-circle-thin:before{content:""}.fa-header:before{content:""}.fa-paragraph:before{content:""}.fa-sliders:before{content:""}.fa-share-alt:before{content:""}.fa-share-alt-square:before{content:""}.fa-bomb:before{content:""}.fa-futbol-o:before,.fa-soccer-ball-o:before{content:""}.fa-tty:before{content:""}.fa-binoculars:before{content:""}.fa-plug:before{content:""}.fa-slideshare:before{content:""}.fa-twitch:before{content:""}.fa-yelp:before{content:""}.fa-newspaper-o:before{content:""}.fa-wifi:before{content:""}.fa-calculator:before{content:""}.fa-paypal:before{content:""}.fa-google-wallet:before{content:""}.fa-cc-visa:before{content:""}.fa-cc-mastercard:before{content:""}.fa-cc-discover:before{content:""}.fa-cc-amex:before{content:""}.fa-cc-paypal:before{content:""}.fa-cc-stripe:before{content:""}.fa-bell-slash:before{content:""}.fa-bell-slash-o:before{content:""}.fa-trash:before{content:""}.fa-copyright:before{content:""}.fa-at:before{content:""}.fa-eyedropper:before{content:""}.fa-paint-brush:before{content:""}.fa-birthday-cake:before{content:""}.fa-area-chart:before{content:""}.fa-pie-chart:before{content:""}.fa-line-chart:before{content:""}.fa-lastfm:before{content:""}.fa-lastfm-square:before{content:""}.fa-toggle-off:before{content:""}.fa-toggle-on:before{content:""}.fa-bicycle:before{content:""}.fa-bus:before{content:""}.fa-ioxhost:before{content:""}.fa-angellist:before{content:""}.fa-cc:before{content:""}.fa-ils:before,.fa-shekel:before,.fa-sheqel:before{content:""}.fa-meanpath:before{content:""}.fa-buysellads:before{content:""}.fa-connectdevelop:before{content:""}.fa-dashcube:before{content:""}.fa-forumbee:before{content:""}.fa-leanpub:before{content:""}.fa-sellsy:before{content:""}.fa-shirtsinbulk:before{content:""}.fa-simplybuilt:before{content:""}.fa-skyatlas:before{content:""}.fa-cart-plus:before{content:""}.fa-cart-arrow-down:before{content:""}.fa-diamond:before{content:""}.fa-ship:before{content:""}.fa-user-secret:before{content:""}.fa-motorcycle:before{content:""}.fa-street-view:before{content:""}.fa-heartbeat:before{content:""}.fa-venus:before{content:""}.fa-mars:before{content:""}.fa-mercury:before{content:""}.fa-intersex:before,.fa-transgender:before{content:""}.fa-transgender-alt:before{content:""}.fa-venus-double:before{content:""}.fa-mars-double:before{content:""}.fa-venus-mars:before{content:""}.fa-mars-stroke:before{content:""}.fa-mars-stroke-v:before{content:""}.fa-mars-stroke-h:before{content:""}.fa-neuter:before{content:""}.fa-genderless:before{content:""}.fa-facebook-official:before{content:""}.fa-pinterest-p:before{content:""}.fa-whatsapp:before{content:""}.fa-server:before{content:""}.fa-user-plus:before{content:""}.fa-user-times:before{content:""}.fa-bed:before,.fa-hotel:before{content:""}.fa-viacoin:before{content:""}.fa-train:before{content:""}.fa-subway:before{content:""}.fa-medium:before{content:""}.fa-y-combinator:before,.fa-yc:before{content:""}.fa-optin-monster:before{content:""}.fa-opencart:before{content:""}.fa-expeditedssl:before{content:""}.fa-battery-4:before,.fa-battery-full:before,.fa-battery:before{content:""}.fa-battery-3:before,.fa-battery-three-quarters:before{content:""}.fa-battery-2:before,.fa-battery-half:before{content:""}.fa-battery-1:before,.fa-battery-quarter:before{content:""}.fa-battery-0:before,.fa-battery-empty:before{content:""}.fa-mouse-pointer:before{content:""}.fa-i-cursor:before{content:""}.fa-object-group:before{content:""}.fa-object-ungroup:before{content:""}.fa-sticky-note:before{content:""}.fa-sticky-note-o:before{content:""}.fa-cc-jcb:before{content:""}.fa-cc-diners-club:before{content:""}.fa-clone:before{content:""}.fa-balance-scale:before{content:""}.fa-hourglass-o:before{content:""}.fa-hourglass-1:before,.fa-hourglass-start:before{content:""}.fa-hourglass-2:before,.fa-hourglass-half:before{content:""}.fa-hourglass-3:before,.fa-hourglass-end:before{content:""}.fa-hourglass:before{content:""}.fa-hand-grab-o:before,.fa-hand-rock-o:before{content:""}.fa-hand-paper-o:before,.fa-hand-stop-o:before{content:""}.fa-hand-scissors-o:before{content:""}.fa-hand-lizard-o:before{content:""}.fa-hand-spock-o:before{content:""}.fa-hand-pointer-o:before{content:""}.fa-hand-peace-o:before{content:""}.fa-trademark:before{content:""}.fa-registered:before{content:""}.fa-creative-commons:before{content:""}.fa-gg:before{content:""}.fa-gg-circle:before{content:""}.fa-tripadvisor:before{content:""}.fa-odnoklassniki:before{content:""}.fa-odnoklassniki-square:before{content:""}.fa-get-pocket:before{content:""}.fa-wikipedia-w:before{content:""}.fa-safari:before{content:""}.fa-chrome:before{content:""}.fa-firefox:before{content:""}.fa-opera:before{content:""}.fa-internet-explorer:before{content:""}.fa-television:before,.fa-tv:before{content:""}.fa-contao:before{content:""}.fa-500px:before{content:""}.fa-amazon:before{content:""}.fa-calendar-plus-o:before{content:""}.fa-calendar-minus-o:before{content:""}.fa-calendar-times-o:before{content:""}.fa-calendar-check-o:before{content:""}.fa-industry:before{content:""}.fa-map-pin:before{content:""}.fa-map-signs:before{content:""}.fa-map-o:before{content:""}.fa-map:before{content:""}.fa-commenting:before{content:""}.fa-commenting-o:before{content:""}.fa-houzz:before{content:""}.fa-vimeo:before{content:""}.fa-black-tie:before{content:""}.fa-fonticons:before{content:""}.fa-reddit-alien:before{content:""}.fa-edge:before{content:""}.fa-credit-card-alt:before{content:""}.fa-codiepie:before{content:""}.fa-modx:before{content:""}.fa-fort-awesome:before{content:""}.fa-usb:before{content:""}.fa-product-hunt:before{content:""}.fa-mixcloud:before{content:""}.fa-scribd:before{content:""}.fa-pause-circle:before{content:""}.fa-pause-circle-o:before{content:""}.fa-stop-circle:before{content:""}.fa-stop-circle-o:before{content:""}.fa-shopping-bag:before{content:""}.fa-shopping-basket:before{content:""}.fa-hashtag:before{content:""}.fa-bluetooth:before{content:""}.fa-bluetooth-b:before{content:""}.fa-percent:before{content:""}.fa-gitlab:before,.icon-gitlab:before{content:""}.fa-wpbeginner:before{content:""}.fa-wpforms:before{content:""}.fa-envira:before{content:""}.fa-universal-access:before{content:""}.fa-wheelchair-alt:before{content:""}.fa-question-circle-o:before{content:""}.fa-blind:before{content:""}.fa-audio-description:before{content:""}.fa-volume-control-phone:before{content:""}.fa-braille:before{content:""}.fa-assistive-listening-systems:before{content:""}.fa-american-sign-language-interpreting:before,.fa-asl-interpreting:before{content:""}.fa-deaf:before,.fa-deafness:before,.fa-hard-of-hearing:before{content:""}.fa-glide:before{content:""}.fa-glide-g:before{content:""}.fa-sign-language:before,.fa-signing:before{content:""}.fa-low-vision:before{content:""}.fa-viadeo:before{content:""}.fa-viadeo-square:before{content:""}.fa-snapchat:before{content:""}.fa-snapchat-ghost:before{content:""}.fa-snapchat-square:before{content:""}.fa-pied-piper:before{content:""}.fa-first-order:before{content:""}.fa-yoast:before{content:""}.fa-themeisle:before{content:""}.fa-google-plus-circle:before,.fa-google-plus-official:before{content:""}.fa-fa:before,.fa-font-awesome:before{content:""}.fa-handshake-o:before{content:""}.fa-envelope-open:before{content:""}.fa-envelope-open-o:before{content:""}.fa-linode:before{content:""}.fa-address-book:before{content:""}.fa-address-book-o:before{content:""}.fa-address-card:before,.fa-vcard:before{content:""}.fa-address-card-o:before,.fa-vcard-o:before{content:""}.fa-user-circle:before{content:""}.fa-user-circle-o:before{content:""}.fa-user-o:before{content:""}.fa-id-badge:before{content:""}.fa-drivers-license:before,.fa-id-card:before{content:""}.fa-drivers-license-o:before,.fa-id-card-o:before{content:""}.fa-quora:before{content:""}.fa-free-code-camp:before{content:""}.fa-telegram:before{content:""}.fa-thermometer-4:before,.fa-thermometer-full:before,.fa-thermometer:before{content:""}.fa-thermometer-3:before,.fa-thermometer-three-quarters:before{content:""}.fa-thermometer-2:before,.fa-thermometer-half:before{content:""}.fa-thermometer-1:before,.fa-thermometer-quarter:before{content:""}.fa-thermometer-0:before,.fa-thermometer-empty:before{content:""}.fa-shower:before{content:""}.fa-bath:before,.fa-bathtub:before,.fa-s15:before{content:""}.fa-podcast:before{content:""}.fa-window-maximize:before{content:""}.fa-window-minimize:before{content:""}.fa-window-restore:before{content:""}.fa-times-rectangle:before,.fa-window-close:before{content:""}.fa-times-rectangle-o:before,.fa-window-close-o:before{content:""}.fa-bandcamp:before{content:""}.fa-grav:before{content:""}.fa-etsy:before{content:""}.fa-imdb:before{content:""}.fa-ravelry:before{content:""}.fa-eercast:before{content:""}.fa-microchip:before{content:""}.fa-snowflake-o:before{content:""}.fa-superpowers:before{content:""}.fa-wpexplorer:before{content:""}.fa-meetup:before{content:""}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);border:0}.sr-only-focusable:active,.sr-only-focusable:focus{position:static;width:auto;height:auto;margin:0;overflow:visible;clip:auto}.fa,.icon,.rst-content .admonition-title,.rst-content .code-block-caption .headerlink,.rst-content .eqno .headerlink,.rst-content code.download span:first-child,.rst-content dl dt .headerlink,.rst-content h1 .headerlink,.rst-content h2 .headerlink,.rst-content h3 .headerlink,.rst-content h4 .headerlink,.rst-content h5 .headerlink,.rst-content h6 .headerlink,.rst-content p.caption .headerlink,.rst-content p .headerlink,.rst-content table>caption .headerlink,.rst-content tt.download span:first-child,.wy-dropdown .caret,.wy-inline-validate.wy-inline-validate-danger .wy-input-context,.wy-inline-validate.wy-inline-validate-info .wy-input-context,.wy-inline-validate.wy-inline-validate-success .wy-input-context,.wy-inline-validate.wy-inline-validate-warning .wy-input-context,.wy-menu-vertical li.current>a button.toctree-expand,.wy-menu-vertical li.on a button.toctree-expand,.wy-menu-vertical li button.toctree-expand{font-family:inherit}.fa:before,.icon:before,.rst-content .admonition-title:before,.rst-content .code-block-caption .headerlink:before,.rst-content .eqno .headerlink:before,.rst-content code.download span:first-child:before,.rst-content dl dt .headerlink:before,.rst-content h1 .headerlink:before,.rst-content h2 .headerlink:before,.rst-content h3 .headerlink:before,.rst-content h4 .headerlink:before,.rst-content h5 .headerlink:before,.rst-content h6 .headerlink:before,.rst-content p.caption .headerlink:before,.rst-content p .headerlink:before,.rst-content table>caption .headerlink:before,.rst-content tt.download span:first-child:before,.wy-dropdown .caret:before,.wy-inline-validate.wy-inline-validate-danger .wy-input-context:before,.wy-inline-validate.wy-inline-validate-info .wy-input-context:before,.wy-inline-validate.wy-inline-validate-success .wy-input-context:before,.wy-inline-validate.wy-inline-validate-warning .wy-input-context:before,.wy-menu-vertical li.current>a button.toctree-expand:before,.wy-menu-vertical li.on a button.toctree-expand:before,.wy-menu-vertical li button.toctree-expand:before{font-family:FontAwesome;display:inline-block;font-style:normal;font-weight:400;line-height:1;text-decoration:inherit}.rst-content .code-block-caption a .headerlink,.rst-content .eqno a .headerlink,.rst-content a .admonition-title,.rst-content code.download a span:first-child,.rst-content dl dt a .headerlink,.rst-content h1 a .headerlink,.rst-content h2 a .headerlink,.rst-content h3 a .headerlink,.rst-content h4 a .headerlink,.rst-content h5 a .headerlink,.rst-content h6 a .headerlink,.rst-content p.caption a .headerlink,.rst-content p a .headerlink,.rst-content table>caption a .headerlink,.rst-content tt.download a span:first-child,.wy-menu-vertical li.current>a button.toctree-expand,.wy-menu-vertical li.on a button.toctree-expand,.wy-menu-vertical li a button.toctree-expand,a .fa,a .icon,a .rst-content .admonition-title,a .rst-content .code-block-caption .headerlink,a .rst-content .eqno .headerlink,a .rst-content code.download span:first-child,a .rst-content dl dt .headerlink,a .rst-content h1 .headerlink,a .rst-content h2 .headerlink,a .rst-content h3 .headerlink,a .rst-content h4 .headerlink,a .rst-content h5 .headerlink,a .rst-content h6 .headerlink,a .rst-content p.caption .headerlink,a .rst-content p .headerlink,a .rst-content table>caption .headerlink,a .rst-content tt.download span:first-child,a .wy-menu-vertical li button.toctree-expand{display:inline-block;text-decoration:inherit}.btn .fa,.btn .icon,.btn .rst-content .admonition-title,.btn .rst-content .code-block-caption .headerlink,.btn .rst-content .eqno .headerlink,.btn .rst-content code.download span:first-child,.btn .rst-content dl dt .headerlink,.btn .rst-content h1 .headerlink,.btn .rst-content h2 .headerlink,.btn .rst-content h3 .headerlink,.btn .rst-content h4 .headerlink,.btn .rst-content h5 .headerlink,.btn .rst-content h6 .headerlink,.btn .rst-content p .headerlink,.btn .rst-content table>caption .headerlink,.btn .rst-content tt.download span:first-child,.btn .wy-menu-vertical li.current>a button.toctree-expand,.btn .wy-menu-vertical li.on a button.toctree-expand,.btn .wy-menu-vertical li button.toctree-expand,.nav .fa,.nav .icon,.nav .rst-content .admonition-title,.nav .rst-content .code-block-caption .headerlink,.nav .rst-content .eqno .headerlink,.nav .rst-content code.download span:first-child,.nav .rst-content dl dt .headerlink,.nav .rst-content h1 .headerlink,.nav .rst-content h2 .headerlink,.nav .rst-content h3 .headerlink,.nav .rst-content h4 .headerlink,.nav .rst-content h5 .headerlink,.nav .rst-content h6 .headerlink,.nav .rst-content p .headerlink,.nav .rst-content table>caption .headerlink,.nav .rst-content tt.download span:first-child,.nav .wy-menu-vertical li.current>a button.toctree-expand,.nav .wy-menu-vertical li.on a button.toctree-expand,.nav .wy-menu-vertical li button.toctree-expand,.rst-content .btn .admonition-title,.rst-content .code-block-caption .btn .headerlink,.rst-content .code-block-caption .nav .headerlink,.rst-content .eqno .btn .headerlink,.rst-content .eqno .nav .headerlink,.rst-content .nav .admonition-title,.rst-content code.download .btn span:first-child,.rst-content code.download .nav span:first-child,.rst-content dl dt .btn .headerlink,.rst-content dl dt .nav .headerlink,.rst-content h1 .btn .headerlink,.rst-content h1 .nav .headerlink,.rst-content h2 .btn .headerlink,.rst-content h2 .nav .headerlink,.rst-content h3 .btn .headerlink,.rst-content h3 .nav .headerlink,.rst-content h4 .btn .headerlink,.rst-content h4 .nav .headerlink,.rst-content h5 .btn .headerlink,.rst-content h5 .nav .headerlink,.rst-content h6 .btn .headerlink,.rst-content h6 .nav .headerlink,.rst-content p .btn .headerlink,.rst-content p .nav .headerlink,.rst-content table>caption .btn .headerlink,.rst-content table>caption .nav .headerlink,.rst-content tt.download .btn span:first-child,.rst-content tt.download .nav span:first-child,.wy-menu-vertical li .btn button.toctree-expand,.wy-menu-vertical li.current>a .btn button.toctree-expand,.wy-menu-vertical li.current>a .nav button.toctree-expand,.wy-menu-vertical li .nav button.toctree-expand,.wy-menu-vertical li.on a .btn button.toctree-expand,.wy-menu-vertical li.on a .nav button.toctree-expand{display:inline}.btn .fa-large.icon,.btn .fa.fa-large,.btn .rst-content .code-block-caption .fa-large.headerlink,.btn .rst-content .eqno .fa-large.headerlink,.btn .rst-content .fa-large.admonition-title,.btn .rst-content code.download span.fa-large:first-child,.btn .rst-content dl dt .fa-large.headerlink,.btn .rst-content h1 .fa-large.headerlink,.btn .rst-content h2 .fa-large.headerlink,.btn .rst-content h3 .fa-large.headerlink,.btn .rst-content h4 .fa-large.headerlink,.btn .rst-content h5 .fa-large.headerlink,.btn .rst-content h6 .fa-large.headerlink,.btn .rst-content p .fa-large.headerlink,.btn .rst-content table>caption .fa-large.headerlink,.btn .rst-content tt.download span.fa-large:first-child,.btn .wy-menu-vertical li button.fa-large.toctree-expand,.nav .fa-large.icon,.nav .fa.fa-large,.nav .rst-content .code-block-caption .fa-large.headerlink,.nav .rst-content .eqno .fa-large.headerlink,.nav .rst-content .fa-large.admonition-title,.nav .rst-content code.download span.fa-large:first-child,.nav .rst-content dl dt .fa-large.headerlink,.nav .rst-content h1 .fa-large.headerlink,.nav .rst-content h2 .fa-large.headerlink,.nav .rst-content h3 .fa-large.headerlink,.nav .rst-content h4 .fa-large.headerlink,.nav .rst-content h5 .fa-large.headerlink,.nav .rst-content h6 .fa-large.headerlink,.nav .rst-content p .fa-large.headerlink,.nav .rst-content table>caption .fa-large.headerlink,.nav .rst-content tt.download span.fa-large:first-child,.nav .wy-menu-vertical li button.fa-large.toctree-expand,.rst-content .btn .fa-large.admonition-title,.rst-content .code-block-caption .btn .fa-large.headerlink,.rst-content .code-block-caption .nav .fa-large.headerlink,.rst-content .eqno .btn .fa-large.headerlink,.rst-content .eqno .nav .fa-large.headerlink,.rst-content .nav .fa-large.admonition-title,.rst-content code.download .btn span.fa-large:first-child,.rst-content code.download .nav span.fa-large:first-child,.rst-content dl dt .btn .fa-large.headerlink,.rst-content dl dt .nav .fa-large.headerlink,.rst-content h1 .btn .fa-large.headerlink,.rst-content h1 .nav .fa-large.headerlink,.rst-content h2 .btn .fa-large.headerlink,.rst-content h2 .nav .fa-large.headerlink,.rst-content h3 .btn .fa-large.headerlink,.rst-content h3 .nav .fa-large.headerlink,.rst-content h4 .btn .fa-large.headerlink,.rst-content h4 .nav .fa-large.headerlink,.rst-content h5 .btn .fa-large.headerlink,.rst-content h5 .nav .fa-large.headerlink,.rst-content h6 .btn .fa-large.headerlink,.rst-content h6 .nav .fa-large.headerlink,.rst-content p .btn .fa-large.headerlink,.rst-content p .nav .fa-large.headerlink,.rst-content table>caption .btn .fa-large.headerlink,.rst-content table>caption .nav .fa-large.headerlink,.rst-content tt.download .btn span.fa-large:first-child,.rst-content tt.download .nav span.fa-large:first-child,.wy-menu-vertical li .btn button.fa-large.toctree-expand,.wy-menu-vertical li .nav button.fa-large.toctree-expand{line-height:.9em}.btn .fa-spin.icon,.btn .fa.fa-spin,.btn .rst-content .code-block-caption .fa-spin.headerlink,.btn .rst-content .eqno .fa-spin.headerlink,.btn .rst-content .fa-spin.admonition-title,.btn .rst-content code.download span.fa-spin:first-child,.btn .rst-content dl dt .fa-spin.headerlink,.btn .rst-content h1 .fa-spin.headerlink,.btn .rst-content h2 .fa-spin.headerlink,.btn .rst-content h3 .fa-spin.headerlink,.btn .rst-content h4 .fa-spin.headerlink,.btn .rst-content h5 .fa-spin.headerlink,.btn .rst-content h6 .fa-spin.headerlink,.btn .rst-content p .fa-spin.headerlink,.btn .rst-content table>caption .fa-spin.headerlink,.btn .rst-content tt.download span.fa-spin:first-child,.btn .wy-menu-vertical li button.fa-spin.toctree-expand,.nav .fa-spin.icon,.nav .fa.fa-spin,.nav .rst-content .code-block-caption .fa-spin.headerlink,.nav .rst-content .eqno .fa-spin.headerlink,.nav .rst-content .fa-spin.admonition-title,.nav .rst-content code.download span.fa-spin:first-child,.nav .rst-content dl dt .fa-spin.headerlink,.nav .rst-content h1 .fa-spin.headerlink,.nav .rst-content h2 .fa-spin.headerlink,.nav .rst-content h3 .fa-spin.headerlink,.nav .rst-content h4 .fa-spin.headerlink,.nav .rst-content h5 .fa-spin.headerlink,.nav .rst-content h6 .fa-spin.headerlink,.nav .rst-content p .fa-spin.headerlink,.nav .rst-content table>caption .fa-spin.headerlink,.nav .rst-content tt.download span.fa-spin:first-child,.nav .wy-menu-vertical li button.fa-spin.toctree-expand,.rst-content .btn .fa-spin.admonition-title,.rst-content .code-block-caption .btn .fa-spin.headerlink,.rst-content .code-block-caption .nav .fa-spin.headerlink,.rst-content .eqno .btn .fa-spin.headerlink,.rst-content .eqno .nav .fa-spin.headerlink,.rst-content .nav .fa-spin.admonition-title,.rst-content code.download .btn span.fa-spin:first-child,.rst-content code.download .nav span.fa-spin:first-child,.rst-content dl dt .btn .fa-spin.headerlink,.rst-content dl dt .nav .fa-spin.headerlink,.rst-content h1 .btn .fa-spin.headerlink,.rst-content h1 .nav .fa-spin.headerlink,.rst-content h2 .btn .fa-spin.headerlink,.rst-content h2 .nav .fa-spin.headerlink,.rst-content h3 .btn .fa-spin.headerlink,.rst-content h3 .nav .fa-spin.headerlink,.rst-content h4 .btn .fa-spin.headerlink,.rst-content h4 .nav .fa-spin.headerlink,.rst-content h5 .btn .fa-spin.headerlink,.rst-content h5 .nav .fa-spin.headerlink,.rst-content h6 .btn .fa-spin.headerlink,.rst-content h6 .nav .fa-spin.headerlink,.rst-content p .btn .fa-spin.headerlink,.rst-content p .nav .fa-spin.headerlink,.rst-content table>caption .btn .fa-spin.headerlink,.rst-content table>caption .nav .fa-spin.headerlink,.rst-content tt.download .btn span.fa-spin:first-child,.rst-content tt.download .nav span.fa-spin:first-child,.wy-menu-vertical li .btn button.fa-spin.toctree-expand,.wy-menu-vertical li .nav button.fa-spin.toctree-expand{display:inline-block}.btn.fa:before,.btn.icon:before,.rst-content .btn.admonition-title:before,.rst-content .code-block-caption .btn.headerlink:before,.rst-content .eqno .btn.headerlink:before,.rst-content code.download span.btn:first-child:before,.rst-content dl dt .btn.headerlink:before,.rst-content h1 .btn.headerlink:before,.rst-content h2 .btn.headerlink:before,.rst-content h3 .btn.headerlink:before,.rst-content h4 .btn.headerlink:before,.rst-content h5 .btn.headerlink:before,.rst-content h6 .btn.headerlink:before,.rst-content p .btn.headerlink:before,.rst-content table>caption .btn.headerlink:before,.rst-content tt.download span.btn:first-child:before,.wy-menu-vertical li button.btn.toctree-expand:before{opacity:.5;-webkit-transition:opacity .05s ease-in;-moz-transition:opacity .05s ease-in;transition:opacity .05s ease-in}.btn.fa:hover:before,.btn.icon:hover:before,.rst-content .btn.admonition-title:hover:before,.rst-content .code-block-caption .btn.headerlink:hover:before,.rst-content .eqno .btn.headerlink:hover:before,.rst-content code.download span.btn:first-child:hover:before,.rst-content dl dt .btn.headerlink:hover:before,.rst-content h1 .btn.headerlink:hover:before,.rst-content h2 .btn.headerlink:hover:before,.rst-content h3 .btn.headerlink:hover:before,.rst-content h4 .btn.headerlink:hover:before,.rst-content h5 .btn.headerlink:hover:before,.rst-content h6 .btn.headerlink:hover:before,.rst-content p .btn.headerlink:hover:before,.rst-content table>caption .btn.headerlink:hover:before,.rst-content tt.download span.btn:first-child:hover:before,.wy-menu-vertical li button.btn.toctree-expand:hover:before{opacity:1}.btn-mini .fa:before,.btn-mini .icon:before,.btn-mini .rst-content .admonition-title:before,.btn-mini .rst-content .code-block-caption .headerlink:before,.btn-mini .rst-content .eqno .headerlink:before,.btn-mini .rst-content code.download span:first-child:before,.btn-mini .rst-content dl dt .headerlink:before,.btn-mini .rst-content h1 .headerlink:before,.btn-mini .rst-content h2 .headerlink:before,.btn-mini .rst-content h3 .headerlink:before,.btn-mini .rst-content h4 .headerlink:before,.btn-mini .rst-content h5 .headerlink:before,.btn-mini .rst-content h6 .headerlink:before,.btn-mini .rst-content p .headerlink:before,.btn-mini .rst-content table>caption .headerlink:before,.btn-mini .rst-content tt.download span:first-child:before,.btn-mini .wy-menu-vertical li button.toctree-expand:before,.rst-content .btn-mini .admonition-title:before,.rst-content .code-block-caption .btn-mini .headerlink:before,.rst-content .eqno .btn-mini .headerlink:before,.rst-content code.download .btn-mini span:first-child:before,.rst-content dl dt .btn-mini .headerlink:before,.rst-content h1 .btn-mini .headerlink:before,.rst-content h2 .btn-mini .headerlink:before,.rst-content h3 .btn-mini .headerlink:before,.rst-content h4 .btn-mini .headerlink:before,.rst-content h5 .btn-mini .headerlink:before,.rst-content h6 .btn-mini .headerlink:before,.rst-content p .btn-mini .headerlink:before,.rst-content table>caption .btn-mini .headerlink:before,.rst-content tt.download .btn-mini span:first-child:before,.wy-menu-vertical li .btn-mini button.toctree-expand:before{font-size:14px;vertical-align:-15%}.rst-content .admonition,.rst-content .admonition-todo,.rst-content .attention,.rst-content .caution,.rst-content .danger,.rst-content .error,.rst-content .hint,.rst-content .important,.rst-content .note,.rst-content .seealso,.rst-content .tip,.rst-content .warning,.wy-alert{padding:12px;line-height:24px;margin-bottom:24px;background:#e7f2fa}.rst-content .admonition-title,.wy-alert-title{font-weight:700;display:block;color:#fff;background:#6ab0de;padding:6px 12px;margin:-12px -12px 12px}.rst-content .danger,.rst-content .error,.rst-content .wy-alert-danger.admonition,.rst-content .wy-alert-danger.admonition-todo,.rst-content .wy-alert-danger.attention,.rst-content .wy-alert-danger.caution,.rst-content .wy-alert-danger.hint,.rst-content .wy-alert-danger.important,.rst-content .wy-alert-danger.note,.rst-content .wy-alert-danger.seealso,.rst-content .wy-alert-danger.tip,.rst-content .wy-alert-danger.warning,.wy-alert.wy-alert-danger{background:#fdf3f2}.rst-content .danger .admonition-title,.rst-content .danger .wy-alert-title,.rst-content .error .admonition-title,.rst-content .error .wy-alert-title,.rst-content .wy-alert-danger.admonition-todo .admonition-title,.rst-content .wy-alert-danger.admonition-todo .wy-alert-title,.rst-content .wy-alert-danger.admonition .admonition-title,.rst-content .wy-alert-danger.admonition .wy-alert-title,.rst-content .wy-alert-danger.attention .admonition-title,.rst-content .wy-alert-danger.attention .wy-alert-title,.rst-content .wy-alert-danger.caution .admonition-title,.rst-content .wy-alert-danger.caution .wy-alert-title,.rst-content .wy-alert-danger.hint .admonition-title,.rst-content .wy-alert-danger.hint .wy-alert-title,.rst-content .wy-alert-danger.important .admonition-title,.rst-content .wy-alert-danger.important .wy-alert-title,.rst-content .wy-alert-danger.note .admonition-title,.rst-content .wy-alert-danger.note .wy-alert-title,.rst-content .wy-alert-danger.seealso .admonition-title,.rst-content .wy-alert-danger.seealso .wy-alert-title,.rst-content .wy-alert-danger.tip .admonition-title,.rst-content .wy-alert-danger.tip .wy-alert-title,.rst-content .wy-alert-danger.warning .admonition-title,.rst-content .wy-alert-danger.warning .wy-alert-title,.rst-content .wy-alert.wy-alert-danger .admonition-title,.wy-alert.wy-alert-danger .rst-content .admonition-title,.wy-alert.wy-alert-danger .wy-alert-title{background:#f29f97}.rst-content .admonition-todo,.rst-content .attention,.rst-content .caution,.rst-content .warning,.rst-content .wy-alert-warning.admonition,.rst-content .wy-alert-warning.danger,.rst-content .wy-alert-warning.error,.rst-content .wy-alert-warning.hint,.rst-content .wy-alert-warning.important,.rst-content .wy-alert-warning.note,.rst-content .wy-alert-warning.seealso,.rst-content .wy-alert-warning.tip,.wy-alert.wy-alert-warning{background:#ffedcc}.rst-content .admonition-todo .admonition-title,.rst-content .admonition-todo .wy-alert-title,.rst-content .attention .admonition-title,.rst-content .attention .wy-alert-title,.rst-content .caution .admonition-title,.rst-content .caution .wy-alert-title,.rst-content .warning .admonition-title,.rst-content .warning .wy-alert-title,.rst-content .wy-alert-warning.admonition .admonition-title,.rst-content .wy-alert-warning.admonition .wy-alert-title,.rst-content .wy-alert-warning.danger .admonition-title,.rst-content .wy-alert-warning.danger .wy-alert-title,.rst-content .wy-alert-warning.error .admonition-title,.rst-content .wy-alert-warning.error .wy-alert-title,.rst-content .wy-alert-warning.hint .admonition-title,.rst-content .wy-alert-warning.hint .wy-alert-title,.rst-content .wy-alert-warning.important .admonition-title,.rst-content .wy-alert-warning.important .wy-alert-title,.rst-content .wy-alert-warning.note .admonition-title,.rst-content .wy-alert-warning.note .wy-alert-title,.rst-content .wy-alert-warning.seealso .admonition-title,.rst-content .wy-alert-warning.seealso .wy-alert-title,.rst-content .wy-alert-warning.tip .admonition-title,.rst-content .wy-alert-warning.tip .wy-alert-title,.rst-content .wy-alert.wy-alert-warning .admonition-title,.wy-alert.wy-alert-warning .rst-content .admonition-title,.wy-alert.wy-alert-warning .wy-alert-title{background:#f0b37e}.rst-content .note,.rst-content .seealso,.rst-content .wy-alert-info.admonition,.rst-content .wy-alert-info.admonition-todo,.rst-content .wy-alert-info.attention,.rst-content .wy-alert-info.caution,.rst-content .wy-alert-info.danger,.rst-content .wy-alert-info.error,.rst-content .wy-alert-info.hint,.rst-content .wy-alert-info.important,.rst-content .wy-alert-info.tip,.rst-content .wy-alert-info.warning,.wy-alert.wy-alert-info{background:#e7f2fa}.rst-content .note .admonition-title,.rst-content .note .wy-alert-title,.rst-content .seealso .admonition-title,.rst-content .seealso .wy-alert-title,.rst-content .wy-alert-info.admonition-todo .admonition-title,.rst-content .wy-alert-info.admonition-todo .wy-alert-title,.rst-content .wy-alert-info.admonition .admonition-title,.rst-content .wy-alert-info.admonition .wy-alert-title,.rst-content .wy-alert-info.attention .admonition-title,.rst-content .wy-alert-info.attention .wy-alert-title,.rst-content .wy-alert-info.caution .admonition-title,.rst-content .wy-alert-info.caution .wy-alert-title,.rst-content .wy-alert-info.danger .admonition-title,.rst-content .wy-alert-info.danger .wy-alert-title,.rst-content .wy-alert-info.error .admonition-title,.rst-content .wy-alert-info.error .wy-alert-title,.rst-content .wy-alert-info.hint .admonition-title,.rst-content .wy-alert-info.hint .wy-alert-title,.rst-content .wy-alert-info.important .admonition-title,.rst-content .wy-alert-info.important .wy-alert-title,.rst-content .wy-alert-info.tip .admonition-title,.rst-content .wy-alert-info.tip .wy-alert-title,.rst-content .wy-alert-info.warning .admonition-title,.rst-content .wy-alert-info.warning .wy-alert-title,.rst-content .wy-alert.wy-alert-info .admonition-title,.wy-alert.wy-alert-info .rst-content .admonition-title,.wy-alert.wy-alert-info .wy-alert-title{background:#6ab0de}.rst-content .hint,.rst-content .important,.rst-content .tip,.rst-content .wy-alert-success.admonition,.rst-content .wy-alert-success.admonition-todo,.rst-content .wy-alert-success.attention,.rst-content .wy-alert-success.caution,.rst-content .wy-alert-success.danger,.rst-content .wy-alert-success.error,.rst-content .wy-alert-success.note,.rst-content .wy-alert-success.seealso,.rst-content .wy-alert-success.warning,.wy-alert.wy-alert-success{background:#dbfaf4}.rst-content .hint .admonition-title,.rst-content .hint .wy-alert-title,.rst-content .important .admonition-title,.rst-content .important .wy-alert-title,.rst-content .tip .admonition-title,.rst-content .tip .wy-alert-title,.rst-content .wy-alert-success.admonition-todo .admonition-title,.rst-content .wy-alert-success.admonition-todo .wy-alert-title,.rst-content .wy-alert-success.admonition .admonition-title,.rst-content .wy-alert-success.admonition .wy-alert-title,.rst-content .wy-alert-success.attention .admonition-title,.rst-content .wy-alert-success.attention .wy-alert-title,.rst-content .wy-alert-success.caution .admonition-title,.rst-content .wy-alert-success.caution .wy-alert-title,.rst-content .wy-alert-success.danger .admonition-title,.rst-content .wy-alert-success.danger .wy-alert-title,.rst-content .wy-alert-success.error .admonition-title,.rst-content .wy-alert-success.error .wy-alert-title,.rst-content .wy-alert-success.note .admonition-title,.rst-content .wy-alert-success.note .wy-alert-title,.rst-content .wy-alert-success.seealso .admonition-title,.rst-content .wy-alert-success.seealso .wy-alert-title,.rst-content .wy-alert-success.warning .admonition-title,.rst-content .wy-alert-success.warning .wy-alert-title,.rst-content .wy-alert.wy-alert-success .admonition-title,.wy-alert.wy-alert-success .rst-content .admonition-title,.wy-alert.wy-alert-success .wy-alert-title{background:#1abc9c}.rst-content .wy-alert-neutral.admonition,.rst-content .wy-alert-neutral.admonition-todo,.rst-content .wy-alert-neutral.attention,.rst-content .wy-alert-neutral.caution,.rst-content .wy-alert-neutral.danger,.rst-content .wy-alert-neutral.error,.rst-content .wy-alert-neutral.hint,.rst-content .wy-alert-neutral.important,.rst-content .wy-alert-neutral.note,.rst-content .wy-alert-neutral.seealso,.rst-content .wy-alert-neutral.tip,.rst-content .wy-alert-neutral.warning,.wy-alert.wy-alert-neutral{background:#f3f6f6}.rst-content .wy-alert-neutral.admonition-todo .admonition-title,.rst-content .wy-alert-neutral.admonition-todo .wy-alert-title,.rst-content .wy-alert-neutral.admonition .admonition-title,.rst-content .wy-alert-neutral.admonition .wy-alert-title,.rst-content .wy-alert-neutral.attention .admonition-title,.rst-content .wy-alert-neutral.attention .wy-alert-title,.rst-content .wy-alert-neutral.caution .admonition-title,.rst-content .wy-alert-neutral.caution .wy-alert-title,.rst-content .wy-alert-neutral.danger .admonition-title,.rst-content .wy-alert-neutral.danger .wy-alert-title,.rst-content .wy-alert-neutral.error .admonition-title,.rst-content .wy-alert-neutral.error .wy-alert-title,.rst-content .wy-alert-neutral.hint .admonition-title,.rst-content .wy-alert-neutral.hint .wy-alert-title,.rst-content .wy-alert-neutral.important .admonition-title,.rst-content .wy-alert-neutral.important .wy-alert-title,.rst-content .wy-alert-neutral.note .admonition-title,.rst-content .wy-alert-neutral.note .wy-alert-title,.rst-content .wy-alert-neutral.seealso .admonition-title,.rst-content .wy-alert-neutral.seealso .wy-alert-title,.rst-content .wy-alert-neutral.tip .admonition-title,.rst-content .wy-alert-neutral.tip .wy-alert-title,.rst-content .wy-alert-neutral.warning .admonition-title,.rst-content .wy-alert-neutral.warning .wy-alert-title,.rst-content .wy-alert.wy-alert-neutral .admonition-title,.wy-alert.wy-alert-neutral .rst-content .admonition-title,.wy-alert.wy-alert-neutral .wy-alert-title{color:#404040;background:#e1e4e5}.rst-content .wy-alert-neutral.admonition-todo a,.rst-content .wy-alert-neutral.admonition a,.rst-content .wy-alert-neutral.attention a,.rst-content .wy-alert-neutral.caution a,.rst-content .wy-alert-neutral.danger a,.rst-content .wy-alert-neutral.error a,.rst-content .wy-alert-neutral.hint a,.rst-content .wy-alert-neutral.important a,.rst-content .wy-alert-neutral.note a,.rst-content .wy-alert-neutral.seealso a,.rst-content .wy-alert-neutral.tip a,.rst-content .wy-alert-neutral.warning a,.wy-alert.wy-alert-neutral a{color:#2980b9}.rst-content .admonition-todo p:last-child,.rst-content .admonition p:last-child,.rst-content .attention p:last-child,.rst-content .caution p:last-child,.rst-content .danger p:last-child,.rst-content .error p:last-child,.rst-content .hint p:last-child,.rst-content .important p:last-child,.rst-content .note p:last-child,.rst-content .seealso p:last-child,.rst-content .tip p:last-child,.rst-content .warning p:last-child,.wy-alert p:last-child{margin-bottom:0}.wy-tray-container{position:fixed;bottom:0;left:0;z-index:600}.wy-tray-container li{display:block;width:300px;background:transparent;color:#fff;text-align:center;box-shadow:0 5px 5px 0 rgba(0,0,0,.1);padding:0 24px;min-width:20%;opacity:0;height:0;line-height:56px;overflow:hidden;-webkit-transition:all .3s ease-in;-moz-transition:all .3s ease-in;transition:all .3s ease-in}.wy-tray-container li.wy-tray-item-success{background:#27ae60}.wy-tray-container li.wy-tray-item-info{background:#2980b9}.wy-tray-container li.wy-tray-item-warning{background:#e67e22}.wy-tray-container li.wy-tray-item-danger{background:#e74c3c}.wy-tray-container li.on{opacity:1;height:56px}@media screen and (max-width:768px){.wy-tray-container{bottom:auto;top:0;width:100%}.wy-tray-container li{width:100%}}button{font-size:100%;margin:0;vertical-align:baseline;*vertical-align:middle;cursor:pointer;line-height:normal;-webkit-appearance:button;*overflow:visible}button::-moz-focus-inner,input::-moz-focus-inner{border:0;padding:0}button[disabled]{cursor:default}.btn{display:inline-block;border-radius:2px;line-height:normal;white-space:nowrap;text-align:center;cursor:pointer;font-size:100%;padding:6px 12px 8px;color:#fff;border:1px solid rgba(0,0,0,.1);background-color:#27ae60;text-decoration:none;font-weight:400;font-family:Lato,proxima-nova,Helvetica Neue,Arial,sans-serif;box-shadow:inset 0 1px 2px -1px hsla(0,0%,100%,.5),inset 0 -2px 0 0 rgba(0,0,0,.1);outline-none:false;vertical-align:middle;*display:inline;zoom:1;-webkit-user-drag:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;-webkit-transition:all .1s linear;-moz-transition:all .1s linear;transition:all .1s linear}.btn-hover{background:#2e8ece;color:#fff}.btn:hover{background:#2cc36b;color:#fff}.btn:focus{background:#2cc36b;outline:0}.btn:active{box-shadow:inset 0 -1px 0 0 rgba(0,0,0,.05),inset 0 2px 0 0 rgba(0,0,0,.1);padding:8px 12px 6px}.btn:visited{color:#fff}.btn-disabled,.btn-disabled:active,.btn-disabled:focus,.btn-disabled:hover,.btn:disabled{background-image:none;filter:progid:DXImageTransform.Microsoft.gradient(enabled = false);filter:alpha(opacity=40);opacity:.4;cursor:not-allowed;box-shadow:none}.btn::-moz-focus-inner{padding:0;border:0}.btn-small{font-size:80%}.btn-info{background-color:#2980b9!important}.btn-info:hover{background-color:#2e8ece!important}.btn-neutral{background-color:#f3f6f6!important;color:#404040!important}.btn-neutral:hover{background-color:#e5ebeb!important;color:#404040}.btn-neutral:visited{color:#404040!important}.btn-success{background-color:#27ae60!important}.btn-success:hover{background-color:#295!important}.btn-danger{background-color:#e74c3c!important}.btn-danger:hover{background-color:#ea6153!important}.btn-warning{background-color:#e67e22!important}.btn-warning:hover{background-color:#e98b39!important}.btn-invert{background-color:#222}.btn-invert:hover{background-color:#2f2f2f!important}.btn-link{background-color:transparent!important;color:#2980b9;box-shadow:none;border-color:transparent!important}.btn-link:active,.btn-link:hover{background-color:transparent!important;color:#409ad5!important;box-shadow:none}.btn-link:visited{color:#9b59b6}.wy-btn-group .btn,.wy-control .btn{vertical-align:middle}.wy-btn-group{margin-bottom:24px;*zoom:1}.wy-btn-group:after,.wy-btn-group:before{display:table;content:""}.wy-btn-group:after{clear:both}.wy-dropdown{position:relative;display:inline-block}.wy-dropdown-active .wy-dropdown-menu{display:block}.wy-dropdown-menu{position:absolute;left:0;display:none;float:left;top:100%;min-width:100%;background:#fcfcfc;z-index:100;border:1px solid #cfd7dd;box-shadow:0 2px 2px 0 rgba(0,0,0,.1);padding:12px}.wy-dropdown-menu>dd>a{display:block;clear:both;color:#404040;white-space:nowrap;font-size:90%;padding:0 12px;cursor:pointer}.wy-dropdown-menu>dd>a:hover{background:#2980b9;color:#fff}.wy-dropdown-menu>dd.divider{border-top:1px solid #cfd7dd;margin:6px 0}.wy-dropdown-menu>dd.search{padding-bottom:12px}.wy-dropdown-menu>dd.search input[type=search]{width:100%}.wy-dropdown-menu>dd.call-to-action{background:#e3e3e3;text-transform:uppercase;font-weight:500;font-size:80%}.wy-dropdown-menu>dd.call-to-action:hover{background:#e3e3e3}.wy-dropdown-menu>dd.call-to-action .btn{color:#fff}.wy-dropdown.wy-dropdown-up .wy-dropdown-menu{bottom:100%;top:auto;left:auto;right:0}.wy-dropdown.wy-dropdown-bubble .wy-dropdown-menu{background:#fcfcfc;margin-top:2px}.wy-dropdown.wy-dropdown-bubble .wy-dropdown-menu a{padding:6px 12px}.wy-dropdown.wy-dropdown-bubble .wy-dropdown-menu a:hover{background:#2980b9;color:#fff}.wy-dropdown.wy-dropdown-left .wy-dropdown-menu{right:0;left:auto;text-align:right}.wy-dropdown-arrow:before{content:" ";border-bottom:5px solid #f5f5f5;border-left:5px solid transparent;border-right:5px solid transparent;position:absolute;display:block;top:-4px;left:50%;margin-left:-3px}.wy-dropdown-arrow.wy-dropdown-arrow-left:before{left:11px}.wy-form-stacked select{display:block}.wy-form-aligned .wy-help-inline,.wy-form-aligned input,.wy-form-aligned label,.wy-form-aligned select,.wy-form-aligned textarea{display:inline-block;*display:inline;*zoom:1;vertical-align:middle}.wy-form-aligned .wy-control-group>label{display:inline-block;vertical-align:middle;width:10em;margin:6px 12px 0 0;float:left}.wy-form-aligned .wy-control{float:left}.wy-form-aligned .wy-control label{display:block}.wy-form-aligned .wy-control select{margin-top:6px}fieldset{margin:0}fieldset,legend{border:0;padding:0}legend{width:100%;white-space:normal;margin-bottom:24px;font-size:150%;*margin-left:-7px}label,legend{display:block}label{margin:0 0 .3125em;color:#333;font-size:90%}input,select,textarea{font-size:100%;margin:0;vertical-align:baseline;*vertical-align:middle}.wy-control-group{margin-bottom:24px;max-width:1200px;margin-left:auto;margin-right:auto;*zoom:1}.wy-control-group:after,.wy-control-group:before{display:table;content:""}.wy-control-group:after{clear:both}.wy-control-group.wy-control-group-required>label:after{content:" *";color:#e74c3c}.wy-control-group .wy-form-full,.wy-control-group .wy-form-halves,.wy-control-group .wy-form-thirds{padding-bottom:12px}.wy-control-group .wy-form-full input[type=color],.wy-control-group .wy-form-full input[type=date],.wy-control-group .wy-form-full input[type=datetime-local],.wy-control-group .wy-form-full input[type=datetime],.wy-control-group .wy-form-full input[type=email],.wy-control-group .wy-form-full input[type=month],.wy-control-group .wy-form-full input[type=number],.wy-control-group .wy-form-full input[type=password],.wy-control-group .wy-form-full input[type=search],.wy-control-group .wy-form-full input[type=tel],.wy-control-group .wy-form-full input[type=text],.wy-control-group .wy-form-full input[type=time],.wy-control-group .wy-form-full input[type=url],.wy-control-group .wy-form-full input[type=week],.wy-control-group .wy-form-full select,.wy-control-group .wy-form-halves input[type=color],.wy-control-group .wy-form-halves input[type=date],.wy-control-group .wy-form-halves input[type=datetime-local],.wy-control-group .wy-form-halves input[type=datetime],.wy-control-group .wy-form-halves input[type=email],.wy-control-group .wy-form-halves input[type=month],.wy-control-group .wy-form-halves input[type=number],.wy-control-group .wy-form-halves input[type=password],.wy-control-group .wy-form-halves input[type=search],.wy-control-group .wy-form-halves input[type=tel],.wy-control-group .wy-form-halves input[type=text],.wy-control-group .wy-form-halves input[type=time],.wy-control-group .wy-form-halves input[type=url],.wy-control-group .wy-form-halves input[type=week],.wy-control-group .wy-form-halves select,.wy-control-group .wy-form-thirds input[type=color],.wy-control-group .wy-form-thirds input[type=date],.wy-control-group .wy-form-thirds input[type=datetime-local],.wy-control-group .wy-form-thirds input[type=datetime],.wy-control-group .wy-form-thirds input[type=email],.wy-control-group .wy-form-thirds input[type=month],.wy-control-group .wy-form-thirds input[type=number],.wy-control-group .wy-form-thirds input[type=password],.wy-control-group .wy-form-thirds input[type=search],.wy-control-group .wy-form-thirds input[type=tel],.wy-control-group .wy-form-thirds input[type=text],.wy-control-group .wy-form-thirds input[type=time],.wy-control-group .wy-form-thirds input[type=url],.wy-control-group .wy-form-thirds input[type=week],.wy-control-group .wy-form-thirds select{width:100%}.wy-control-group .wy-form-full{float:left;display:block;width:100%;margin-right:0}.wy-control-group .wy-form-full:last-child{margin-right:0}.wy-control-group .wy-form-halves{float:left;display:block;margin-right:2.35765%;width:48.82117%}.wy-control-group .wy-form-halves:last-child,.wy-control-group .wy-form-halves:nth-of-type(2n){margin-right:0}.wy-control-group .wy-form-halves:nth-of-type(odd){clear:left}.wy-control-group .wy-form-thirds{float:left;display:block;margin-right:2.35765%;width:31.76157%}.wy-control-group .wy-form-thirds:last-child,.wy-control-group .wy-form-thirds:nth-of-type(3n){margin-right:0}.wy-control-group .wy-form-thirds:nth-of-type(3n+1){clear:left}.wy-control-group.wy-control-group-no-input .wy-control,.wy-control-no-input{margin:6px 0 0;font-size:90%}.wy-control-no-input{display:inline-block}.wy-control-group.fluid-input input[type=color],.wy-control-group.fluid-input input[type=date],.wy-control-group.fluid-input input[type=datetime-local],.wy-control-group.fluid-input input[type=datetime],.wy-control-group.fluid-input input[type=email],.wy-control-group.fluid-input input[type=month],.wy-control-group.fluid-input input[type=number],.wy-control-group.fluid-input input[type=password],.wy-control-group.fluid-input input[type=search],.wy-control-group.fluid-input input[type=tel],.wy-control-group.fluid-input input[type=text],.wy-control-group.fluid-input input[type=time],.wy-control-group.fluid-input input[type=url],.wy-control-group.fluid-input input[type=week]{width:100%}.wy-form-message-inline{padding-left:.3em;color:#666;font-size:90%}.wy-form-message{display:block;color:#999;font-size:70%;margin-top:.3125em;font-style:italic}.wy-form-message p{font-size:inherit;font-style:italic;margin-bottom:6px}.wy-form-message p:last-child{margin-bottom:0}input{line-height:normal}input[type=button],input[type=reset],input[type=submit]{-webkit-appearance:button;cursor:pointer;font-family:Lato,proxima-nova,Helvetica Neue,Arial,sans-serif;*overflow:visible}input[type=color],input[type=date],input[type=datetime-local],input[type=datetime],input[type=email],input[type=month],input[type=number],input[type=password],input[type=search],input[type=tel],input[type=text],input[type=time],input[type=url],input[type=week]{-webkit-appearance:none;padding:6px;display:inline-block;border:1px solid #ccc;font-size:80%;font-family:Lato,proxima-nova,Helvetica Neue,Arial,sans-serif;box-shadow:inset 0 1px 3px #ddd;border-radius:0;-webkit-transition:border .3s linear;-moz-transition:border .3s linear;transition:border .3s linear}input[type=datetime-local]{padding:.34375em .625em}input[disabled]{cursor:default}input[type=checkbox],input[type=radio]{padding:0;margin-right:.3125em;*height:13px;*width:13px}input[type=checkbox],input[type=radio],input[type=search]{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}input[type=search]::-webkit-search-cancel-button,input[type=search]::-webkit-search-decoration{-webkit-appearance:none}input[type=color]:focus,input[type=date]:focus,input[type=datetime-local]:focus,input[type=datetime]:focus,input[type=email]:focus,input[type=month]:focus,input[type=number]:focus,input[type=password]:focus,input[type=search]:focus,input[type=tel]:focus,input[type=text]:focus,input[type=time]:focus,input[type=url]:focus,input[type=week]:focus{outline:0;outline:thin dotted\9;border-color:#333}input.no-focus:focus{border-color:#ccc!important}input[type=checkbox]:focus,input[type=file]:focus,input[type=radio]:focus{outline:thin dotted #333;outline:1px auto #129fea}input[type=color][disabled],input[type=date][disabled],input[type=datetime-local][disabled],input[type=datetime][disabled],input[type=email][disabled],input[type=month][disabled],input[type=number][disabled],input[type=password][disabled],input[type=search][disabled],input[type=tel][disabled],input[type=text][disabled],input[type=time][disabled],input[type=url][disabled],input[type=week][disabled]{cursor:not-allowed;background-color:#fafafa}input:focus:invalid,select:focus:invalid,textarea:focus:invalid{color:#e74c3c;border:1px solid #e74c3c}input:focus:invalid:focus,select:focus:invalid:focus,textarea:focus:invalid:focus{border-color:#e74c3c}input[type=checkbox]:focus:invalid:focus,input[type=file]:focus:invalid:focus,input[type=radio]:focus:invalid:focus{outline-color:#e74c3c}input.wy-input-large{padding:12px;font-size:100%}textarea{overflow:auto;vertical-align:top;width:100%;font-family:Lato,proxima-nova,Helvetica Neue,Arial,sans-serif}select,textarea{padding:.5em .625em;display:inline-block;border:1px solid #ccc;font-size:80%;box-shadow:inset 0 1px 3px #ddd;-webkit-transition:border .3s linear;-moz-transition:border .3s linear;transition:border .3s linear}select{border:1px solid #ccc;background-color:#fff}select[multiple]{height:auto}select:focus,textarea:focus{outline:0}input[readonly],select[disabled],select[readonly],textarea[disabled],textarea[readonly]{cursor:not-allowed;background-color:#fafafa}input[type=checkbox][disabled],input[type=radio][disabled]{cursor:not-allowed}.wy-checkbox,.wy-radio{margin:6px 0;color:#404040;display:block}.wy-checkbox input,.wy-radio input{vertical-align:baseline}.wy-form-message-inline{display:inline-block;*display:inline;*zoom:1;vertical-align:middle}.wy-input-prefix,.wy-input-suffix{white-space:nowrap;padding:6px}.wy-input-prefix .wy-input-context,.wy-input-suffix .wy-input-context{line-height:27px;padding:0 8px;display:inline-block;font-size:80%;background-color:#f3f6f6;border:1px solid #ccc;color:#999}.wy-input-suffix .wy-input-context{border-left:0}.wy-input-prefix .wy-input-context{border-right:0}.wy-switch{position:relative;display:block;height:24px;margin-top:12px;cursor:pointer}.wy-switch:before{left:0;top:0;width:36px;height:12px;background:#ccc}.wy-switch:after,.wy-switch:before{position:absolute;content:"";display:block;border-radius:4px;-webkit-transition:all .2s ease-in-out;-moz-transition:all .2s ease-in-out;transition:all .2s ease-in-out}.wy-switch:after{width:18px;height:18px;background:#999;left:-3px;top:-3px}.wy-switch span{position:absolute;left:48px;display:block;font-size:12px;color:#ccc;line-height:1}.wy-switch.active:before{background:#1e8449}.wy-switch.active:after{left:24px;background:#27ae60}.wy-switch.disabled{cursor:not-allowed;opacity:.8}.wy-control-group.wy-control-group-error .wy-form-message,.wy-control-group.wy-control-group-error>label{color:#e74c3c}.wy-control-group.wy-control-group-error input[type=color],.wy-control-group.wy-control-group-error input[type=date],.wy-control-group.wy-control-group-error input[type=datetime-local],.wy-control-group.wy-control-group-error input[type=datetime],.wy-control-group.wy-control-group-error input[type=email],.wy-control-group.wy-control-group-error input[type=month],.wy-control-group.wy-control-group-error input[type=number],.wy-control-group.wy-control-group-error input[type=password],.wy-control-group.wy-control-group-error input[type=search],.wy-control-group.wy-control-group-error input[type=tel],.wy-control-group.wy-control-group-error input[type=text],.wy-control-group.wy-control-group-error input[type=time],.wy-control-group.wy-control-group-error input[type=url],.wy-control-group.wy-control-group-error input[type=week],.wy-control-group.wy-control-group-error textarea{border:1px solid #e74c3c}.wy-inline-validate{white-space:nowrap}.wy-inline-validate .wy-input-context{padding:.5em .625em;display:inline-block;font-size:80%}.wy-inline-validate.wy-inline-validate-success .wy-input-context{color:#27ae60}.wy-inline-validate.wy-inline-validate-danger .wy-input-context{color:#e74c3c}.wy-inline-validate.wy-inline-validate-warning .wy-input-context{color:#e67e22}.wy-inline-validate.wy-inline-validate-info .wy-input-context{color:#2980b9}.rotate-90{-webkit-transform:rotate(90deg);-moz-transform:rotate(90deg);-ms-transform:rotate(90deg);-o-transform:rotate(90deg);transform:rotate(90deg)}.rotate-180{-webkit-transform:rotate(180deg);-moz-transform:rotate(180deg);-ms-transform:rotate(180deg);-o-transform:rotate(180deg);transform:rotate(180deg)}.rotate-270{-webkit-transform:rotate(270deg);-moz-transform:rotate(270deg);-ms-transform:rotate(270deg);-o-transform:rotate(270deg);transform:rotate(270deg)}.mirror{-webkit-transform:scaleX(-1);-moz-transform:scaleX(-1);-ms-transform:scaleX(-1);-o-transform:scaleX(-1);transform:scaleX(-1)}.mirror.rotate-90{-webkit-transform:scaleX(-1) rotate(90deg);-moz-transform:scaleX(-1) rotate(90deg);-ms-transform:scaleX(-1) rotate(90deg);-o-transform:scaleX(-1) rotate(90deg);transform:scaleX(-1) rotate(90deg)}.mirror.rotate-180{-webkit-transform:scaleX(-1) rotate(180deg);-moz-transform:scaleX(-1) rotate(180deg);-ms-transform:scaleX(-1) rotate(180deg);-o-transform:scaleX(-1) rotate(180deg);transform:scaleX(-1) rotate(180deg)}.mirror.rotate-270{-webkit-transform:scaleX(-1) rotate(270deg);-moz-transform:scaleX(-1) rotate(270deg);-ms-transform:scaleX(-1) rotate(270deg);-o-transform:scaleX(-1) rotate(270deg);transform:scaleX(-1) rotate(270deg)}@media only screen and (max-width:480px){.wy-form button[type=submit]{margin:.7em 0 0}.wy-form input[type=color],.wy-form input[type=date],.wy-form input[type=datetime-local],.wy-form input[type=datetime],.wy-form input[type=email],.wy-form input[type=month],.wy-form input[type=number],.wy-form input[type=password],.wy-form input[type=search],.wy-form input[type=tel],.wy-form input[type=text],.wy-form input[type=time],.wy-form input[type=url],.wy-form input[type=week],.wy-form label{margin-bottom:.3em;display:block}.wy-form input[type=color],.wy-form input[type=date],.wy-form input[type=datetime-local],.wy-form input[type=datetime],.wy-form input[type=email],.wy-form input[type=month],.wy-form input[type=number],.wy-form input[type=password],.wy-form input[type=search],.wy-form input[type=tel],.wy-form input[type=time],.wy-form input[type=url],.wy-form input[type=week]{margin-bottom:0}.wy-form-aligned .wy-control-group label{margin-bottom:.3em;text-align:left;display:block;width:100%}.wy-form-aligned .wy-control{margin:1.5em 0 0}.wy-form-message,.wy-form-message-inline,.wy-form .wy-help-inline{display:block;font-size:80%;padding:6px 0}}@media screen and (max-width:768px){.tablet-hide{display:none}}@media screen and (max-width:480px){.mobile-hide{display:none}}.float-left{float:left}.float-right{float:right}.full-width{width:100%}.rst-content table.docutils,.rst-content table.field-list,.wy-table{border-collapse:collapse;border-spacing:0;empty-cells:show;margin-bottom:24px}.rst-content table.docutils caption,.rst-content table.field-list caption,.wy-table caption{color:#000;font:italic 85%/1 arial,sans-serif;padding:1em 0;text-align:center}.rst-content table.docutils td,.rst-content table.docutils th,.rst-content table.field-list td,.rst-content table.field-list th,.wy-table td,.wy-table th{font-size:90%;margin:0;overflow:visible;padding:8px 16px}.rst-content table.docutils td:first-child,.rst-content table.docutils th:first-child,.rst-content table.field-list td:first-child,.rst-content table.field-list th:first-child,.wy-table td:first-child,.wy-table th:first-child{border-left-width:0}.rst-content table.docutils thead,.rst-content table.field-list thead,.wy-table thead{color:#000;text-align:left;vertical-align:bottom;white-space:nowrap}.rst-content table.docutils thead th,.rst-content table.field-list thead th,.wy-table thead th{font-weight:700;border-bottom:2px solid #e1e4e5}.rst-content table.docutils td,.rst-content table.field-list td,.wy-table td{background-color:transparent;vertical-align:middle}.rst-content table.docutils td p,.rst-content table.field-list td p,.wy-table td p{line-height:18px}.rst-content table.docutils td p:last-child,.rst-content table.field-list td p:last-child,.wy-table td p:last-child{margin-bottom:0}.rst-content table.docutils .wy-table-cell-min,.rst-content table.field-list .wy-table-cell-min,.wy-table .wy-table-cell-min{width:1%;padding-right:0}.rst-content table.docutils .wy-table-cell-min input[type=checkbox],.rst-content table.field-list .wy-table-cell-min input[type=checkbox],.wy-table .wy-table-cell-min input[type=checkbox]{margin:0}.wy-table-secondary{color:grey;font-size:90%}.wy-table-tertiary{color:grey;font-size:80%}.rst-content table.docutils:not(.field-list) tr:nth-child(2n-1) td,.wy-table-backed,.wy-table-odd td,.wy-table-striped tr:nth-child(2n-1) td{background-color:#f3f6f6}.rst-content table.docutils,.wy-table-bordered-all{border:1px solid #e1e4e5}.rst-content table.docutils td,.wy-table-bordered-all td{border-bottom:1px solid #e1e4e5;border-left:1px solid #e1e4e5}.rst-content table.docutils tbody>tr:last-child td,.wy-table-bordered-all tbody>tr:last-child td{border-bottom-width:0}.wy-table-bordered{border:1px solid #e1e4e5}.wy-table-bordered-rows td{border-bottom:1px solid #e1e4e5}.wy-table-bordered-rows tbody>tr:last-child td{border-bottom-width:0}.wy-table-horizontal td,.wy-table-horizontal th{border-width:0 0 1px;border-bottom:1px solid #e1e4e5}.wy-table-horizontal tbody>tr:last-child td{border-bottom-width:0}.wy-table-responsive{margin-bottom:24px;max-width:100%;overflow:auto}.wy-table-responsive table{margin-bottom:0!important}.wy-table-responsive table td,.wy-table-responsive table th{white-space:nowrap}a{color:#2980b9;text-decoration:none;cursor:pointer}a:hover{color:#3091d1}a:visited{color:#9b59b6}html{height:100%}body,html{overflow-x:hidden}body{font-family:Lato,proxima-nova,Helvetica Neue,Arial,sans-serif;font-weight:400;color:#404040;min-height:100%;background:#edf0f2}.wy-text-left{text-align:left}.wy-text-center{text-align:center}.wy-text-right{text-align:right}.wy-text-large{font-size:120%}.wy-text-normal{font-size:100%}.wy-text-small,small{font-size:80%}.wy-text-strike{text-decoration:line-through}.wy-text-warning{color:#e67e22!important}a.wy-text-warning:hover{color:#eb9950!important}.wy-text-info{color:#2980b9!important}a.wy-text-info:hover{color:#409ad5!important}.wy-text-success{color:#27ae60!important}a.wy-text-success:hover{color:#36d278!important}.wy-text-danger{color:#e74c3c!important}a.wy-text-danger:hover{color:#ed7669!important}.wy-text-neutral{color:#404040!important}a.wy-text-neutral:hover{color:#595959!important}.rst-content .toctree-wrapper>p.caption,h1,h2,h3,h4,h5,h6,legend{margin-top:0;font-weight:700;font-family:Roboto Slab,ff-tisa-web-pro,Georgia,Arial,sans-serif}p{line-height:24px;font-size:16px;margin:0 0 24px}h1{font-size:175%}.rst-content .toctree-wrapper>p.caption,h2{font-size:150%}h3{font-size:125%}h4{font-size:115%}h5{font-size:110%}h6{font-size:100%}hr{display:block;height:1px;border:0;border-top:1px solid #e1e4e5;margin:24px 0;padding:0}.rst-content code,.rst-content tt,code{white-space:nowrap;max-width:100%;background:#fff;border:1px solid #e1e4e5;font-size:75%;padding:0 5px;font-family:SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,Courier,monospace;color:#e74c3c;overflow-x:auto}.rst-content tt.code-large,code.code-large{font-size:90%}.rst-content .section ul,.rst-content .toctree-wrapper ul,.rst-content section ul,.wy-plain-list-disc,article ul{list-style:disc;line-height:24px;margin-bottom:24px}.rst-content .section ul li,.rst-content .toctree-wrapper ul li,.rst-content section ul li,.wy-plain-list-disc li,article ul li{list-style:disc;margin-left:24px}.rst-content .section ul li p:last-child,.rst-content .section ul li ul,.rst-content .toctree-wrapper ul li p:last-child,.rst-content .toctree-wrapper ul li ul,.rst-content section ul li p:last-child,.rst-content section ul li ul,.wy-plain-list-disc li p:last-child,.wy-plain-list-disc li ul,article ul li p:last-child,article ul li ul{margin-bottom:0}.rst-content .section ul li li,.rst-content .toctree-wrapper ul li li,.rst-content section ul li li,.wy-plain-list-disc li li,article ul li li{list-style:circle}.rst-content .section ul li li li,.rst-content .toctree-wrapper ul li li li,.rst-content section ul li li li,.wy-plain-list-disc li li li,article ul li li li{list-style:square}.rst-content .section ul li ol li,.rst-content .toctree-wrapper ul li ol li,.rst-content section ul li ol li,.wy-plain-list-disc li ol li,article ul li ol li{list-style:decimal}.rst-content .section ol,.rst-content .section ol.arabic,.rst-content .toctree-wrapper ol,.rst-content .toctree-wrapper ol.arabic,.rst-content section ol,.rst-content section ol.arabic,.wy-plain-list-decimal,article ol{list-style:decimal;line-height:24px;margin-bottom:24px}.rst-content .section ol.arabic li,.rst-content .section ol li,.rst-content .toctree-wrapper ol.arabic li,.rst-content .toctree-wrapper ol li,.rst-content section ol.arabic li,.rst-content section ol li,.wy-plain-list-decimal li,article ol li{list-style:decimal;margin-left:24px}.rst-content .section ol.arabic li ul,.rst-content .section ol li p:last-child,.rst-content .section ol li ul,.rst-content .toctree-wrapper ol.arabic li ul,.rst-content .toctree-wrapper ol li p:last-child,.rst-content .toctree-wrapper ol li ul,.rst-content section ol.arabic li ul,.rst-content section ol li p:last-child,.rst-content section ol li ul,.wy-plain-list-decimal li p:last-child,.wy-plain-list-decimal li ul,article ol li p:last-child,article ol li ul{margin-bottom:0}.rst-content .section ol.arabic li ul li,.rst-content .section ol li ul li,.rst-content .toctree-wrapper ol.arabic li ul li,.rst-content .toctree-wrapper ol li ul li,.rst-content section ol.arabic li ul li,.rst-content section ol li ul li,.wy-plain-list-decimal li ul li,article ol li ul li{list-style:disc}.wy-breadcrumbs{*zoom:1}.wy-breadcrumbs:after,.wy-breadcrumbs:before{display:table;content:""}.wy-breadcrumbs:after{clear:both}.wy-breadcrumbs>li{display:inline-block;padding-top:5px}.wy-breadcrumbs>li.wy-breadcrumbs-aside{float:right}.rst-content .wy-breadcrumbs>li code,.rst-content .wy-breadcrumbs>li tt,.wy-breadcrumbs>li .rst-content tt,.wy-breadcrumbs>li code{all:inherit;color:inherit}.breadcrumb-item:before{content:"/";color:#bbb;font-size:13px;padding:0 6px 0 3px}.wy-breadcrumbs-extra{margin-bottom:0;color:#b3b3b3;font-size:80%;display:inline-block}@media screen and (max-width:480px){.wy-breadcrumbs-extra,.wy-breadcrumbs li.wy-breadcrumbs-aside{display:none}}@media print{.wy-breadcrumbs li.wy-breadcrumbs-aside{display:none}}html{font-size:16px}.wy-affix{position:fixed;top:1.618em}.wy-menu a:hover{text-decoration:none}.wy-menu-horiz{*zoom:1}.wy-menu-horiz:after,.wy-menu-horiz:before{display:table;content:""}.wy-menu-horiz:after{clear:both}.wy-menu-horiz li,.wy-menu-horiz ul{display:inline-block}.wy-menu-horiz li:hover{background:hsla(0,0%,100%,.1)}.wy-menu-horiz li.divide-left{border-left:1px solid #404040}.wy-menu-horiz li.divide-right{border-right:1px solid #404040}.wy-menu-horiz a{height:32px;display:inline-block;line-height:32px;padding:0 16px}.wy-menu-vertical{width:300px}.wy-menu-vertical header,.wy-menu-vertical p.caption{color:#55a5d9;height:32px;line-height:32px;padding:0 1.618em;margin:12px 0 0;display:block;font-weight:700;text-transform:uppercase;font-size:85%;white-space:nowrap}.wy-menu-vertical ul{margin-bottom:0}.wy-menu-vertical li.divide-top{border-top:1px solid #404040}.wy-menu-vertical li.divide-bottom{border-bottom:1px solid #404040}.wy-menu-vertical li.current{background:#e3e3e3}.wy-menu-vertical li.current a{color:grey;border-right:1px solid #c9c9c9;padding:.4045em 2.427em}.wy-menu-vertical li.current a:hover{background:#d6d6d6}.rst-content .wy-menu-vertical li tt,.wy-menu-vertical li .rst-content tt,.wy-menu-vertical li code{border:none;background:inherit;color:inherit;padding-left:0;padding-right:0}.wy-menu-vertical li button.toctree-expand{display:block;float:left;margin-left:-1.2em;line-height:18px;color:#4d4d4d;border:none;background:none;padding:0}.wy-menu-vertical li.current>a,.wy-menu-vertical li.on a{color:#404040;font-weight:700;position:relative;background:#fcfcfc;border:none;padding:.4045em 1.618em}.wy-menu-vertical li.current>a:hover,.wy-menu-vertical li.on a:hover{background:#fcfcfc}.wy-menu-vertical li.current>a:hover button.toctree-expand,.wy-menu-vertical li.on a:hover button.toctree-expand{color:grey}.wy-menu-vertical li.current>a button.toctree-expand,.wy-menu-vertical li.on a button.toctree-expand{display:block;line-height:18px;color:#333}.wy-menu-vertical li.toctree-l1.current>a{border-bottom:1px solid #c9c9c9;border-top:1px solid #c9c9c9}.wy-menu-vertical .toctree-l1.current .toctree-l2>ul,.wy-menu-vertical .toctree-l2.current .toctree-l3>ul,.wy-menu-vertical .toctree-l3.current .toctree-l4>ul,.wy-menu-vertical .toctree-l4.current .toctree-l5>ul,.wy-menu-vertical .toctree-l5.current .toctree-l6>ul,.wy-menu-vertical .toctree-l6.current .toctree-l7>ul,.wy-menu-vertical .toctree-l7.current .toctree-l8>ul,.wy-menu-vertical .toctree-l8.current .toctree-l9>ul,.wy-menu-vertical .toctree-l9.current .toctree-l10>ul,.wy-menu-vertical .toctree-l10.current .toctree-l11>ul{display:none}.wy-menu-vertical .toctree-l1.current .current.toctree-l2>ul,.wy-menu-vertical .toctree-l2.current .current.toctree-l3>ul,.wy-menu-vertical .toctree-l3.current .current.toctree-l4>ul,.wy-menu-vertical .toctree-l4.current .current.toctree-l5>ul,.wy-menu-vertical .toctree-l5.current .current.toctree-l6>ul,.wy-menu-vertical .toctree-l6.current .current.toctree-l7>ul,.wy-menu-vertical .toctree-l7.current .current.toctree-l8>ul,.wy-menu-vertical .toctree-l8.current .current.toctree-l9>ul,.wy-menu-vertical .toctree-l9.current .current.toctree-l10>ul,.wy-menu-vertical .toctree-l10.current .current.toctree-l11>ul{display:block}.wy-menu-vertical li.toctree-l3,.wy-menu-vertical li.toctree-l4{font-size:.9em}.wy-menu-vertical li.toctree-l2 a,.wy-menu-vertical li.toctree-l3 a,.wy-menu-vertical li.toctree-l4 a,.wy-menu-vertical li.toctree-l5 a,.wy-menu-vertical li.toctree-l6 a,.wy-menu-vertical li.toctree-l7 a,.wy-menu-vertical li.toctree-l8 a,.wy-menu-vertical li.toctree-l9 a,.wy-menu-vertical li.toctree-l10 a{color:#404040}.wy-menu-vertical li.toctree-l2 a:hover button.toctree-expand,.wy-menu-vertical li.toctree-l3 a:hover button.toctree-expand,.wy-menu-vertical li.toctree-l4 a:hover button.toctree-expand,.wy-menu-vertical li.toctree-l5 a:hover button.toctree-expand,.wy-menu-vertical li.toctree-l6 a:hover button.toctree-expand,.wy-menu-vertical li.toctree-l7 a:hover button.toctree-expand,.wy-menu-vertical li.toctree-l8 a:hover button.toctree-expand,.wy-menu-vertical li.toctree-l9 a:hover button.toctree-expand,.wy-menu-vertical li.toctree-l10 a:hover button.toctree-expand{color:grey}.wy-menu-vertical li.toctree-l2.current li.toctree-l3>a,.wy-menu-vertical li.toctree-l3.current li.toctree-l4>a,.wy-menu-vertical li.toctree-l4.current li.toctree-l5>a,.wy-menu-vertical li.toctree-l5.current li.toctree-l6>a,.wy-menu-vertical li.toctree-l6.current li.toctree-l7>a,.wy-menu-vertical li.toctree-l7.current li.toctree-l8>a,.wy-menu-vertical li.toctree-l8.current li.toctree-l9>a,.wy-menu-vertical li.toctree-l9.current li.toctree-l10>a,.wy-menu-vertical li.toctree-l10.current li.toctree-l11>a{display:block}.wy-menu-vertical li.toctree-l2.current>a{padding:.4045em 2.427em}.wy-menu-vertical li.toctree-l2.current li.toctree-l3>a{padding:.4045em 1.618em .4045em 4.045em}.wy-menu-vertical li.toctree-l3.current>a{padding:.4045em 4.045em}.wy-menu-vertical li.toctree-l3.current li.toctree-l4>a{padding:.4045em 1.618em .4045em 5.663em}.wy-menu-vertical li.toctree-l4.current>a{padding:.4045em 5.663em}.wy-menu-vertical li.toctree-l4.current li.toctree-l5>a{padding:.4045em 1.618em .4045em 7.281em}.wy-menu-vertical li.toctree-l5.current>a{padding:.4045em 7.281em}.wy-menu-vertical li.toctree-l5.current li.toctree-l6>a{padding:.4045em 1.618em .4045em 8.899em}.wy-menu-vertical li.toctree-l6.current>a{padding:.4045em 8.899em}.wy-menu-vertical li.toctree-l6.current li.toctree-l7>a{padding:.4045em 1.618em .4045em 10.517em}.wy-menu-vertical li.toctree-l7.current>a{padding:.4045em 10.517em}.wy-menu-vertical li.toctree-l7.current li.toctree-l8>a{padding:.4045em 1.618em .4045em 12.135em}.wy-menu-vertical li.toctree-l8.current>a{padding:.4045em 12.135em}.wy-menu-vertical li.toctree-l8.current li.toctree-l9>a{padding:.4045em 1.618em .4045em 13.753em}.wy-menu-vertical li.toctree-l9.current>a{padding:.4045em 13.753em}.wy-menu-vertical li.toctree-l9.current li.toctree-l10>a{padding:.4045em 1.618em .4045em 15.371em}.wy-menu-vertical li.toctree-l10.current>a{padding:.4045em 15.371em}.wy-menu-vertical li.toctree-l10.current li.toctree-l11>a{padding:.4045em 1.618em .4045em 16.989em}.wy-menu-vertical li.toctree-l2.current>a,.wy-menu-vertical li.toctree-l2.current li.toctree-l3>a{background:#c9c9c9}.wy-menu-vertical li.toctree-l2 button.toctree-expand{color:#a3a3a3}.wy-menu-vertical li.toctree-l3.current>a,.wy-menu-vertical li.toctree-l3.current li.toctree-l4>a{background:#bdbdbd}.wy-menu-vertical li.toctree-l3 button.toctree-expand{color:#969696}.wy-menu-vertical li.current ul{display:block}.wy-menu-vertical li ul{margin-bottom:0;display:none}.wy-menu-vertical li ul li a{margin-bottom:0;color:#d9d9d9;font-weight:400}.wy-menu-vertical a{line-height:18px;padding:.4045em 1.618em;display:block;position:relative;font-size:90%;color:#d9d9d9}.wy-menu-vertical a:hover{background-color:#4e4a4a;cursor:pointer}.wy-menu-vertical a:hover button.toctree-expand{color:#d9d9d9}.wy-menu-vertical a:active{background-color:#2980b9;cursor:pointer;color:#fff}.wy-menu-vertical a:active button.toctree-expand{color:#fff}.wy-side-nav-search{display:block;width:300px;padding:.809em;margin-bottom:.809em;z-index:200;background-color:#2980b9;text-align:center;color:#fcfcfc}.wy-side-nav-search input[type=text]{width:100%;border-radius:50px;padding:6px 12px;border-color:#2472a4}.wy-side-nav-search img{display:block;margin:auto auto .809em;height:45px;width:45px;background-color:#2980b9;padding:5px;border-radius:100%}.wy-side-nav-search .wy-dropdown>a,.wy-side-nav-search>a{color:#fcfcfc;font-size:100%;font-weight:700;display:inline-block;padding:4px 6px;margin-bottom:.809em;max-width:100%}.wy-side-nav-search .wy-dropdown>a:hover,.wy-side-nav-search .wy-dropdown>aactive,.wy-side-nav-search .wy-dropdown>afocus,.wy-side-nav-search>a:hover,.wy-side-nav-search>aactive,.wy-side-nav-search>afocus{background:hsla(0,0%,100%,.1)}.wy-side-nav-search .wy-dropdown>a img.logo,.wy-side-nav-search>a img.logo{display:block;margin:0 auto;height:auto;width:auto;border-radius:0;max-width:100%;background:transparent}.wy-side-nav-search .wy-dropdown>a.icon,.wy-side-nav-search>a.icon{display:block}.wy-side-nav-search .wy-dropdown>a.icon img.logo,.wy-side-nav-search>a.icon img.logo{margin-top:.85em}.wy-side-nav-search>div.switch-menus{position:relative;display:block;margin-top:-.4045em;margin-bottom:.809em;font-weight:400;color:hsla(0,0%,100%,.3)}.wy-side-nav-search>div.switch-menus>div.language-switch,.wy-side-nav-search>div.switch-menus>div.version-switch{display:inline-block;padding:.2em}.wy-side-nav-search>div.switch-menus>div.language-switch select,.wy-side-nav-search>div.switch-menus>div.version-switch select{display:inline-block;margin-right:-2rem;padding-right:2rem;max-width:240px;text-align-last:center;background:none;border:none;border-radius:0;box-shadow:none;font-family:Lato,proxima-nova,Helvetica Neue,Arial,sans-serif;font-size:1em;font-weight:400;color:hsla(0,0%,100%,.3);cursor:pointer;appearance:none;-webkit-appearance:none;-moz-appearance:none}.wy-side-nav-search>div.switch-menus>div.language-switch select:active,.wy-side-nav-search>div.switch-menus>div.language-switch select:focus,.wy-side-nav-search>div.switch-menus>div.language-switch select:hover,.wy-side-nav-search>div.switch-menus>div.version-switch select:active,.wy-side-nav-search>div.switch-menus>div.version-switch select:focus,.wy-side-nav-search>div.switch-menus>div.version-switch select:hover{background:hsla(0,0%,100%,.1);color:hsla(0,0%,100%,.5)}.wy-side-nav-search>div.switch-menus>div.language-switch select option,.wy-side-nav-search>div.switch-menus>div.version-switch select option{color:#000}.wy-side-nav-search>div.switch-menus>div.language-switch:has(>select):after,.wy-side-nav-search>div.switch-menus>div.version-switch:has(>select):after{display:inline-block;width:1.5em;height:100%;padding:.1em;content:"\f0d7";font-size:1em;line-height:1.2em;font-family:FontAwesome;text-align:center;pointer-events:none;box-sizing:border-box}.wy-nav .wy-menu-vertical header{color:#2980b9}.wy-nav .wy-menu-vertical a{color:#b3b3b3}.wy-nav .wy-menu-vertical a:hover{background-color:#2980b9;color:#fff}[data-menu-wrap]{-webkit-transition:all .2s ease-in;-moz-transition:all .2s ease-in;transition:all .2s ease-in;position:absolute;opacity:1;width:100%;opacity:0}[data-menu-wrap].move-center{left:0;right:auto;opacity:1}[data-menu-wrap].move-left{right:auto;left:-100%;opacity:0}[data-menu-wrap].move-right{right:-100%;left:auto;opacity:0}.wy-body-for-nav{background:#fcfcfc}.wy-grid-for-nav{position:absolute;width:100%;height:100%}.wy-nav-side{position:fixed;top:0;bottom:0;left:0;padding-bottom:2em;width:300px;overflow-x:hidden;overflow-y:hidden;min-height:100%;color:#9b9b9b;background:#343131;z-index:200}.wy-side-scroll{width:320px;position:relative;overflow-x:hidden;overflow-y:scroll;height:100%}.wy-nav-top{display:none;background:#2980b9;color:#fff;padding:.4045em .809em;position:relative;line-height:50px;text-align:center;font-size:100%;*zoom:1}.wy-nav-top:after,.wy-nav-top:before{display:table;content:""}.wy-nav-top:after{clear:both}.wy-nav-top a{color:#fff;font-weight:700}.wy-nav-top img{margin-right:12px;height:45px;width:45px;background-color:#2980b9;padding:5px;border-radius:100%}.wy-nav-top i{font-size:30px;float:left;cursor:pointer;padding-top:inherit}.wy-nav-content-wrap{margin-left:300px;background:#fcfcfc;min-height:100%}.wy-nav-content{padding:1.618em 3.236em;height:100%;max-width:800px;margin:auto}.wy-body-mask{position:fixed;width:100%;height:100%;background:rgba(0,0,0,.2);display:none;z-index:499}.wy-body-mask.on{display:block}footer{color:grey}footer p{margin-bottom:12px}.rst-content footer span.commit tt,footer span.commit .rst-content tt,footer span.commit code{padding:0;font-family:SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,Courier,monospace;font-size:1em;background:none;border:none;color:grey}.rst-footer-buttons{*zoom:1}.rst-footer-buttons:after,.rst-footer-buttons:before{width:100%;display:table;content:""}.rst-footer-buttons:after{clear:both}.rst-breadcrumbs-buttons{margin-top:12px;*zoom:1}.rst-breadcrumbs-buttons:after,.rst-breadcrumbs-buttons:before{display:table;content:""}.rst-breadcrumbs-buttons:after{clear:both}#search-results .search li{margin-bottom:24px;border-bottom:1px solid #e1e4e5;padding-bottom:24px}#search-results .search li:first-child{border-top:1px solid #e1e4e5;padding-top:24px}#search-results .search li a{font-size:120%;margin-bottom:12px;display:inline-block}#search-results .context{color:grey;font-size:90%}.genindextable li>ul{margin-left:24px}@media screen and (max-width:768px){.wy-body-for-nav{background:#fcfcfc}.wy-nav-top{display:block}.wy-nav-side{left:-300px}.wy-nav-side.shift{width:85%;left:0}.wy-menu.wy-menu-vertical,.wy-side-nav-search,.wy-side-scroll{width:auto}.wy-nav-content-wrap{margin-left:0}.wy-nav-content-wrap .wy-nav-content{padding:1.618em}.wy-nav-content-wrap.shift{position:fixed;min-width:100%;left:85%;top:0;height:100%;overflow:hidden}}@media screen and (min-width:1100px){.wy-nav-content-wrap{background:rgba(0,0,0,.05)}.wy-nav-content{margin:0;background:#fcfcfc}}@media print{.rst-versions,.wy-nav-side,footer{display:none}.wy-nav-content-wrap{margin-left:0}}.rst-versions{position:fixed;bottom:0;left:0;width:300px;color:#fcfcfc;background:#1f1d1d;font-family:Lato,proxima-nova,Helvetica Neue,Arial,sans-serif;z-index:400}.rst-versions a{color:#2980b9;text-decoration:none}.rst-versions .rst-badge-small{display:none}.rst-versions .rst-current-version{padding:12px;background-color:#272525;display:block;text-align:right;font-size:90%;cursor:pointer;color:#27ae60;*zoom:1}.rst-versions .rst-current-version:after,.rst-versions .rst-current-version:before{display:table;content:""}.rst-versions .rst-current-version:after{clear:both}.rst-content .code-block-caption .rst-versions .rst-current-version .headerlink,.rst-content .eqno .rst-versions .rst-current-version .headerlink,.rst-content .rst-versions .rst-current-version .admonition-title,.rst-content code.download .rst-versions .rst-current-version span:first-child,.rst-content dl dt .rst-versions .rst-current-version .headerlink,.rst-content h1 .rst-versions .rst-current-version .headerlink,.rst-content h2 .rst-versions .rst-current-version .headerlink,.rst-content h3 .rst-versions .rst-current-version .headerlink,.rst-content h4 .rst-versions .rst-current-version .headerlink,.rst-content h5 .rst-versions .rst-current-version .headerlink,.rst-content h6 .rst-versions .rst-current-version .headerlink,.rst-content p .rst-versions .rst-current-version .headerlink,.rst-content table>caption .rst-versions .rst-current-version .headerlink,.rst-content tt.download .rst-versions .rst-current-version span:first-child,.rst-versions .rst-current-version .fa,.rst-versions .rst-current-version .icon,.rst-versions .rst-current-version .rst-content .admonition-title,.rst-versions .rst-current-version .rst-content .code-block-caption .headerlink,.rst-versions .rst-current-version .rst-content .eqno .headerlink,.rst-versions .rst-current-version .rst-content code.download span:first-child,.rst-versions .rst-current-version .rst-content dl dt .headerlink,.rst-versions .rst-current-version .rst-content h1 .headerlink,.rst-versions .rst-current-version .rst-content h2 .headerlink,.rst-versions .rst-current-version .rst-content h3 .headerlink,.rst-versions .rst-current-version .rst-content h4 .headerlink,.rst-versions .rst-current-version .rst-content h5 .headerlink,.rst-versions .rst-current-version .rst-content h6 .headerlink,.rst-versions .rst-current-version .rst-content p .headerlink,.rst-versions .rst-current-version .rst-content table>caption .headerlink,.rst-versions .rst-current-version .rst-content tt.download span:first-child,.rst-versions .rst-current-version .wy-menu-vertical li button.toctree-expand,.wy-menu-vertical li .rst-versions .rst-current-version button.toctree-expand{color:#fcfcfc}.rst-versions .rst-current-version .fa-book,.rst-versions .rst-current-version .icon-book{float:left}.rst-versions .rst-current-version.rst-out-of-date{background-color:#e74c3c;color:#fff}.rst-versions .rst-current-version.rst-active-old-version{background-color:#f1c40f;color:#000}.rst-versions.shift-up{height:auto;max-height:100%;overflow-y:scroll}.rst-versions.shift-up .rst-other-versions{display:block}.rst-versions .rst-other-versions{font-size:90%;padding:12px;color:grey;display:none}.rst-versions .rst-other-versions hr{display:block;height:1px;border:0;margin:20px 0;padding:0;border-top:1px solid #413d3d}.rst-versions .rst-other-versions dd{display:inline-block;margin:0}.rst-versions .rst-other-versions dd a{display:inline-block;padding:6px;color:#fcfcfc}.rst-versions .rst-other-versions .rtd-current-item{font-weight:700}.rst-versions.rst-badge{width:auto;bottom:20px;right:20px;left:auto;border:none;max-width:300px;max-height:90%}.rst-versions.rst-badge .fa-book,.rst-versions.rst-badge .icon-book{float:none;line-height:30px}.rst-versions.rst-badge.shift-up .rst-current-version{text-align:right}.rst-versions.rst-badge.shift-up .rst-current-version .fa-book,.rst-versions.rst-badge.shift-up .rst-current-version .icon-book{float:left}.rst-versions.rst-badge>.rst-current-version{width:auto;height:30px;line-height:30px;padding:0 6px;display:block;text-align:center}@media screen and (max-width:768px){.rst-versions{width:85%;display:none}.rst-versions.shift{display:block}}#flyout-search-form{padding:6px}.rst-content .toctree-wrapper>p.caption,.rst-content h1,.rst-content h2,.rst-content h3,.rst-content h4,.rst-content h5,.rst-content h6{margin-bottom:24px}.rst-content img{max-width:100%;height:auto}.rst-content div.figure,.rst-content figure{margin-bottom:24px}.rst-content div.figure .caption-text,.rst-content figure .caption-text{font-style:italic}.rst-content div.figure p:last-child.caption,.rst-content figure p:last-child.caption{margin-bottom:0}.rst-content div.figure.align-center,.rst-content figure.align-center{text-align:center}.rst-content .section>a>img,.rst-content .section>img,.rst-content section>a>img,.rst-content section>img{margin-bottom:24px}.rst-content abbr[title]{text-decoration:none}.rst-content.style-external-links a.reference.external:after{font-family:FontAwesome;content:"\f08e";color:#b3b3b3;vertical-align:super;font-size:60%;margin:0 .2em}.rst-content blockquote{margin-left:24px;line-height:24px;margin-bottom:24px}.rst-content pre.literal-block{white-space:pre;margin:0;padding:12px;font-family:SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,Courier,monospace;display:block;overflow:auto}.rst-content div[class^=highlight],.rst-content pre.literal-block{border:1px solid #e1e4e5;overflow-x:auto;margin:1px 0 24px}.rst-content div[class^=highlight] div[class^=highlight],.rst-content pre.literal-block div[class^=highlight]{padding:0;border:none;margin:0}.rst-content div[class^=highlight] td.code{width:100%}.rst-content .linenodiv pre{border-right:1px solid #e6e9ea;margin:0;padding:12px;font-family:SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,Courier,monospace;user-select:none;pointer-events:none}.rst-content div[class^=highlight] pre{white-space:pre;margin:0;padding:12px;display:block;overflow:auto}.rst-content div[class^=highlight] pre .hll{display:block;margin:0 -12px;padding:0 12px}.rst-content .linenodiv pre,.rst-content div[class^=highlight] pre,.rst-content pre.literal-block{font-family:SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,Courier,monospace;font-size:12px;line-height:1.4}.rst-content div.highlight .gp,.rst-content div.highlight span.linenos{user-select:none;pointer-events:none}.rst-content div.highlight span.linenos{display:inline-block;padding-left:0;padding-right:12px;margin-right:12px;border-right:1px solid #e6e9ea}.rst-content .code-block-caption{font-style:italic;font-size:85%;line-height:1;padding:1em 0;text-align:center}@media print{.rst-content .codeblock,.rst-content div[class^=highlight],.rst-content div[class^=highlight] pre{white-space:pre-wrap}}.rst-content .admonition,.rst-content .admonition-todo,.rst-content .attention,.rst-content .caution,.rst-content .danger,.rst-content .error,.rst-content .hint,.rst-content .important,.rst-content .note,.rst-content .seealso,.rst-content .tip,.rst-content .warning{clear:both}.rst-content .admonition-todo .last,.rst-content .admonition-todo>:last-child,.rst-content .admonition .last,.rst-content .admonition>:last-child,.rst-content .attention .last,.rst-content .attention>:last-child,.rst-content .caution .last,.rst-content .caution>:last-child,.rst-content .danger .last,.rst-content .danger>:last-child,.rst-content .error .last,.rst-content .error>:last-child,.rst-content .hint .last,.rst-content .hint>:last-child,.rst-content .important .last,.rst-content .important>:last-child,.rst-content .note .last,.rst-content .note>:last-child,.rst-content .seealso .last,.rst-content .seealso>:last-child,.rst-content .tip .last,.rst-content .tip>:last-child,.rst-content .warning .last,.rst-content .warning>:last-child{margin-bottom:0}.rst-content .admonition-title:before{margin-right:4px}.rst-content .admonition table{border-color:rgba(0,0,0,.1)}.rst-content .admonition table td,.rst-content .admonition table th{background:transparent!important;border-color:rgba(0,0,0,.1)!important}.rst-content .section ol.loweralpha,.rst-content .section ol.loweralpha>li,.rst-content .toctree-wrapper ol.loweralpha,.rst-content .toctree-wrapper ol.loweralpha>li,.rst-content section ol.loweralpha,.rst-content section ol.loweralpha>li{list-style:lower-alpha}.rst-content .section ol.upperalpha,.rst-content .section ol.upperalpha>li,.rst-content .toctree-wrapper ol.upperalpha,.rst-content .toctree-wrapper ol.upperalpha>li,.rst-content section ol.upperalpha,.rst-content section ol.upperalpha>li{list-style:upper-alpha}.rst-content .section ol li>*,.rst-content .section ul li>*,.rst-content .toctree-wrapper ol li>*,.rst-content .toctree-wrapper ul li>*,.rst-content section ol li>*,.rst-content section ul li>*{margin-top:12px;margin-bottom:12px}.rst-content .section ol li>:first-child,.rst-content .section ul li>:first-child,.rst-content .toctree-wrapper ol li>:first-child,.rst-content .toctree-wrapper ul li>:first-child,.rst-content section ol li>:first-child,.rst-content section ul li>:first-child{margin-top:0}.rst-content .section ol li>p,.rst-content .section ol li>p:last-child,.rst-content .section ul li>p,.rst-content .section ul li>p:last-child,.rst-content .toctree-wrapper ol li>p,.rst-content .toctree-wrapper ol li>p:last-child,.rst-content .toctree-wrapper ul li>p,.rst-content .toctree-wrapper ul li>p:last-child,.rst-content section ol li>p,.rst-content section ol li>p:last-child,.rst-content section ul li>p,.rst-content section ul li>p:last-child{margin-bottom:12px}.rst-content .section ol li>p:only-child,.rst-content .section ol li>p:only-child:last-child,.rst-content .section ul li>p:only-child,.rst-content .section ul li>p:only-child:last-child,.rst-content .toctree-wrapper ol li>p:only-child,.rst-content .toctree-wrapper ol li>p:only-child:last-child,.rst-content .toctree-wrapper ul li>p:only-child,.rst-content .toctree-wrapper ul li>p:only-child:last-child,.rst-content section ol li>p:only-child,.rst-content section ol li>p:only-child:last-child,.rst-content section ul li>p:only-child,.rst-content section ul li>p:only-child:last-child{margin-bottom:0}.rst-content .section ol li>ol,.rst-content .section ol li>ul,.rst-content .section ul li>ol,.rst-content .section ul li>ul,.rst-content .toctree-wrapper ol li>ol,.rst-content .toctree-wrapper ol li>ul,.rst-content .toctree-wrapper ul li>ol,.rst-content .toctree-wrapper ul li>ul,.rst-content section ol li>ol,.rst-content section ol li>ul,.rst-content section ul li>ol,.rst-content section ul li>ul{margin-bottom:12px}.rst-content .section ol.simple li>*,.rst-content .section ol.simple li ol,.rst-content .section ol.simple li ul,.rst-content .section ul.simple li>*,.rst-content .section ul.simple li ol,.rst-content .section ul.simple li ul,.rst-content .toctree-wrapper ol.simple li>*,.rst-content .toctree-wrapper ol.simple li ol,.rst-content .toctree-wrapper ol.simple li ul,.rst-content .toctree-wrapper ul.simple li>*,.rst-content .toctree-wrapper ul.simple li ol,.rst-content .toctree-wrapper ul.simple li ul,.rst-content section ol.simple li>*,.rst-content section ol.simple li ol,.rst-content section ol.simple li ul,.rst-content section ul.simple li>*,.rst-content section ul.simple li ol,.rst-content section ul.simple li ul{margin-top:0;margin-bottom:0}.rst-content .line-block{margin-left:0;margin-bottom:24px;line-height:24px}.rst-content .line-block .line-block{margin-left:24px;margin-bottom:0}.rst-content .topic-title{font-weight:700;margin-bottom:12px}.rst-content .toc-backref{color:#404040}.rst-content .align-right{float:right;margin:0 0 24px 24px}.rst-content .align-left{float:left;margin:0 24px 24px 0}.rst-content .align-center{margin:auto}.rst-content .align-center:not(table){display:block}.rst-content .code-block-caption .headerlink,.rst-content .eqno .headerlink,.rst-content .toctree-wrapper>p.caption .headerlink,.rst-content dl dt .headerlink,.rst-content h1 .headerlink,.rst-content h2 .headerlink,.rst-content h3 .headerlink,.rst-content h4 .headerlink,.rst-content h5 .headerlink,.rst-content h6 .headerlink,.rst-content p.caption .headerlink,.rst-content p .headerlink,.rst-content table>caption .headerlink{opacity:0;font-size:14px;font-family:FontAwesome;margin-left:.5em}.rst-content .code-block-caption .headerlink:focus,.rst-content .code-block-caption:hover .headerlink,.rst-content .eqno .headerlink:focus,.rst-content .eqno:hover .headerlink,.rst-content .toctree-wrapper>p.caption .headerlink:focus,.rst-content .toctree-wrapper>p.caption:hover .headerlink,.rst-content dl dt .headerlink:focus,.rst-content dl dt:hover .headerlink,.rst-content h1 .headerlink:focus,.rst-content h1:hover .headerlink,.rst-content h2 .headerlink:focus,.rst-content h2:hover .headerlink,.rst-content h3 .headerlink:focus,.rst-content h3:hover .headerlink,.rst-content h4 .headerlink:focus,.rst-content h4:hover .headerlink,.rst-content h5 .headerlink:focus,.rst-content h5:hover .headerlink,.rst-content h6 .headerlink:focus,.rst-content h6:hover .headerlink,.rst-content p.caption .headerlink:focus,.rst-content p.caption:hover .headerlink,.rst-content p .headerlink:focus,.rst-content p:hover .headerlink,.rst-content table>caption .headerlink:focus,.rst-content table>caption:hover .headerlink{opacity:1}.rst-content p a{overflow-wrap:anywhere}.rst-content .wy-table td p,.rst-content .wy-table td ul,.rst-content .wy-table th p,.rst-content .wy-table th ul,.rst-content table.docutils td p,.rst-content table.docutils td ul,.rst-content table.docutils th p,.rst-content table.docutils th ul,.rst-content table.field-list td p,.rst-content table.field-list td ul,.rst-content table.field-list th p,.rst-content table.field-list th ul{font-size:inherit}.rst-content .btn:focus{outline:2px solid}.rst-content table>caption .headerlink:after{font-size:12px}.rst-content .centered{text-align:center}.rst-content .sidebar{float:right;width:40%;display:block;margin:0 0 24px 24px;padding:24px;background:#f3f6f6;border:1px solid #e1e4e5}.rst-content .sidebar dl,.rst-content .sidebar p,.rst-content .sidebar ul{font-size:90%}.rst-content .sidebar .last,.rst-content .sidebar>:last-child{margin-bottom:0}.rst-content .sidebar .sidebar-title{display:block;font-family:Roboto Slab,ff-tisa-web-pro,Georgia,Arial,sans-serif;font-weight:700;background:#e1e4e5;padding:6px 12px;margin:-24px -24px 24px;font-size:100%}.rst-content .highlighted{background:#f1c40f;box-shadow:0 0 0 2px #f1c40f;display:inline;font-weight:700}.rst-content .citation-reference,.rst-content .footnote-reference{vertical-align:baseline;position:relative;top:-.4em;line-height:0;font-size:90%}.rst-content .citation-reference>span.fn-bracket,.rst-content .footnote-reference>span.fn-bracket{display:none}.rst-content .hlist{width:100%}.rst-content dl dt span.classifier:before{content:" : "}.rst-content dl dt span.classifier-delimiter{display:none!important}html.writer-html4 .rst-content table.docutils.citation,html.writer-html4 .rst-content table.docutils.footnote{background:none;border:none}html.writer-html4 .rst-content table.docutils.citation td,html.writer-html4 .rst-content table.docutils.citation tr,html.writer-html4 .rst-content table.docutils.footnote td,html.writer-html4 .rst-content table.docutils.footnote tr{border:none;background-color:transparent!important;white-space:normal}html.writer-html4 .rst-content table.docutils.citation td.label,html.writer-html4 .rst-content table.docutils.footnote td.label{padding-left:0;padding-right:0;vertical-align:top}html.writer-html5 .rst-content dl.citation,html.writer-html5 .rst-content dl.field-list,html.writer-html5 .rst-content dl.footnote{display:grid;grid-template-columns:auto minmax(80%,95%)}html.writer-html5 .rst-content dl.citation>dt,html.writer-html5 .rst-content dl.field-list>dt,html.writer-html5 .rst-content dl.footnote>dt{display:inline-grid;grid-template-columns:max-content auto}html.writer-html5 .rst-content aside.citation,html.writer-html5 .rst-content aside.footnote,html.writer-html5 .rst-content div.citation{display:grid;grid-template-columns:auto auto minmax(.65rem,auto) minmax(40%,95%)}html.writer-html5 .rst-content aside.citation>span.label,html.writer-html5 .rst-content aside.footnote>span.label,html.writer-html5 .rst-content div.citation>span.label{grid-column-start:1;grid-column-end:2}html.writer-html5 .rst-content aside.citation>span.backrefs,html.writer-html5 .rst-content aside.footnote>span.backrefs,html.writer-html5 .rst-content div.citation>span.backrefs{grid-column-start:2;grid-column-end:3;grid-row-start:1;grid-row-end:3}html.writer-html5 .rst-content aside.citation>p,html.writer-html5 .rst-content aside.footnote>p,html.writer-html5 .rst-content div.citation>p{grid-column-start:4;grid-column-end:5}html.writer-html5 .rst-content dl.citation,html.writer-html5 .rst-content dl.field-list,html.writer-html5 .rst-content dl.footnote{margin-bottom:24px}html.writer-html5 .rst-content dl.citation>dt,html.writer-html5 .rst-content dl.field-list>dt,html.writer-html5 .rst-content dl.footnote>dt{padding-left:1rem}html.writer-html5 .rst-content dl.citation>dd,html.writer-html5 .rst-content dl.citation>dt,html.writer-html5 .rst-content dl.field-list>dd,html.writer-html5 .rst-content dl.field-list>dt,html.writer-html5 .rst-content dl.footnote>dd,html.writer-html5 .rst-content dl.footnote>dt{margin-bottom:0}html.writer-html5 .rst-content dl.citation,html.writer-html5 .rst-content dl.footnote{font-size:.9rem}html.writer-html5 .rst-content dl.citation>dt,html.writer-html5 .rst-content dl.footnote>dt{margin:0 .5rem .5rem 0;line-height:1.2rem;word-break:break-all;font-weight:400}html.writer-html5 .rst-content dl.citation>dt>span.brackets:before,html.writer-html5 .rst-content dl.footnote>dt>span.brackets:before{content:"["}html.writer-html5 .rst-content dl.citation>dt>span.brackets:after,html.writer-html5 .rst-content dl.footnote>dt>span.brackets:after{content:"]"}html.writer-html5 .rst-content dl.citation>dt>span.fn-backref,html.writer-html5 .rst-content dl.footnote>dt>span.fn-backref{text-align:left;font-style:italic;margin-left:.65rem;word-break:break-word;word-spacing:-.1rem;max-width:5rem}html.writer-html5 .rst-content dl.citation>dt>span.fn-backref>a,html.writer-html5 .rst-content dl.footnote>dt>span.fn-backref>a{word-break:keep-all}html.writer-html5 .rst-content dl.citation>dt>span.fn-backref>a:not(:first-child):before,html.writer-html5 .rst-content dl.footnote>dt>span.fn-backref>a:not(:first-child):before{content:" "}html.writer-html5 .rst-content dl.citation>dd,html.writer-html5 .rst-content dl.footnote>dd{margin:0 0 .5rem;line-height:1.2rem}html.writer-html5 .rst-content dl.citation>dd p,html.writer-html5 .rst-content dl.footnote>dd p{font-size:.9rem}html.writer-html5 .rst-content aside.citation,html.writer-html5 .rst-content aside.footnote,html.writer-html5 .rst-content div.citation{padding-left:1rem;padding-right:1rem;font-size:.9rem;line-height:1.2rem}html.writer-html5 .rst-content aside.citation p,html.writer-html5 .rst-content aside.footnote p,html.writer-html5 .rst-content div.citation p{font-size:.9rem;line-height:1.2rem;margin-bottom:12px}html.writer-html5 .rst-content aside.citation span.backrefs,html.writer-html5 .rst-content aside.footnote span.backrefs,html.writer-html5 .rst-content div.citation span.backrefs{text-align:left;font-style:italic;margin-left:.65rem;word-break:break-word;word-spacing:-.1rem;max-width:5rem}html.writer-html5 .rst-content aside.citation span.backrefs>a,html.writer-html5 .rst-content aside.footnote span.backrefs>a,html.writer-html5 .rst-content div.citation span.backrefs>a{word-break:keep-all}html.writer-html5 .rst-content aside.citation span.backrefs>a:not(:first-child):before,html.writer-html5 .rst-content aside.footnote span.backrefs>a:not(:first-child):before,html.writer-html5 .rst-content div.citation span.backrefs>a:not(:first-child):before{content:" "}html.writer-html5 .rst-content aside.citation span.label,html.writer-html5 .rst-content aside.footnote span.label,html.writer-html5 .rst-content div.citation span.label{line-height:1.2rem}html.writer-html5 .rst-content aside.citation-list,html.writer-html5 .rst-content aside.footnote-list,html.writer-html5 .rst-content div.citation-list{margin-bottom:24px}html.writer-html5 .rst-content dl.option-list kbd{font-size:.9rem}.rst-content table.docutils.footnote,html.writer-html4 .rst-content table.docutils.citation,html.writer-html5 .rst-content aside.footnote,html.writer-html5 .rst-content aside.footnote-list aside.footnote,html.writer-html5 .rst-content div.citation-list>div.citation,html.writer-html5 .rst-content dl.citation,html.writer-html5 .rst-content dl.footnote{color:grey}.rst-content table.docutils.footnote code,.rst-content table.docutils.footnote tt,html.writer-html4 .rst-content table.docutils.citation code,html.writer-html4 .rst-content table.docutils.citation tt,html.writer-html5 .rst-content aside.footnote-list aside.footnote code,html.writer-html5 .rst-content aside.footnote-list aside.footnote tt,html.writer-html5 .rst-content aside.footnote code,html.writer-html5 .rst-content aside.footnote tt,html.writer-html5 .rst-content div.citation-list>div.citation code,html.writer-html5 .rst-content div.citation-list>div.citation tt,html.writer-html5 .rst-content dl.citation code,html.writer-html5 .rst-content dl.citation tt,html.writer-html5 .rst-content dl.footnote code,html.writer-html5 .rst-content dl.footnote tt{color:#555}.rst-content .wy-table-responsive.citation,.rst-content .wy-table-responsive.footnote{margin-bottom:0}.rst-content .wy-table-responsive.citation+:not(.citation),.rst-content .wy-table-responsive.footnote+:not(.footnote){margin-top:24px}.rst-content .wy-table-responsive.citation:last-child,.rst-content .wy-table-responsive.footnote:last-child{margin-bottom:24px}.rst-content table.docutils th{border-color:#e1e4e5}html.writer-html5 .rst-content table.docutils th{border:1px solid #e1e4e5}html.writer-html5 .rst-content table.docutils td>p,html.writer-html5 .rst-content table.docutils th>p{line-height:1rem;margin-bottom:0;font-size:.9rem}.rst-content table.docutils td .last,.rst-content table.docutils td .last>:last-child{margin-bottom:0}.rst-content table.field-list,.rst-content table.field-list td{border:none}.rst-content table.field-list td p{line-height:inherit}.rst-content table.field-list td>strong{display:inline-block}.rst-content table.field-list .field-name{padding-right:10px;text-align:left;white-space:nowrap}.rst-content table.field-list .field-body{text-align:left}.rst-content code,.rst-content tt{color:#000;font-family:SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,Courier,monospace;padding:2px 5px}.rst-content code big,.rst-content code em,.rst-content tt big,.rst-content tt em{font-size:100%!important;line-height:normal}.rst-content code.literal,.rst-content tt.literal{color:#e74c3c;white-space:normal}.rst-content code.xref,.rst-content tt.xref,a .rst-content code,a .rst-content tt{font-weight:700;color:#404040;overflow-wrap:normal}.rst-content kbd,.rst-content pre,.rst-content samp{font-family:SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,Courier,monospace}.rst-content a code,.rst-content a tt{color:#2980b9}.rst-content dl{margin-bottom:24px}.rst-content dl dt{font-weight:700;margin-bottom:12px}.rst-content dl ol,.rst-content dl p,.rst-content dl table,.rst-content dl ul{margin-bottom:12px}.rst-content dl dd{margin:0 0 12px 24px;line-height:24px}.rst-content dl dd>ol:last-child,.rst-content dl dd>p:last-child,.rst-content dl dd>table:last-child,.rst-content dl dd>ul:last-child{margin-bottom:0}html.writer-html4 .rst-content dl:not(.docutils),html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple){margin-bottom:24px}html.writer-html4 .rst-content dl:not(.docutils)>dt,html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple)>dt{display:table;margin:6px 0;font-size:90%;line-height:normal;background:#e7f2fa;color:#2980b9;border-top:3px solid #6ab0de;padding:6px;position:relative}html.writer-html4 .rst-content dl:not(.docutils)>dt:before,html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple)>dt:before{color:#6ab0de}html.writer-html4 .rst-content dl:not(.docutils)>dt .headerlink,html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple)>dt .headerlink{color:#404040;font-size:100%!important}html.writer-html4 .rst-content dl:not(.docutils) dl:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple)>dt,html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple) dl:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple)>dt{margin-bottom:6px;border:none;border-left:3px solid #ccc;background:#f0f0f0;color:#555}html.writer-html4 .rst-content dl:not(.docutils) dl:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple)>dt .headerlink,html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple) dl:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple)>dt .headerlink{color:#404040;font-size:100%!important}html.writer-html4 .rst-content dl:not(.docutils)>dt:first-child,html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple)>dt:first-child{margin-top:0}html.writer-html4 .rst-content dl:not(.docutils) code.descclassname,html.writer-html4 .rst-content dl:not(.docutils) code.descname,html.writer-html4 .rst-content dl:not(.docutils) tt.descclassname,html.writer-html4 .rst-content dl:not(.docutils) tt.descname,html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple) code.descclassname,html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple) code.descname,html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple) tt.descclassname,html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple) tt.descname{background-color:transparent;border:none;padding:0;font-size:100%!important}html.writer-html4 .rst-content dl:not(.docutils) code.descname,html.writer-html4 .rst-content dl:not(.docutils) tt.descname,html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple) code.descname,html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple) tt.descname{font-weight:700}html.writer-html4 .rst-content dl:not(.docutils) .optional,html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple) .optional{display:inline-block;padding:0 4px;color:#000;font-weight:700}html.writer-html4 .rst-content dl:not(.docutils) .property,html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple) .property{display:inline-block;padding-right:8px;max-width:100%}html.writer-html4 .rst-content dl:not(.docutils) .k,html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple) .k{font-style:italic}html.writer-html4 .rst-content dl:not(.docutils) .descclassname,html.writer-html4 .rst-content dl:not(.docutils) .descname,html.writer-html4 .rst-content dl:not(.docutils) .sig-name,html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple) .descclassname,html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple) .descname,html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple) .sig-name{font-family:SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,Courier,monospace;color:#000}.rst-content .viewcode-back,.rst-content .viewcode-link{display:inline-block;color:#27ae60;font-size:80%;padding-left:24px}.rst-content .viewcode-back{display:block;float:right}.rst-content p.rubric{margin-bottom:12px;font-weight:700}.rst-content code.download,.rst-content tt.download{background:inherit;padding:inherit;font-weight:400;font-family:inherit;font-size:inherit;color:inherit;border:inherit;white-space:inherit}.rst-content code.download span:first-child,.rst-content tt.download span:first-child{-webkit-font-smoothing:subpixel-antialiased}.rst-content code.download span:first-child:before,.rst-content tt.download span:first-child:before{margin-right:4px}.rst-content .guilabel,.rst-content .menuselection{font-size:80%;font-weight:700;border-radius:4px;padding:2.4px 6px;margin:auto 2px}.rst-content .guilabel,.rst-content .menuselection{border:1px solid #7fbbe3;background:#e7f2fa}.rst-content :not(dl.option-list)>:not(dt):not(kbd):not(.kbd)>.kbd,.rst-content :not(dl.option-list)>:not(dt):not(kbd):not(.kbd)>kbd{color:inherit;font-size:80%;background-color:#fff;border:1px solid #a6a6a6;border-radius:4px;box-shadow:0 2px grey;padding:2.4px 6px;margin:auto 0}.rst-content .versionmodified{font-style:italic}@media screen and (max-width:480px){.rst-content .sidebar{width:100%;float:none;margin-left:0}}span[id*=MathJax-Span]{color:#404040}.math{text-align:center}@font-face{font-family:Lato;src:url(fonts/lato-normal.woff2?bd03a2cc277bbbc338d464e679fe9942) format("woff2"),url(fonts/lato-normal.woff?27bd77b9162d388cb8d4c4217c7c5e2a) format("woff");font-weight:400;font-style:normal;font-display:block}@font-face{font-family:Lato;src:url(fonts/lato-bold.woff2?cccb897485813c7c256901dbca54ecf2) format("woff2"),url(fonts/lato-bold.woff?d878b6c29b10beca227e9eef4246111b) format("woff");font-weight:700;font-style:normal;font-display:block}@font-face{font-family:Lato;src:url(fonts/lato-bold-italic.woff2?0b6bb6725576b072c5d0b02ecdd1900d) format("woff2"),url(fonts/lato-bold-italic.woff?9c7e4e9eb485b4a121c760e61bc3707c) format("woff");font-weight:700;font-style:italic;font-display:block}@font-face{font-family:Lato;src:url(fonts/lato-normal-italic.woff2?4eb103b4d12be57cb1d040ed5e162e9d) format("woff2"),url(fonts/lato-normal-italic.woff?f28f2d6482446544ef1ea1ccc6dd5892) format("woff");font-weight:400;font-style:italic;font-display:block}@font-face{font-family:Roboto Slab;font-style:normal;font-weight:400;src:url(fonts/Roboto-Slab-Regular.woff2?7abf5b8d04d26a2cafea937019bca958) format("woff2"),url(fonts/Roboto-Slab-Regular.woff?c1be9284088d487c5e3ff0a10a92e58c) format("woff");font-display:block}@font-face{font-family:Roboto Slab;font-style:normal;font-weight:700;src:url(fonts/Roboto-Slab-Bold.woff2?9984f4a9bda09be08e83f2506954adbe) format("woff2"),url(fonts/Roboto-Slab-Bold.woff?bed5564a116b05148e3b3bea6fb1162a) format("woff");font-display:block} \ No newline at end of file diff --git a/docs/latest/_static/doctools.js b/docs/latest/_static/doctools.js new file mode 100644 index 00000000..807cdb17 --- /dev/null +++ b/docs/latest/_static/doctools.js @@ -0,0 +1,150 @@ +/* + * Base JavaScript utilities for all Sphinx HTML documentation. + */ +"use strict"; + +const BLACKLISTED_KEY_CONTROL_ELEMENTS = new Set([ + "TEXTAREA", + "INPUT", + "SELECT", + "BUTTON", +]); + +const _ready = (callback) => { + if (document.readyState !== "loading") { + callback(); + } else { + document.addEventListener("DOMContentLoaded", callback); + } +}; + +/** + * Small JavaScript module for the documentation. + */ +const Documentation = { + init: () => { + Documentation.initDomainIndexTable(); + Documentation.initOnKeyListeners(); + }, + + /** + * i18n support + */ + TRANSLATIONS: {}, + PLURAL_EXPR: (n) => (n === 1 ? 0 : 1), + LOCALE: "unknown", + + // gettext and ngettext don't access this so that the functions + // can safely bound to a different name (_ = Documentation.gettext) + gettext: (string) => { + const translated = Documentation.TRANSLATIONS[string]; + switch (typeof translated) { + case "undefined": + return string; // no translation + case "string": + return translated; // translation exists + default: + return translated[0]; // (singular, plural) translation tuple exists + } + }, + + ngettext: (singular, plural, n) => { + const translated = Documentation.TRANSLATIONS[singular]; + if (typeof translated !== "undefined") + return translated[Documentation.PLURAL_EXPR(n)]; + return n === 1 ? singular : plural; + }, + + addTranslations: (catalog) => { + Object.assign(Documentation.TRANSLATIONS, catalog.messages); + Documentation.PLURAL_EXPR = new Function( + "n", + `return (${catalog.plural_expr})`, + ); + Documentation.LOCALE = catalog.locale; + }, + + /** + * helper function to focus on search bar + */ + focusSearchBar: () => { + document.querySelectorAll("input[name=q]")[0]?.focus(); + }, + + /** + * Initialise the domain index toggle buttons + */ + initDomainIndexTable: () => { + const toggler = (el) => { + const idNumber = el.id.substr(7); + const toggledRows = document.querySelectorAll(`tr.cg-${idNumber}`); + if (el.src.substr(-9) === "minus.png") { + el.src = `${el.src.substr(0, el.src.length - 9)}plus.png`; + toggledRows.forEach((el) => (el.style.display = "none")); + } else { + el.src = `${el.src.substr(0, el.src.length - 8)}minus.png`; + toggledRows.forEach((el) => (el.style.display = "")); + } + }; + + const togglerElements = document.querySelectorAll("img.toggler"); + togglerElements.forEach((el) => + el.addEventListener("click", (event) => toggler(event.currentTarget)), + ); + togglerElements.forEach((el) => (el.style.display = "")); + if (DOCUMENTATION_OPTIONS.COLLAPSE_INDEX) togglerElements.forEach(toggler); + }, + + initOnKeyListeners: () => { + // only install a listener if it is really needed + if ( + !DOCUMENTATION_OPTIONS.NAVIGATION_WITH_KEYS + && !DOCUMENTATION_OPTIONS.ENABLE_SEARCH_SHORTCUTS + ) + return; + + document.addEventListener("keydown", (event) => { + // bail for input elements + if (BLACKLISTED_KEY_CONTROL_ELEMENTS.has(document.activeElement.tagName)) + return; + // bail with special keys + if (event.altKey || event.ctrlKey || event.metaKey) return; + + if (!event.shiftKey) { + switch (event.key) { + case "ArrowLeft": + if (!DOCUMENTATION_OPTIONS.NAVIGATION_WITH_KEYS) break; + + const prevLink = document.querySelector('link[rel="prev"]'); + if (prevLink && prevLink.href) { + window.location.href = prevLink.href; + event.preventDefault(); + } + break; + case "ArrowRight": + if (!DOCUMENTATION_OPTIONS.NAVIGATION_WITH_KEYS) break; + + const nextLink = document.querySelector('link[rel="next"]'); + if (nextLink && nextLink.href) { + window.location.href = nextLink.href; + event.preventDefault(); + } + break; + } + } + + // some keyboard layouts may need Shift to get / + switch (event.key) { + case "/": + if (!DOCUMENTATION_OPTIONS.ENABLE_SEARCH_SHORTCUTS) break; + Documentation.focusSearchBar(); + event.preventDefault(); + } + }); + }, +}; + +// quick alias for translations +const _ = Documentation.gettext; + +_ready(Documentation.init); diff --git a/docs/latest/_static/documentation_options.js b/docs/latest/_static/documentation_options.js new file mode 100644 index 00000000..7e4c114f --- /dev/null +++ b/docs/latest/_static/documentation_options.js @@ -0,0 +1,13 @@ +const DOCUMENTATION_OPTIONS = { + VERSION: '', + LANGUAGE: 'en', + COLLAPSE_INDEX: false, + BUILDER: 'html', + FILE_SUFFIX: '.html', + LINK_SUFFIX: '.html', + HAS_SOURCE: true, + SOURCELINK_SUFFIX: '.txt', + NAVIGATION_WITH_KEYS: false, + SHOW_SEARCH_SUMMARY: true, + ENABLE_SEARCH_SHORTCUTS: true, +}; \ No newline at end of file diff --git a/docs/latest/_static/english-stemmer.js b/docs/latest/_static/english-stemmer.js new file mode 100644 index 00000000..056760ee --- /dev/null +++ b/docs/latest/_static/english-stemmer.js @@ -0,0 +1,1066 @@ +// Generated from english.sbl by Snowball 3.0.1 - https://snowballstem.org/ + +/**@constructor*/ +var EnglishStemmer = function() { + var base = new BaseStemmer(); + + /** @const */ var a_0 = [ + ["arsen", -1, -1], + ["commun", -1, -1], + ["emerg", -1, -1], + ["gener", -1, -1], + ["later", -1, -1], + ["organ", -1, -1], + ["past", -1, -1], + ["univers", -1, -1] + ]; + + /** @const */ var a_1 = [ + ["'", -1, 1], + ["'s'", 0, 1], + ["'s", -1, 1] + ]; + + /** @const */ var a_2 = [ + ["ied", -1, 2], + ["s", -1, 3], + ["ies", 1, 2], + ["sses", 1, 1], + ["ss", 1, -1], + ["us", 1, -1] + ]; + + /** @const */ var a_3 = [ + ["succ", -1, 1], + ["proc", -1, 1], + ["exc", -1, 1] + ]; + + /** @const */ var a_4 = [ + ["even", -1, 2], + ["cann", -1, 2], + ["inn", -1, 2], + ["earr", -1, 2], + ["herr", -1, 2], + ["out", -1, 2], + ["y", -1, 1] + ]; + + /** @const */ var a_5 = [ + ["", -1, -1], + ["ed", 0, 2], + ["eed", 1, 1], + ["ing", 0, 3], + ["edly", 0, 2], + ["eedly", 4, 1], + ["ingly", 0, 2] + ]; + + /** @const */ var a_6 = [ + ["", -1, 3], + ["bb", 0, 2], + ["dd", 0, 2], + ["ff", 0, 2], + ["gg", 0, 2], + ["bl", 0, 1], + ["mm", 0, 2], + ["nn", 0, 2], + ["pp", 0, 2], + ["rr", 0, 2], + ["at", 0, 1], + ["tt", 0, 2], + ["iz", 0, 1] + ]; + + /** @const */ var a_7 = [ + ["anci", -1, 3], + ["enci", -1, 2], + ["ogi", -1, 14], + ["li", -1, 16], + ["bli", 3, 12], + ["abli", 4, 4], + ["alli", 3, 8], + ["fulli", 3, 9], + ["lessli", 3, 15], + ["ousli", 3, 10], + ["entli", 3, 5], + ["aliti", -1, 8], + ["biliti", -1, 12], + ["iviti", -1, 11], + ["tional", -1, 1], + ["ational", 14, 7], + ["alism", -1, 8], + ["ation", -1, 7], + ["ization", 17, 6], + ["izer", -1, 6], + ["ator", -1, 7], + ["iveness", -1, 11], + ["fulness", -1, 9], + ["ousness", -1, 10], + ["ogist", -1, 13] + ]; + + /** @const */ var a_8 = [ + ["icate", -1, 4], + ["ative", -1, 6], + ["alize", -1, 3], + ["iciti", -1, 4], + ["ical", -1, 4], + ["tional", -1, 1], + ["ational", 5, 2], + ["ful", -1, 5], + ["ness", -1, 5] + ]; + + /** @const */ var a_9 = [ + ["ic", -1, 1], + ["ance", -1, 1], + ["ence", -1, 1], + ["able", -1, 1], + ["ible", -1, 1], + ["ate", -1, 1], + ["ive", -1, 1], + ["ize", -1, 1], + ["iti", -1, 1], + ["al", -1, 1], + ["ism", -1, 1], + ["ion", -1, 2], + ["er", -1, 1], + ["ous", -1, 1], + ["ant", -1, 1], + ["ent", -1, 1], + ["ment", 15, 1], + ["ement", 16, 1] + ]; + + /** @const */ var a_10 = [ + ["e", -1, 1], + ["l", -1, 2] + ]; + + /** @const */ var a_11 = [ + ["andes", -1, -1], + ["atlas", -1, -1], + ["bias", -1, -1], + ["cosmos", -1, -1], + ["early", -1, 5], + ["gently", -1, 3], + ["howe", -1, -1], + ["idly", -1, 2], + ["news", -1, -1], + ["only", -1, 6], + ["singly", -1, 7], + ["skies", -1, 1], + ["sky", -1, -1], + ["ugly", -1, 4] + ]; + + /** @const */ var /** Array */ g_aeo = [17, 64]; + + /** @const */ var /** Array */ g_v = [17, 65, 16, 1]; + + /** @const */ var /** Array */ g_v_WXY = [1, 17, 65, 208, 1]; + + /** @const */ var /** Array */ g_valid_LI = [55, 141, 2]; + + var /** boolean */ B_Y_found = false; + var /** number */ I_p2 = 0; + var /** number */ I_p1 = 0; + + + /** @return {boolean} */ + function r_prelude() { + B_Y_found = false; + /** @const */ var /** number */ v_1 = base.cursor; + lab0: { + base.bra = base.cursor; + if (!(base.eq_s("'"))) + { + break lab0; + } + base.ket = base.cursor; + if (!base.slice_del()) + { + return false; + } + } + base.cursor = v_1; + /** @const */ var /** number */ v_2 = base.cursor; + lab1: { + base.bra = base.cursor; + if (!(base.eq_s("y"))) + { + break lab1; + } + base.ket = base.cursor; + if (!base.slice_from("Y")) + { + return false; + } + B_Y_found = true; + } + base.cursor = v_2; + /** @const */ var /** number */ v_3 = base.cursor; + lab2: { + while(true) + { + /** @const */ var /** number */ v_4 = base.cursor; + lab3: { + golab4: while(true) + { + /** @const */ var /** number */ v_5 = base.cursor; + lab5: { + if (!(base.in_grouping(g_v, 97, 121))) + { + break lab5; + } + base.bra = base.cursor; + if (!(base.eq_s("y"))) + { + break lab5; + } + base.ket = base.cursor; + base.cursor = v_5; + break golab4; + } + base.cursor = v_5; + if (base.cursor >= base.limit) + { + break lab3; + } + base.cursor++; + } + if (!base.slice_from("Y")) + { + return false; + } + B_Y_found = true; + continue; + } + base.cursor = v_4; + break; + } + } + base.cursor = v_3; + return true; + }; + + /** @return {boolean} */ + function r_mark_regions() { + I_p1 = base.limit; + I_p2 = base.limit; + /** @const */ var /** number */ v_1 = base.cursor; + lab0: { + lab1: { + /** @const */ var /** number */ v_2 = base.cursor; + lab2: { + if (base.find_among(a_0) == 0) + { + break lab2; + } + break lab1; + } + base.cursor = v_2; + if (!base.go_out_grouping(g_v, 97, 121)) + { + break lab0; + } + base.cursor++; + if (!base.go_in_grouping(g_v, 97, 121)) + { + break lab0; + } + base.cursor++; + } + I_p1 = base.cursor; + if (!base.go_out_grouping(g_v, 97, 121)) + { + break lab0; + } + base.cursor++; + if (!base.go_in_grouping(g_v, 97, 121)) + { + break lab0; + } + base.cursor++; + I_p2 = base.cursor; + } + base.cursor = v_1; + return true; + }; + + /** @return {boolean} */ + function r_shortv() { + lab0: { + /** @const */ var /** number */ v_1 = base.limit - base.cursor; + lab1: { + if (!(base.out_grouping_b(g_v_WXY, 89, 121))) + { + break lab1; + } + if (!(base.in_grouping_b(g_v, 97, 121))) + { + break lab1; + } + if (!(base.out_grouping_b(g_v, 97, 121))) + { + break lab1; + } + break lab0; + } + base.cursor = base.limit - v_1; + lab2: { + if (!(base.out_grouping_b(g_v, 97, 121))) + { + break lab2; + } + if (!(base.in_grouping_b(g_v, 97, 121))) + { + break lab2; + } + if (base.cursor > base.limit_backward) + { + break lab2; + } + break lab0; + } + base.cursor = base.limit - v_1; + if (!(base.eq_s_b("past"))) + { + return false; + } + } + return true; + }; + + /** @return {boolean} */ + function r_R1() { + return I_p1 <= base.cursor; + }; + + /** @return {boolean} */ + function r_R2() { + return I_p2 <= base.cursor; + }; + + /** @return {boolean} */ + function r_Step_1a() { + var /** number */ among_var; + /** @const */ var /** number */ v_1 = base.limit - base.cursor; + lab0: { + base.ket = base.cursor; + if (base.find_among_b(a_1) == 0) + { + base.cursor = base.limit - v_1; + break lab0; + } + base.bra = base.cursor; + if (!base.slice_del()) + { + return false; + } + } + base.ket = base.cursor; + among_var = base.find_among_b(a_2); + if (among_var == 0) + { + return false; + } + base.bra = base.cursor; + switch (among_var) { + case 1: + if (!base.slice_from("ss")) + { + return false; + } + break; + case 2: + lab1: { + /** @const */ var /** number */ v_2 = base.limit - base.cursor; + lab2: { + { + /** @const */ var /** number */ c1 = base.cursor - 2; + if (c1 < base.limit_backward) + { + break lab2; + } + base.cursor = c1; + } + if (!base.slice_from("i")) + { + return false; + } + break lab1; + } + base.cursor = base.limit - v_2; + if (!base.slice_from("ie")) + { + return false; + } + } + break; + case 3: + if (base.cursor <= base.limit_backward) + { + return false; + } + base.cursor--; + if (!base.go_out_grouping_b(g_v, 97, 121)) + { + return false; + } + base.cursor--; + if (!base.slice_del()) + { + return false; + } + break; + } + return true; + }; + + /** @return {boolean} */ + function r_Step_1b() { + var /** number */ among_var; + base.ket = base.cursor; + among_var = base.find_among_b(a_5); + base.bra = base.cursor; + lab0: { + /** @const */ var /** number */ v_1 = base.limit - base.cursor; + lab1: { + switch (among_var) { + case 1: + /** @const */ var /** number */ v_2 = base.limit - base.cursor; + lab2: { + lab3: { + /** @const */ var /** number */ v_3 = base.limit - base.cursor; + lab4: { + if (base.find_among_b(a_3) == 0) + { + break lab4; + } + if (base.cursor > base.limit_backward) + { + break lab4; + } + break lab3; + } + base.cursor = base.limit - v_3; + if (!r_R1()) + { + break lab2; + } + if (!base.slice_from("ee")) + { + return false; + } + } + } + base.cursor = base.limit - v_2; + break; + case 2: + break lab1; + case 3: + among_var = base.find_among_b(a_4); + if (among_var == 0) + { + break lab1; + } + switch (among_var) { + case 1: + /** @const */ var /** number */ v_4 = base.limit - base.cursor; + if (!(base.out_grouping_b(g_v, 97, 121))) + { + break lab1; + } + if (base.cursor > base.limit_backward) + { + break lab1; + } + base.cursor = base.limit - v_4; + base.bra = base.cursor; + if (!base.slice_from("ie")) + { + return false; + } + break; + case 2: + if (base.cursor > base.limit_backward) + { + break lab1; + } + break; + } + break; + } + break lab0; + } + base.cursor = base.limit - v_1; + /** @const */ var /** number */ v_5 = base.limit - base.cursor; + if (!base.go_out_grouping_b(g_v, 97, 121)) + { + return false; + } + base.cursor--; + base.cursor = base.limit - v_5; + if (!base.slice_del()) + { + return false; + } + base.ket = base.cursor; + base.bra = base.cursor; + /** @const */ var /** number */ v_6 = base.limit - base.cursor; + among_var = base.find_among_b(a_6); + switch (among_var) { + case 1: + if (!base.slice_from("e")) + { + return false; + } + return false; + case 2: + { + /** @const */ var /** number */ v_7 = base.limit - base.cursor; + lab5: { + if (!(base.in_grouping_b(g_aeo, 97, 111))) + { + break lab5; + } + if (base.cursor > base.limit_backward) + { + break lab5; + } + return false; + } + base.cursor = base.limit - v_7; + } + break; + case 3: + if (base.cursor != I_p1) + { + return false; + } + /** @const */ var /** number */ v_8 = base.limit - base.cursor; + if (!r_shortv()) + { + return false; + } + base.cursor = base.limit - v_8; + if (!base.slice_from("e")) + { + return false; + } + return false; + } + base.cursor = base.limit - v_6; + base.ket = base.cursor; + if (base.cursor <= base.limit_backward) + { + return false; + } + base.cursor--; + base.bra = base.cursor; + if (!base.slice_del()) + { + return false; + } + } + return true; + }; + + /** @return {boolean} */ + function r_Step_1c() { + base.ket = base.cursor; + lab0: { + /** @const */ var /** number */ v_1 = base.limit - base.cursor; + lab1: { + if (!(base.eq_s_b("y"))) + { + break lab1; + } + break lab0; + } + base.cursor = base.limit - v_1; + if (!(base.eq_s_b("Y"))) + { + return false; + } + } + base.bra = base.cursor; + if (!(base.out_grouping_b(g_v, 97, 121))) + { + return false; + } + lab2: { + if (base.cursor > base.limit_backward) + { + break lab2; + } + return false; + } + if (!base.slice_from("i")) + { + return false; + } + return true; + }; + + /** @return {boolean} */ + function r_Step_2() { + var /** number */ among_var; + base.ket = base.cursor; + among_var = base.find_among_b(a_7); + if (among_var == 0) + { + return false; + } + base.bra = base.cursor; + if (!r_R1()) + { + return false; + } + switch (among_var) { + case 1: + if (!base.slice_from("tion")) + { + return false; + } + break; + case 2: + if (!base.slice_from("ence")) + { + return false; + } + break; + case 3: + if (!base.slice_from("ance")) + { + return false; + } + break; + case 4: + if (!base.slice_from("able")) + { + return false; + } + break; + case 5: + if (!base.slice_from("ent")) + { + return false; + } + break; + case 6: + if (!base.slice_from("ize")) + { + return false; + } + break; + case 7: + if (!base.slice_from("ate")) + { + return false; + } + break; + case 8: + if (!base.slice_from("al")) + { + return false; + } + break; + case 9: + if (!base.slice_from("ful")) + { + return false; + } + break; + case 10: + if (!base.slice_from("ous")) + { + return false; + } + break; + case 11: + if (!base.slice_from("ive")) + { + return false; + } + break; + case 12: + if (!base.slice_from("ble")) + { + return false; + } + break; + case 13: + if (!base.slice_from("og")) + { + return false; + } + break; + case 14: + if (!(base.eq_s_b("l"))) + { + return false; + } + if (!base.slice_from("og")) + { + return false; + } + break; + case 15: + if (!base.slice_from("less")) + { + return false; + } + break; + case 16: + if (!(base.in_grouping_b(g_valid_LI, 99, 116))) + { + return false; + } + if (!base.slice_del()) + { + return false; + } + break; + } + return true; + }; + + /** @return {boolean} */ + function r_Step_3() { + var /** number */ among_var; + base.ket = base.cursor; + among_var = base.find_among_b(a_8); + if (among_var == 0) + { + return false; + } + base.bra = base.cursor; + if (!r_R1()) + { + return false; + } + switch (among_var) { + case 1: + if (!base.slice_from("tion")) + { + return false; + } + break; + case 2: + if (!base.slice_from("ate")) + { + return false; + } + break; + case 3: + if (!base.slice_from("al")) + { + return false; + } + break; + case 4: + if (!base.slice_from("ic")) + { + return false; + } + break; + case 5: + if (!base.slice_del()) + { + return false; + } + break; + case 6: + if (!r_R2()) + { + return false; + } + if (!base.slice_del()) + { + return false; + } + break; + } + return true; + }; + + /** @return {boolean} */ + function r_Step_4() { + var /** number */ among_var; + base.ket = base.cursor; + among_var = base.find_among_b(a_9); + if (among_var == 0) + { + return false; + } + base.bra = base.cursor; + if (!r_R2()) + { + return false; + } + switch (among_var) { + case 1: + if (!base.slice_del()) + { + return false; + } + break; + case 2: + lab0: { + /** @const */ var /** number */ v_1 = base.limit - base.cursor; + lab1: { + if (!(base.eq_s_b("s"))) + { + break lab1; + } + break lab0; + } + base.cursor = base.limit - v_1; + if (!(base.eq_s_b("t"))) + { + return false; + } + } + if (!base.slice_del()) + { + return false; + } + break; + } + return true; + }; + + /** @return {boolean} */ + function r_Step_5() { + var /** number */ among_var; + base.ket = base.cursor; + among_var = base.find_among_b(a_10); + if (among_var == 0) + { + return false; + } + base.bra = base.cursor; + switch (among_var) { + case 1: + lab0: { + lab1: { + if (!r_R2()) + { + break lab1; + } + break lab0; + } + if (!r_R1()) + { + return false; + } + { + /** @const */ var /** number */ v_1 = base.limit - base.cursor; + lab2: { + if (!r_shortv()) + { + break lab2; + } + return false; + } + base.cursor = base.limit - v_1; + } + } + if (!base.slice_del()) + { + return false; + } + break; + case 2: + if (!r_R2()) + { + return false; + } + if (!(base.eq_s_b("l"))) + { + return false; + } + if (!base.slice_del()) + { + return false; + } + break; + } + return true; + }; + + /** @return {boolean} */ + function r_exception1() { + var /** number */ among_var; + base.bra = base.cursor; + among_var = base.find_among(a_11); + if (among_var == 0) + { + return false; + } + base.ket = base.cursor; + if (base.cursor < base.limit) + { + return false; + } + switch (among_var) { + case 1: + if (!base.slice_from("sky")) + { + return false; + } + break; + case 2: + if (!base.slice_from("idl")) + { + return false; + } + break; + case 3: + if (!base.slice_from("gentl")) + { + return false; + } + break; + case 4: + if (!base.slice_from("ugli")) + { + return false; + } + break; + case 5: + if (!base.slice_from("earli")) + { + return false; + } + break; + case 6: + if (!base.slice_from("onli")) + { + return false; + } + break; + case 7: + if (!base.slice_from("singl")) + { + return false; + } + break; + } + return true; + }; + + /** @return {boolean} */ + function r_postlude() { + if (!B_Y_found) + { + return false; + } + while(true) + { + /** @const */ var /** number */ v_1 = base.cursor; + lab0: { + golab1: while(true) + { + /** @const */ var /** number */ v_2 = base.cursor; + lab2: { + base.bra = base.cursor; + if (!(base.eq_s("Y"))) + { + break lab2; + } + base.ket = base.cursor; + base.cursor = v_2; + break golab1; + } + base.cursor = v_2; + if (base.cursor >= base.limit) + { + break lab0; + } + base.cursor++; + } + if (!base.slice_from("y")) + { + return false; + } + continue; + } + base.cursor = v_1; + break; + } + return true; + }; + + this.stem = /** @return {boolean} */ function() { + lab0: { + /** @const */ var /** number */ v_1 = base.cursor; + lab1: { + if (!r_exception1()) + { + break lab1; + } + break lab0; + } + base.cursor = v_1; + lab2: { + { + /** @const */ var /** number */ v_2 = base.cursor; + lab3: { + { + /** @const */ var /** number */ c1 = base.cursor + 3; + if (c1 > base.limit) + { + break lab3; + } + base.cursor = c1; + } + break lab2; + } + base.cursor = v_2; + } + break lab0; + } + base.cursor = v_1; + r_prelude(); + r_mark_regions(); + base.limit_backward = base.cursor; base.cursor = base.limit; + /** @const */ var /** number */ v_3 = base.limit - base.cursor; + r_Step_1a(); + base.cursor = base.limit - v_3; + /** @const */ var /** number */ v_4 = base.limit - base.cursor; + r_Step_1b(); + base.cursor = base.limit - v_4; + /** @const */ var /** number */ v_5 = base.limit - base.cursor; + r_Step_1c(); + base.cursor = base.limit - v_5; + /** @const */ var /** number */ v_6 = base.limit - base.cursor; + r_Step_2(); + base.cursor = base.limit - v_6; + /** @const */ var /** number */ v_7 = base.limit - base.cursor; + r_Step_3(); + base.cursor = base.limit - v_7; + /** @const */ var /** number */ v_8 = base.limit - base.cursor; + r_Step_4(); + base.cursor = base.limit - v_8; + /** @const */ var /** number */ v_9 = base.limit - base.cursor; + r_Step_5(); + base.cursor = base.limit - v_9; + base.cursor = base.limit_backward; + /** @const */ var /** number */ v_10 = base.cursor; + r_postlude(); + base.cursor = v_10; + } + return true; + }; + + /**@return{string}*/ + this['stemWord'] = function(/**string*/word) { + base.setCurrent(word); + this.stem(); + return base.getCurrent(); + }; +}; diff --git a/docs/latest/_static/file.png b/docs/latest/_static/file.png new file mode 100644 index 00000000..a858a410 Binary files /dev/null and b/docs/latest/_static/file.png differ diff --git a/docs/latest/_static/fonts/FontAwesome.otf b/docs/latest/_static/fonts/FontAwesome.otf new file mode 100644 index 00000000..401ec0f3 Binary files /dev/null and b/docs/latest/_static/fonts/FontAwesome.otf differ diff --git a/docs/latest/_static/fonts/Lato/lato-bold.eot b/docs/latest/_static/fonts/Lato/lato-bold.eot new file mode 100644 index 00000000..3361183a Binary files /dev/null and b/docs/latest/_static/fonts/Lato/lato-bold.eot differ diff --git a/docs/latest/_static/fonts/Lato/lato-bold.ttf b/docs/latest/_static/fonts/Lato/lato-bold.ttf new file mode 100644 index 00000000..29f691d5 Binary files /dev/null and b/docs/latest/_static/fonts/Lato/lato-bold.ttf differ diff --git a/docs/latest/_static/fonts/Lato/lato-bold.woff b/docs/latest/_static/fonts/Lato/lato-bold.woff new file mode 100644 index 00000000..c6dff51f Binary files /dev/null and b/docs/latest/_static/fonts/Lato/lato-bold.woff differ diff --git a/docs/latest/_static/fonts/Lato/lato-bold.woff2 b/docs/latest/_static/fonts/Lato/lato-bold.woff2 new file mode 100644 index 00000000..bb195043 Binary files /dev/null and b/docs/latest/_static/fonts/Lato/lato-bold.woff2 differ diff --git a/docs/latest/_static/fonts/Lato/lato-bolditalic.eot b/docs/latest/_static/fonts/Lato/lato-bolditalic.eot new file mode 100644 index 00000000..3d415493 Binary files /dev/null and b/docs/latest/_static/fonts/Lato/lato-bolditalic.eot differ diff --git a/docs/latest/_static/fonts/Lato/lato-bolditalic.ttf b/docs/latest/_static/fonts/Lato/lato-bolditalic.ttf new file mode 100644 index 00000000..f402040b Binary files /dev/null and b/docs/latest/_static/fonts/Lato/lato-bolditalic.ttf differ diff --git a/docs/latest/_static/fonts/Lato/lato-bolditalic.woff b/docs/latest/_static/fonts/Lato/lato-bolditalic.woff new file mode 100644 index 00000000..88ad05b9 Binary files /dev/null and b/docs/latest/_static/fonts/Lato/lato-bolditalic.woff differ diff --git a/docs/latest/_static/fonts/Lato/lato-bolditalic.woff2 b/docs/latest/_static/fonts/Lato/lato-bolditalic.woff2 new file mode 100644 index 00000000..c4e3d804 Binary files /dev/null and b/docs/latest/_static/fonts/Lato/lato-bolditalic.woff2 differ diff --git a/docs/latest/_static/fonts/Lato/lato-italic.eot b/docs/latest/_static/fonts/Lato/lato-italic.eot new file mode 100644 index 00000000..3f826421 Binary files /dev/null and b/docs/latest/_static/fonts/Lato/lato-italic.eot differ diff --git a/docs/latest/_static/fonts/Lato/lato-italic.ttf b/docs/latest/_static/fonts/Lato/lato-italic.ttf new file mode 100644 index 00000000..b4bfc9b2 Binary files /dev/null and b/docs/latest/_static/fonts/Lato/lato-italic.ttf differ diff --git a/docs/latest/_static/fonts/Lato/lato-italic.woff b/docs/latest/_static/fonts/Lato/lato-italic.woff new file mode 100644 index 00000000..76114bc0 Binary files /dev/null and b/docs/latest/_static/fonts/Lato/lato-italic.woff differ diff --git a/docs/latest/_static/fonts/Lato/lato-italic.woff2 b/docs/latest/_static/fonts/Lato/lato-italic.woff2 new file mode 100644 index 00000000..3404f37e Binary files /dev/null and b/docs/latest/_static/fonts/Lato/lato-italic.woff2 differ diff --git a/docs/latest/_static/fonts/Lato/lato-regular.eot b/docs/latest/_static/fonts/Lato/lato-regular.eot new file mode 100644 index 00000000..11e3f2a5 Binary files /dev/null and b/docs/latest/_static/fonts/Lato/lato-regular.eot differ diff --git a/docs/latest/_static/fonts/Lato/lato-regular.ttf b/docs/latest/_static/fonts/Lato/lato-regular.ttf new file mode 100644 index 00000000..74decd9e Binary files /dev/null and b/docs/latest/_static/fonts/Lato/lato-regular.ttf differ diff --git a/docs/latest/_static/fonts/Lato/lato-regular.woff b/docs/latest/_static/fonts/Lato/lato-regular.woff new file mode 100644 index 00000000..ae1307ff Binary files /dev/null and b/docs/latest/_static/fonts/Lato/lato-regular.woff differ diff --git a/docs/latest/_static/fonts/Lato/lato-regular.woff2 b/docs/latest/_static/fonts/Lato/lato-regular.woff2 new file mode 100644 index 00000000..3bf98433 Binary files /dev/null and b/docs/latest/_static/fonts/Lato/lato-regular.woff2 differ diff --git a/docs/latest/_static/fonts/RobotoSlab/roboto-slab-v7-bold.eot b/docs/latest/_static/fonts/RobotoSlab/roboto-slab-v7-bold.eot new file mode 100644 index 00000000..79dc8efe Binary files /dev/null and b/docs/latest/_static/fonts/RobotoSlab/roboto-slab-v7-bold.eot differ diff --git a/docs/latest/_static/fonts/RobotoSlab/roboto-slab-v7-bold.ttf b/docs/latest/_static/fonts/RobotoSlab/roboto-slab-v7-bold.ttf new file mode 100644 index 00000000..df5d1df2 Binary files /dev/null and b/docs/latest/_static/fonts/RobotoSlab/roboto-slab-v7-bold.ttf differ diff --git a/docs/latest/_static/fonts/RobotoSlab/roboto-slab-v7-bold.woff b/docs/latest/_static/fonts/RobotoSlab/roboto-slab-v7-bold.woff new file mode 100644 index 00000000..6cb60000 Binary files /dev/null and b/docs/latest/_static/fonts/RobotoSlab/roboto-slab-v7-bold.woff differ diff --git a/docs/latest/_static/fonts/RobotoSlab/roboto-slab-v7-bold.woff2 b/docs/latest/_static/fonts/RobotoSlab/roboto-slab-v7-bold.woff2 new file mode 100644 index 00000000..7059e231 Binary files /dev/null and b/docs/latest/_static/fonts/RobotoSlab/roboto-slab-v7-bold.woff2 differ diff --git a/docs/latest/_static/fonts/RobotoSlab/roboto-slab-v7-regular.eot b/docs/latest/_static/fonts/RobotoSlab/roboto-slab-v7-regular.eot new file mode 100644 index 00000000..2f7ca78a Binary files /dev/null and b/docs/latest/_static/fonts/RobotoSlab/roboto-slab-v7-regular.eot differ diff --git a/docs/latest/_static/fonts/RobotoSlab/roboto-slab-v7-regular.ttf b/docs/latest/_static/fonts/RobotoSlab/roboto-slab-v7-regular.ttf new file mode 100644 index 00000000..eb52a790 Binary files /dev/null and b/docs/latest/_static/fonts/RobotoSlab/roboto-slab-v7-regular.ttf differ diff --git a/docs/latest/_static/fonts/RobotoSlab/roboto-slab-v7-regular.woff b/docs/latest/_static/fonts/RobotoSlab/roboto-slab-v7-regular.woff new file mode 100644 index 00000000..f815f63f Binary files /dev/null and b/docs/latest/_static/fonts/RobotoSlab/roboto-slab-v7-regular.woff differ diff --git a/docs/latest/_static/fonts/RobotoSlab/roboto-slab-v7-regular.woff2 b/docs/latest/_static/fonts/RobotoSlab/roboto-slab-v7-regular.woff2 new file mode 100644 index 00000000..f2c76e5b Binary files /dev/null and b/docs/latest/_static/fonts/RobotoSlab/roboto-slab-v7-regular.woff2 differ diff --git a/docs/latest/_static/fonts/fontawesome-webfont.eot b/docs/latest/_static/fonts/fontawesome-webfont.eot new file mode 100644 index 00000000..e9f60ca9 Binary files /dev/null and b/docs/latest/_static/fonts/fontawesome-webfont.eot differ diff --git a/docs/latest/_static/fonts/fontawesome-webfont.svg b/docs/latest/_static/fonts/fontawesome-webfont.svg new file mode 100644 index 00000000..855c845e --- /dev/null +++ b/docs/latest/_static/fonts/fontawesome-webfont.svg @@ -0,0 +1,2671 @@ + + + + +Created by FontForge 20120731 at Mon Oct 24 17:37:40 2016 + By ,,, +Copyright Dave Gandy 2016. All rights reserved. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/latest/_static/fonts/fontawesome-webfont.ttf b/docs/latest/_static/fonts/fontawesome-webfont.ttf new file mode 100644 index 00000000..35acda2f Binary files /dev/null and b/docs/latest/_static/fonts/fontawesome-webfont.ttf differ diff --git a/docs/latest/_static/fonts/fontawesome-webfont.woff b/docs/latest/_static/fonts/fontawesome-webfont.woff new file mode 100644 index 00000000..400014a4 Binary files /dev/null and b/docs/latest/_static/fonts/fontawesome-webfont.woff differ diff --git a/docs/latest/_static/fonts/fontawesome-webfont.woff2 b/docs/latest/_static/fonts/fontawesome-webfont.woff2 new file mode 100644 index 00000000..4d13fc60 Binary files /dev/null and b/docs/latest/_static/fonts/fontawesome-webfont.woff2 differ diff --git a/docs/latest/_static/jquery-3.4.1.js b/docs/latest/_static/jquery-3.4.1.js new file mode 100644 index 00000000..773ad95c --- /dev/null +++ b/docs/latest/_static/jquery-3.4.1.js @@ -0,0 +1,10598 @@ +/*! + * jQuery JavaScript Library v3.4.1 + * https://jquery.com/ + * + * Includes Sizzle.js + * https://sizzlejs.com/ + * + * Copyright JS Foundation and other contributors + * Released under the MIT license + * https://jquery.org/license + * + * Date: 2019-05-01T21:04Z + */ +( function( global, factory ) { + + "use strict"; + + if ( typeof module === "object" && typeof module.exports === "object" ) { + + // For CommonJS and CommonJS-like environments where a proper `window` + // is present, execute the factory and get jQuery. + // For environments that do not have a `window` with a `document` + // (such as Node.js), expose a factory as module.exports. + // This accentuates the need for the creation of a real `window`. + // e.g. var jQuery = require("jquery")(window); + // See ticket #14549 for more info. + module.exports = global.document ? + factory( global, true ) : + function( w ) { + if ( !w.document ) { + throw new Error( "jQuery requires a window with a document" ); + } + return factory( w ); + }; + } else { + factory( global ); + } + +// Pass this if window is not defined yet +} )( typeof window !== "undefined" ? window : this, function( window, noGlobal ) { + +// Edge <= 12 - 13+, Firefox <=18 - 45+, IE 10 - 11, Safari 5.1 - 9+, iOS 6 - 9.1 +// throw exceptions when non-strict code (e.g., ASP.NET 4.5) accesses strict mode +// arguments.callee.caller (trac-13335). But as of jQuery 3.0 (2016), strict mode should be common +// enough that all such attempts are guarded in a try block. +"use strict"; + +var arr = []; + +var document = window.document; + +var getProto = Object.getPrototypeOf; + +var slice = arr.slice; + +var concat = arr.concat; + +var push = arr.push; + +var indexOf = arr.indexOf; + +var class2type = {}; + +var toString = class2type.toString; + +var hasOwn = class2type.hasOwnProperty; + +var fnToString = hasOwn.toString; + +var ObjectFunctionString = fnToString.call( Object ); + +var support = {}; + +var isFunction = function isFunction( obj ) { + + // Support: Chrome <=57, Firefox <=52 + // In some browsers, typeof returns "function" for HTML elements + // (i.e., `typeof document.createElement( "object" ) === "function"`). + // We don't want to classify *any* DOM node as a function. + return typeof obj === "function" && typeof obj.nodeType !== "number"; + }; + + +var isWindow = function isWindow( obj ) { + return obj != null && obj === obj.window; + }; + + + + + var preservedScriptAttributes = { + type: true, + src: true, + nonce: true, + noModule: true + }; + + function DOMEval( code, node, doc ) { + doc = doc || document; + + var i, val, + script = doc.createElement( "script" ); + + script.text = code; + if ( node ) { + for ( i in preservedScriptAttributes ) { + + // Support: Firefox 64+, Edge 18+ + // Some browsers don't support the "nonce" property on scripts. + // On the other hand, just using `getAttribute` is not enough as + // the `nonce` attribute is reset to an empty string whenever it + // becomes browsing-context connected. + // See https://github.com/whatwg/html/issues/2369 + // See https://html.spec.whatwg.org/#nonce-attributes + // The `node.getAttribute` check was added for the sake of + // `jQuery.globalEval` so that it can fake a nonce-containing node + // via an object. + val = node[ i ] || node.getAttribute && node.getAttribute( i ); + if ( val ) { + script.setAttribute( i, val ); + } + } + } + doc.head.appendChild( script ).parentNode.removeChild( script ); + } + + +function toType( obj ) { + if ( obj == null ) { + return obj + ""; + } + + // Support: Android <=2.3 only (functionish RegExp) + return typeof obj === "object" || typeof obj === "function" ? + class2type[ toString.call( obj ) ] || "object" : + typeof obj; +} +/* global Symbol */ +// Defining this global in .eslintrc.json would create a danger of using the global +// unguarded in another place, it seems safer to define global only for this module + + + +var + version = "3.4.1", + + // Define a local copy of jQuery + jQuery = function( selector, context ) { + + // The jQuery object is actually just the init constructor 'enhanced' + // Need init if jQuery is called (just allow error to be thrown if not included) + return new jQuery.fn.init( selector, context ); + }, + + // Support: Android <=4.0 only + // Make sure we trim BOM and NBSP + rtrim = /^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g; + +jQuery.fn = jQuery.prototype = { + + // The current version of jQuery being used + jquery: version, + + constructor: jQuery, + + // The default length of a jQuery object is 0 + length: 0, + + toArray: function() { + return slice.call( this ); + }, + + // Get the Nth element in the matched element set OR + // Get the whole matched element set as a clean array + get: function( num ) { + + // Return all the elements in a clean array + if ( num == null ) { + return slice.call( this ); + } + + // Return just the one element from the set + return num < 0 ? this[ num + this.length ] : this[ num ]; + }, + + // Take an array of elements and push it onto the stack + // (returning the new matched element set) + pushStack: function( elems ) { + + // Build a new jQuery matched element set + var ret = jQuery.merge( this.constructor(), elems ); + + // Add the old object onto the stack (as a reference) + ret.prevObject = this; + + // Return the newly-formed element set + return ret; + }, + + // Execute a callback for every element in the matched set. + each: function( callback ) { + return jQuery.each( this, callback ); + }, + + map: function( callback ) { + return this.pushStack( jQuery.map( this, function( elem, i ) { + return callback.call( elem, i, elem ); + } ) ); + }, + + slice: function() { + return this.pushStack( slice.apply( this, arguments ) ); + }, + + first: function() { + return this.eq( 0 ); + }, + + last: function() { + return this.eq( -1 ); + }, + + eq: function( i ) { + var len = this.length, + j = +i + ( i < 0 ? len : 0 ); + return this.pushStack( j >= 0 && j < len ? [ this[ j ] ] : [] ); + }, + + end: function() { + return this.prevObject || this.constructor(); + }, + + // For internal use only. + // Behaves like an Array's method, not like a jQuery method. + push: push, + sort: arr.sort, + splice: arr.splice +}; + +jQuery.extend = jQuery.fn.extend = function() { + var options, name, src, copy, copyIsArray, clone, + target = arguments[ 0 ] || {}, + i = 1, + length = arguments.length, + deep = false; + + // Handle a deep copy situation + if ( typeof target === "boolean" ) { + deep = target; + + // Skip the boolean and the target + target = arguments[ i ] || {}; + i++; + } + + // Handle case when target is a string or something (possible in deep copy) + if ( typeof target !== "object" && !isFunction( target ) ) { + target = {}; + } + + // Extend jQuery itself if only one argument is passed + if ( i === length ) { + target = this; + i--; + } + + for ( ; i < length; i++ ) { + + // Only deal with non-null/undefined values + if ( ( options = arguments[ i ] ) != null ) { + + // Extend the base object + for ( name in options ) { + copy = options[ name ]; + + // Prevent Object.prototype pollution + // Prevent never-ending loop + if ( name === "__proto__" || target === copy ) { + continue; + } + + // Recurse if we're merging plain objects or arrays + if ( deep && copy && ( jQuery.isPlainObject( copy ) || + ( copyIsArray = Array.isArray( copy ) ) ) ) { + src = target[ name ]; + + // Ensure proper type for the source value + if ( copyIsArray && !Array.isArray( src ) ) { + clone = []; + } else if ( !copyIsArray && !jQuery.isPlainObject( src ) ) { + clone = {}; + } else { + clone = src; + } + copyIsArray = false; + + // Never move original objects, clone them + target[ name ] = jQuery.extend( deep, clone, copy ); + + // Don't bring in undefined values + } else if ( copy !== undefined ) { + target[ name ] = copy; + } + } + } + } + + // Return the modified object + return target; +}; + +jQuery.extend( { + + // Unique for each copy of jQuery on the page + expando: "jQuery" + ( version + Math.random() ).replace( /\D/g, "" ), + + // Assume jQuery is ready without the ready module + isReady: true, + + error: function( msg ) { + throw new Error( msg ); + }, + + noop: function() {}, + + isPlainObject: function( obj ) { + var proto, Ctor; + + // Detect obvious negatives + // Use toString instead of jQuery.type to catch host objects + if ( !obj || toString.call( obj ) !== "[object Object]" ) { + return false; + } + + proto = getProto( obj ); + + // Objects with no prototype (e.g., `Object.create( null )`) are plain + if ( !proto ) { + return true; + } + + // Objects with prototype are plain iff they were constructed by a global Object function + Ctor = hasOwn.call( proto, "constructor" ) && proto.constructor; + return typeof Ctor === "function" && fnToString.call( Ctor ) === ObjectFunctionString; + }, + + isEmptyObject: function( obj ) { + var name; + + for ( name in obj ) { + return false; + } + return true; + }, + + // Evaluates a script in a global context + globalEval: function( code, options ) { + DOMEval( code, { nonce: options && options.nonce } ); + }, + + each: function( obj, callback ) { + var length, i = 0; + + if ( isArrayLike( obj ) ) { + length = obj.length; + for ( ; i < length; i++ ) { + if ( callback.call( obj[ i ], i, obj[ i ] ) === false ) { + break; + } + } + } else { + for ( i in obj ) { + if ( callback.call( obj[ i ], i, obj[ i ] ) === false ) { + break; + } + } + } + + return obj; + }, + + // Support: Android <=4.0 only + trim: function( text ) { + return text == null ? + "" : + ( text + "" ).replace( rtrim, "" ); + }, + + // results is for internal usage only + makeArray: function( arr, results ) { + var ret = results || []; + + if ( arr != null ) { + if ( isArrayLike( Object( arr ) ) ) { + jQuery.merge( ret, + typeof arr === "string" ? + [ arr ] : arr + ); + } else { + push.call( ret, arr ); + } + } + + return ret; + }, + + inArray: function( elem, arr, i ) { + return arr == null ? -1 : indexOf.call( arr, elem, i ); + }, + + // Support: Android <=4.0 only, PhantomJS 1 only + // push.apply(_, arraylike) throws on ancient WebKit + merge: function( first, second ) { + var len = +second.length, + j = 0, + i = first.length; + + for ( ; j < len; j++ ) { + first[ i++ ] = second[ j ]; + } + + first.length = i; + + return first; + }, + + grep: function( elems, callback, invert ) { + var callbackInverse, + matches = [], + i = 0, + length = elems.length, + callbackExpect = !invert; + + // Go through the array, only saving the items + // that pass the validator function + for ( ; i < length; i++ ) { + callbackInverse = !callback( elems[ i ], i ); + if ( callbackInverse !== callbackExpect ) { + matches.push( elems[ i ] ); + } + } + + return matches; + }, + + // arg is for internal usage only + map: function( elems, callback, arg ) { + var length, value, + i = 0, + ret = []; + + // Go through the array, translating each of the items to their new values + if ( isArrayLike( elems ) ) { + length = elems.length; + for ( ; i < length; i++ ) { + value = callback( elems[ i ], i, arg ); + + if ( value != null ) { + ret.push( value ); + } + } + + // Go through every key on the object, + } else { + for ( i in elems ) { + value = callback( elems[ i ], i, arg ); + + if ( value != null ) { + ret.push( value ); + } + } + } + + // Flatten any nested arrays + return concat.apply( [], ret ); + }, + + // A global GUID counter for objects + guid: 1, + + // jQuery.support is not used in Core but other projects attach their + // properties to it so it needs to exist. + support: support +} ); + +if ( typeof Symbol === "function" ) { + jQuery.fn[ Symbol.iterator ] = arr[ Symbol.iterator ]; +} + +// Populate the class2type map +jQuery.each( "Boolean Number String Function Array Date RegExp Object Error Symbol".split( " " ), +function( i, name ) { + class2type[ "[object " + name + "]" ] = name.toLowerCase(); +} ); + +function isArrayLike( obj ) { + + // Support: real iOS 8.2 only (not reproducible in simulator) + // `in` check used to prevent JIT error (gh-2145) + // hasOwn isn't used here due to false negatives + // regarding Nodelist length in IE + var length = !!obj && "length" in obj && obj.length, + type = toType( obj ); + + if ( isFunction( obj ) || isWindow( obj ) ) { + return false; + } + + return type === "array" || length === 0 || + typeof length === "number" && length > 0 && ( length - 1 ) in obj; +} +var Sizzle = +/*! + * Sizzle CSS Selector Engine v2.3.4 + * https://sizzlejs.com/ + * + * Copyright JS Foundation and other contributors + * Released under the MIT license + * https://js.foundation/ + * + * Date: 2019-04-08 + */ +(function( window ) { + +var i, + support, + Expr, + getText, + isXML, + tokenize, + compile, + select, + outermostContext, + sortInput, + hasDuplicate, + + // Local document vars + setDocument, + document, + docElem, + documentIsHTML, + rbuggyQSA, + rbuggyMatches, + matches, + contains, + + // Instance-specific data + expando = "sizzle" + 1 * new Date(), + preferredDoc = window.document, + dirruns = 0, + done = 0, + classCache = createCache(), + tokenCache = createCache(), + compilerCache = createCache(), + nonnativeSelectorCache = createCache(), + sortOrder = function( a, b ) { + if ( a === b ) { + hasDuplicate = true; + } + return 0; + }, + + // Instance methods + hasOwn = ({}).hasOwnProperty, + arr = [], + pop = arr.pop, + push_native = arr.push, + push = arr.push, + slice = arr.slice, + // Use a stripped-down indexOf as it's faster than native + // https://jsperf.com/thor-indexof-vs-for/5 + indexOf = function( list, elem ) { + var i = 0, + len = list.length; + for ( ; i < len; i++ ) { + if ( list[i] === elem ) { + return i; + } + } + return -1; + }, + + booleans = "checked|selected|async|autofocus|autoplay|controls|defer|disabled|hidden|ismap|loop|multiple|open|readonly|required|scoped", + + // Regular expressions + + // http://www.w3.org/TR/css3-selectors/#whitespace + whitespace = "[\\x20\\t\\r\\n\\f]", + + // http://www.w3.org/TR/CSS21/syndata.html#value-def-identifier + identifier = "(?:\\\\.|[\\w-]|[^\0-\\xa0])+", + + // Attribute selectors: http://www.w3.org/TR/selectors/#attribute-selectors + attributes = "\\[" + whitespace + "*(" + identifier + ")(?:" + whitespace + + // Operator (capture 2) + "*([*^$|!~]?=)" + whitespace + + // "Attribute values must be CSS identifiers [capture 5] or strings [capture 3 or capture 4]" + "*(?:'((?:\\\\.|[^\\\\'])*)'|\"((?:\\\\.|[^\\\\\"])*)\"|(" + identifier + "))|)" + whitespace + + "*\\]", + + pseudos = ":(" + identifier + ")(?:\\((" + + // To reduce the number of selectors needing tokenize in the preFilter, prefer arguments: + // 1. quoted (capture 3; capture 4 or capture 5) + "('((?:\\\\.|[^\\\\'])*)'|\"((?:\\\\.|[^\\\\\"])*)\")|" + + // 2. simple (capture 6) + "((?:\\\\.|[^\\\\()[\\]]|" + attributes + ")*)|" + + // 3. anything else (capture 2) + ".*" + + ")\\)|)", + + // Leading and non-escaped trailing whitespace, capturing some non-whitespace characters preceding the latter + rwhitespace = new RegExp( whitespace + "+", "g" ), + rtrim = new RegExp( "^" + whitespace + "+|((?:^|[^\\\\])(?:\\\\.)*)" + whitespace + "+$", "g" ), + + rcomma = new RegExp( "^" + whitespace + "*," + whitespace + "*" ), + rcombinators = new RegExp( "^" + whitespace + "*([>+~]|" + whitespace + ")" + whitespace + "*" ), + rdescend = new RegExp( whitespace + "|>" ), + + rpseudo = new RegExp( pseudos ), + ridentifier = new RegExp( "^" + identifier + "$" ), + + matchExpr = { + "ID": new RegExp( "^#(" + identifier + ")" ), + "CLASS": new RegExp( "^\\.(" + identifier + ")" ), + "TAG": new RegExp( "^(" + identifier + "|[*])" ), + "ATTR": new RegExp( "^" + attributes ), + "PSEUDO": new RegExp( "^" + pseudos ), + "CHILD": new RegExp( "^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\(" + whitespace + + "*(even|odd|(([+-]|)(\\d*)n|)" + whitespace + "*(?:([+-]|)" + whitespace + + "*(\\d+)|))" + whitespace + "*\\)|)", "i" ), + "bool": new RegExp( "^(?:" + booleans + ")$", "i" ), + // For use in libraries implementing .is() + // We use this for POS matching in `select` + "needsContext": new RegExp( "^" + whitespace + "*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\(" + + whitespace + "*((?:-\\d)?\\d*)" + whitespace + "*\\)|)(?=[^-]|$)", "i" ) + }, + + rhtml = /HTML$/i, + rinputs = /^(?:input|select|textarea|button)$/i, + rheader = /^h\d$/i, + + rnative = /^[^{]+\{\s*\[native \w/, + + // Easily-parseable/retrievable ID or TAG or CLASS selectors + rquickExpr = /^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/, + + rsibling = /[+~]/, + + // CSS escapes + // http://www.w3.org/TR/CSS21/syndata.html#escaped-characters + runescape = new RegExp( "\\\\([\\da-f]{1,6}" + whitespace + "?|(" + whitespace + ")|.)", "ig" ), + funescape = function( _, escaped, escapedWhitespace ) { + var high = "0x" + escaped - 0x10000; + // NaN means non-codepoint + // Support: Firefox<24 + // Workaround erroneous numeric interpretation of +"0x" + return high !== high || escapedWhitespace ? + escaped : + high < 0 ? + // BMP codepoint + String.fromCharCode( high + 0x10000 ) : + // Supplemental Plane codepoint (surrogate pair) + String.fromCharCode( high >> 10 | 0xD800, high & 0x3FF | 0xDC00 ); + }, + + // CSS string/identifier serialization + // https://drafts.csswg.org/cssom/#common-serializing-idioms + rcssescape = /([\0-\x1f\x7f]|^-?\d)|^-$|[^\0-\x1f\x7f-\uFFFF\w-]/g, + fcssescape = function( ch, asCodePoint ) { + if ( asCodePoint ) { + + // U+0000 NULL becomes U+FFFD REPLACEMENT CHARACTER + if ( ch === "\0" ) { + return "\uFFFD"; + } + + // Control characters and (dependent upon position) numbers get escaped as code points + return ch.slice( 0, -1 ) + "\\" + ch.charCodeAt( ch.length - 1 ).toString( 16 ) + " "; + } + + // Other potentially-special ASCII characters get backslash-escaped + return "\\" + ch; + }, + + // Used for iframes + // See setDocument() + // Removing the function wrapper causes a "Permission Denied" + // error in IE + unloadHandler = function() { + setDocument(); + }, + + inDisabledFieldset = addCombinator( + function( elem ) { + return elem.disabled === true && elem.nodeName.toLowerCase() === "fieldset"; + }, + { dir: "parentNode", next: "legend" } + ); + +// Optimize for push.apply( _, NodeList ) +try { + push.apply( + (arr = slice.call( preferredDoc.childNodes )), + preferredDoc.childNodes + ); + // Support: Android<4.0 + // Detect silently failing push.apply + arr[ preferredDoc.childNodes.length ].nodeType; +} catch ( e ) { + push = { apply: arr.length ? + + // Leverage slice if possible + function( target, els ) { + push_native.apply( target, slice.call(els) ); + } : + + // Support: IE<9 + // Otherwise append directly + function( target, els ) { + var j = target.length, + i = 0; + // Can't trust NodeList.length + while ( (target[j++] = els[i++]) ) {} + target.length = j - 1; + } + }; +} + +function Sizzle( selector, context, results, seed ) { + var m, i, elem, nid, match, groups, newSelector, + newContext = context && context.ownerDocument, + + // nodeType defaults to 9, since context defaults to document + nodeType = context ? context.nodeType : 9; + + results = results || []; + + // Return early from calls with invalid selector or context + if ( typeof selector !== "string" || !selector || + nodeType !== 1 && nodeType !== 9 && nodeType !== 11 ) { + + return results; + } + + // Try to shortcut find operations (as opposed to filters) in HTML documents + if ( !seed ) { + + if ( ( context ? context.ownerDocument || context : preferredDoc ) !== document ) { + setDocument( context ); + } + context = context || document; + + if ( documentIsHTML ) { + + // If the selector is sufficiently simple, try using a "get*By*" DOM method + // (excepting DocumentFragment context, where the methods don't exist) + if ( nodeType !== 11 && (match = rquickExpr.exec( selector )) ) { + + // ID selector + if ( (m = match[1]) ) { + + // Document context + if ( nodeType === 9 ) { + if ( (elem = context.getElementById( m )) ) { + + // Support: IE, Opera, Webkit + // TODO: identify versions + // getElementById can match elements by name instead of ID + if ( elem.id === m ) { + results.push( elem ); + return results; + } + } else { + return results; + } + + // Element context + } else { + + // Support: IE, Opera, Webkit + // TODO: identify versions + // getElementById can match elements by name instead of ID + if ( newContext && (elem = newContext.getElementById( m )) && + contains( context, elem ) && + elem.id === m ) { + + results.push( elem ); + return results; + } + } + + // Type selector + } else if ( match[2] ) { + push.apply( results, context.getElementsByTagName( selector ) ); + return results; + + // Class selector + } else if ( (m = match[3]) && support.getElementsByClassName && + context.getElementsByClassName ) { + + push.apply( results, context.getElementsByClassName( m ) ); + return results; + } + } + + // Take advantage of querySelectorAll + if ( support.qsa && + !nonnativeSelectorCache[ selector + " " ] && + (!rbuggyQSA || !rbuggyQSA.test( selector )) && + + // Support: IE 8 only + // Exclude object elements + (nodeType !== 1 || context.nodeName.toLowerCase() !== "object") ) { + + newSelector = selector; + newContext = context; + + // qSA considers elements outside a scoping root when evaluating child or + // descendant combinators, which is not what we want. + // In such cases, we work around the behavior by prefixing every selector in the + // list with an ID selector referencing the scope context. + // Thanks to Andrew Dupont for this technique. + if ( nodeType === 1 && rdescend.test( selector ) ) { + + // Capture the context ID, setting it first if necessary + if ( (nid = context.getAttribute( "id" )) ) { + nid = nid.replace( rcssescape, fcssescape ); + } else { + context.setAttribute( "id", (nid = expando) ); + } + + // Prefix every selector in the list + groups = tokenize( selector ); + i = groups.length; + while ( i-- ) { + groups[i] = "#" + nid + " " + toSelector( groups[i] ); + } + newSelector = groups.join( "," ); + + // Expand context for sibling selectors + newContext = rsibling.test( selector ) && testContext( context.parentNode ) || + context; + } + + try { + push.apply( results, + newContext.querySelectorAll( newSelector ) + ); + return results; + } catch ( qsaError ) { + nonnativeSelectorCache( selector, true ); + } finally { + if ( nid === expando ) { + context.removeAttribute( "id" ); + } + } + } + } + } + + // All others + return select( selector.replace( rtrim, "$1" ), context, results, seed ); +} + +/** + * Create key-value caches of limited size + * @returns {function(string, object)} Returns the Object data after storing it on itself with + * property name the (space-suffixed) string and (if the cache is larger than Expr.cacheLength) + * deleting the oldest entry + */ +function createCache() { + var keys = []; + + function cache( key, value ) { + // Use (key + " ") to avoid collision with native prototype properties (see Issue #157) + if ( keys.push( key + " " ) > Expr.cacheLength ) { + // Only keep the most recent entries + delete cache[ keys.shift() ]; + } + return (cache[ key + " " ] = value); + } + return cache; +} + +/** + * Mark a function for special use by Sizzle + * @param {Function} fn The function to mark + */ +function markFunction( fn ) { + fn[ expando ] = true; + return fn; +} + +/** + * Support testing using an element + * @param {Function} fn Passed the created element and returns a boolean result + */ +function assert( fn ) { + var el = document.createElement("fieldset"); + + try { + return !!fn( el ); + } catch (e) { + return false; + } finally { + // Remove from its parent by default + if ( el.parentNode ) { + el.parentNode.removeChild( el ); + } + // release memory in IE + el = null; + } +} + +/** + * Adds the same handler for all of the specified attrs + * @param {String} attrs Pipe-separated list of attributes + * @param {Function} handler The method that will be applied + */ +function addHandle( attrs, handler ) { + var arr = attrs.split("|"), + i = arr.length; + + while ( i-- ) { + Expr.attrHandle[ arr[i] ] = handler; + } +} + +/** + * Checks document order of two siblings + * @param {Element} a + * @param {Element} b + * @returns {Number} Returns less than 0 if a precedes b, greater than 0 if a follows b + */ +function siblingCheck( a, b ) { + var cur = b && a, + diff = cur && a.nodeType === 1 && b.nodeType === 1 && + a.sourceIndex - b.sourceIndex; + + // Use IE sourceIndex if available on both nodes + if ( diff ) { + return diff; + } + + // Check if b follows a + if ( cur ) { + while ( (cur = cur.nextSibling) ) { + if ( cur === b ) { + return -1; + } + } + } + + return a ? 1 : -1; +} + +/** + * Returns a function to use in pseudos for input types + * @param {String} type + */ +function createInputPseudo( type ) { + return function( elem ) { + var name = elem.nodeName.toLowerCase(); + return name === "input" && elem.type === type; + }; +} + +/** + * Returns a function to use in pseudos for buttons + * @param {String} type + */ +function createButtonPseudo( type ) { + return function( elem ) { + var name = elem.nodeName.toLowerCase(); + return (name === "input" || name === "button") && elem.type === type; + }; +} + +/** + * Returns a function to use in pseudos for :enabled/:disabled + * @param {Boolean} disabled true for :disabled; false for :enabled + */ +function createDisabledPseudo( disabled ) { + + // Known :disabled false positives: fieldset[disabled] > legend:nth-of-type(n+2) :can-disable + return function( elem ) { + + // Only certain elements can match :enabled or :disabled + // https://html.spec.whatwg.org/multipage/scripting.html#selector-enabled + // https://html.spec.whatwg.org/multipage/scripting.html#selector-disabled + if ( "form" in elem ) { + + // Check for inherited disabledness on relevant non-disabled elements: + // * listed form-associated elements in a disabled fieldset + // https://html.spec.whatwg.org/multipage/forms.html#category-listed + // https://html.spec.whatwg.org/multipage/forms.html#concept-fe-disabled + // * option elements in a disabled optgroup + // https://html.spec.whatwg.org/multipage/forms.html#concept-option-disabled + // All such elements have a "form" property. + if ( elem.parentNode && elem.disabled === false ) { + + // Option elements defer to a parent optgroup if present + if ( "label" in elem ) { + if ( "label" in elem.parentNode ) { + return elem.parentNode.disabled === disabled; + } else { + return elem.disabled === disabled; + } + } + + // Support: IE 6 - 11 + // Use the isDisabled shortcut property to check for disabled fieldset ancestors + return elem.isDisabled === disabled || + + // Where there is no isDisabled, check manually + /* jshint -W018 */ + elem.isDisabled !== !disabled && + inDisabledFieldset( elem ) === disabled; + } + + return elem.disabled === disabled; + + // Try to winnow out elements that can't be disabled before trusting the disabled property. + // Some victims get caught in our net (label, legend, menu, track), but it shouldn't + // even exist on them, let alone have a boolean value. + } else if ( "label" in elem ) { + return elem.disabled === disabled; + } + + // Remaining elements are neither :enabled nor :disabled + return false; + }; +} + +/** + * Returns a function to use in pseudos for positionals + * @param {Function} fn + */ +function createPositionalPseudo( fn ) { + return markFunction(function( argument ) { + argument = +argument; + return markFunction(function( seed, matches ) { + var j, + matchIndexes = fn( [], seed.length, argument ), + i = matchIndexes.length; + + // Match elements found at the specified indexes + while ( i-- ) { + if ( seed[ (j = matchIndexes[i]) ] ) { + seed[j] = !(matches[j] = seed[j]); + } + } + }); + }); +} + +/** + * Checks a node for validity as a Sizzle context + * @param {Element|Object=} context + * @returns {Element|Object|Boolean} The input node if acceptable, otherwise a falsy value + */ +function testContext( context ) { + return context && typeof context.getElementsByTagName !== "undefined" && context; +} + +// Expose support vars for convenience +support = Sizzle.support = {}; + +/** + * Detects XML nodes + * @param {Element|Object} elem An element or a document + * @returns {Boolean} True iff elem is a non-HTML XML node + */ +isXML = Sizzle.isXML = function( elem ) { + var namespace = elem.namespaceURI, + docElem = (elem.ownerDocument || elem).documentElement; + + // Support: IE <=8 + // Assume HTML when documentElement doesn't yet exist, such as inside loading iframes + // https://bugs.jquery.com/ticket/4833 + return !rhtml.test( namespace || docElem && docElem.nodeName || "HTML" ); +}; + +/** + * Sets document-related variables once based on the current document + * @param {Element|Object} [doc] An element or document object to use to set the document + * @returns {Object} Returns the current document + */ +setDocument = Sizzle.setDocument = function( node ) { + var hasCompare, subWindow, + doc = node ? node.ownerDocument || node : preferredDoc; + + // Return early if doc is invalid or already selected + if ( doc === document || doc.nodeType !== 9 || !doc.documentElement ) { + return document; + } + + // Update global variables + document = doc; + docElem = document.documentElement; + documentIsHTML = !isXML( document ); + + // Support: IE 9-11, Edge + // Accessing iframe documents after unload throws "permission denied" errors (jQuery #13936) + if ( preferredDoc !== document && + (subWindow = document.defaultView) && subWindow.top !== subWindow ) { + + // Support: IE 11, Edge + if ( subWindow.addEventListener ) { + subWindow.addEventListener( "unload", unloadHandler, false ); + + // Support: IE 9 - 10 only + } else if ( subWindow.attachEvent ) { + subWindow.attachEvent( "onunload", unloadHandler ); + } + } + + /* Attributes + ---------------------------------------------------------------------- */ + + // Support: IE<8 + // Verify that getAttribute really returns attributes and not properties + // (excepting IE8 booleans) + support.attributes = assert(function( el ) { + el.className = "i"; + return !el.getAttribute("className"); + }); + + /* getElement(s)By* + ---------------------------------------------------------------------- */ + + // Check if getElementsByTagName("*") returns only elements + support.getElementsByTagName = assert(function( el ) { + el.appendChild( document.createComment("") ); + return !el.getElementsByTagName("*").length; + }); + + // Support: IE<9 + support.getElementsByClassName = rnative.test( document.getElementsByClassName ); + + // Support: IE<10 + // Check if getElementById returns elements by name + // The broken getElementById methods don't pick up programmatically-set names, + // so use a roundabout getElementsByName test + support.getById = assert(function( el ) { + docElem.appendChild( el ).id = expando; + return !document.getElementsByName || !document.getElementsByName( expando ).length; + }); + + // ID filter and find + if ( support.getById ) { + Expr.filter["ID"] = function( id ) { + var attrId = id.replace( runescape, funescape ); + return function( elem ) { + return elem.getAttribute("id") === attrId; + }; + }; + Expr.find["ID"] = function( id, context ) { + if ( typeof context.getElementById !== "undefined" && documentIsHTML ) { + var elem = context.getElementById( id ); + return elem ? [ elem ] : []; + } + }; + } else { + Expr.filter["ID"] = function( id ) { + var attrId = id.replace( runescape, funescape ); + return function( elem ) { + var node = typeof elem.getAttributeNode !== "undefined" && + elem.getAttributeNode("id"); + return node && node.value === attrId; + }; + }; + + // Support: IE 6 - 7 only + // getElementById is not reliable as a find shortcut + Expr.find["ID"] = function( id, context ) { + if ( typeof context.getElementById !== "undefined" && documentIsHTML ) { + var node, i, elems, + elem = context.getElementById( id ); + + if ( elem ) { + + // Verify the id attribute + node = elem.getAttributeNode("id"); + if ( node && node.value === id ) { + return [ elem ]; + } + + // Fall back on getElementsByName + elems = context.getElementsByName( id ); + i = 0; + while ( (elem = elems[i++]) ) { + node = elem.getAttributeNode("id"); + if ( node && node.value === id ) { + return [ elem ]; + } + } + } + + return []; + } + }; + } + + // Tag + Expr.find["TAG"] = support.getElementsByTagName ? + function( tag, context ) { + if ( typeof context.getElementsByTagName !== "undefined" ) { + return context.getElementsByTagName( tag ); + + // DocumentFragment nodes don't have gEBTN + } else if ( support.qsa ) { + return context.querySelectorAll( tag ); + } + } : + + function( tag, context ) { + var elem, + tmp = [], + i = 0, + // By happy coincidence, a (broken) gEBTN appears on DocumentFragment nodes too + results = context.getElementsByTagName( tag ); + + // Filter out possible comments + if ( tag === "*" ) { + while ( (elem = results[i++]) ) { + if ( elem.nodeType === 1 ) { + tmp.push( elem ); + } + } + + return tmp; + } + return results; + }; + + // Class + Expr.find["CLASS"] = support.getElementsByClassName && function( className, context ) { + if ( typeof context.getElementsByClassName !== "undefined" && documentIsHTML ) { + return context.getElementsByClassName( className ); + } + }; + + /* QSA/matchesSelector + ---------------------------------------------------------------------- */ + + // QSA and matchesSelector support + + // matchesSelector(:active) reports false when true (IE9/Opera 11.5) + rbuggyMatches = []; + + // qSa(:focus) reports false when true (Chrome 21) + // We allow this because of a bug in IE8/9 that throws an error + // whenever `document.activeElement` is accessed on an iframe + // So, we allow :focus to pass through QSA all the time to avoid the IE error + // See https://bugs.jquery.com/ticket/13378 + rbuggyQSA = []; + + if ( (support.qsa = rnative.test( document.querySelectorAll )) ) { + // Build QSA regex + // Regex strategy adopted from Diego Perini + assert(function( el ) { + // Select is set to empty string on purpose + // This is to test IE's treatment of not explicitly + // setting a boolean content attribute, + // since its presence should be enough + // https://bugs.jquery.com/ticket/12359 + docElem.appendChild( el ).innerHTML = "" + + ""; + + // Support: IE8, Opera 11-12.16 + // Nothing should be selected when empty strings follow ^= or $= or *= + // The test attribute must be unknown in Opera but "safe" for WinRT + // https://msdn.microsoft.com/en-us/library/ie/hh465388.aspx#attribute_section + if ( el.querySelectorAll("[msallowcapture^='']").length ) { + rbuggyQSA.push( "[*^$]=" + whitespace + "*(?:''|\"\")" ); + } + + // Support: IE8 + // Boolean attributes and "value" are not treated correctly + if ( !el.querySelectorAll("[selected]").length ) { + rbuggyQSA.push( "\\[" + whitespace + "*(?:value|" + booleans + ")" ); + } + + // Support: Chrome<29, Android<4.4, Safari<7.0+, iOS<7.0+, PhantomJS<1.9.8+ + if ( !el.querySelectorAll( "[id~=" + expando + "-]" ).length ) { + rbuggyQSA.push("~="); + } + + // Webkit/Opera - :checked should return selected option elements + // http://www.w3.org/TR/2011/REC-css3-selectors-20110929/#checked + // IE8 throws error here and will not see later tests + if ( !el.querySelectorAll(":checked").length ) { + rbuggyQSA.push(":checked"); + } + + // Support: Safari 8+, iOS 8+ + // https://bugs.webkit.org/show_bug.cgi?id=136851 + // In-page `selector#id sibling-combinator selector` fails + if ( !el.querySelectorAll( "a#" + expando + "+*" ).length ) { + rbuggyQSA.push(".#.+[+~]"); + } + }); + + assert(function( el ) { + el.innerHTML = "" + + ""; + + // Support: Windows 8 Native Apps + // The type and name attributes are restricted during .innerHTML assignment + var input = document.createElement("input"); + input.setAttribute( "type", "hidden" ); + el.appendChild( input ).setAttribute( "name", "D" ); + + // Support: IE8 + // Enforce case-sensitivity of name attribute + if ( el.querySelectorAll("[name=d]").length ) { + rbuggyQSA.push( "name" + whitespace + "*[*^$|!~]?=" ); + } + + // FF 3.5 - :enabled/:disabled and hidden elements (hidden elements are still enabled) + // IE8 throws error here and will not see later tests + if ( el.querySelectorAll(":enabled").length !== 2 ) { + rbuggyQSA.push( ":enabled", ":disabled" ); + } + + // Support: IE9-11+ + // IE's :disabled selector does not pick up the children of disabled fieldsets + docElem.appendChild( el ).disabled = true; + if ( el.querySelectorAll(":disabled").length !== 2 ) { + rbuggyQSA.push( ":enabled", ":disabled" ); + } + + // Opera 10-11 does not throw on post-comma invalid pseudos + el.querySelectorAll("*,:x"); + rbuggyQSA.push(",.*:"); + }); + } + + if ( (support.matchesSelector = rnative.test( (matches = docElem.matches || + docElem.webkitMatchesSelector || + docElem.mozMatchesSelector || + docElem.oMatchesSelector || + docElem.msMatchesSelector) )) ) { + + assert(function( el ) { + // Check to see if it's possible to do matchesSelector + // on a disconnected node (IE 9) + support.disconnectedMatch = matches.call( el, "*" ); + + // This should fail with an exception + // Gecko does not error, returns false instead + matches.call( el, "[s!='']:x" ); + rbuggyMatches.push( "!=", pseudos ); + }); + } + + rbuggyQSA = rbuggyQSA.length && new RegExp( rbuggyQSA.join("|") ); + rbuggyMatches = rbuggyMatches.length && new RegExp( rbuggyMatches.join("|") ); + + /* Contains + ---------------------------------------------------------------------- */ + hasCompare = rnative.test( docElem.compareDocumentPosition ); + + // Element contains another + // Purposefully self-exclusive + // As in, an element does not contain itself + contains = hasCompare || rnative.test( docElem.contains ) ? + function( a, b ) { + var adown = a.nodeType === 9 ? a.documentElement : a, + bup = b && b.parentNode; + return a === bup || !!( bup && bup.nodeType === 1 && ( + adown.contains ? + adown.contains( bup ) : + a.compareDocumentPosition && a.compareDocumentPosition( bup ) & 16 + )); + } : + function( a, b ) { + if ( b ) { + while ( (b = b.parentNode) ) { + if ( b === a ) { + return true; + } + } + } + return false; + }; + + /* Sorting + ---------------------------------------------------------------------- */ + + // Document order sorting + sortOrder = hasCompare ? + function( a, b ) { + + // Flag for duplicate removal + if ( a === b ) { + hasDuplicate = true; + return 0; + } + + // Sort on method existence if only one input has compareDocumentPosition + var compare = !a.compareDocumentPosition - !b.compareDocumentPosition; + if ( compare ) { + return compare; + } + + // Calculate position if both inputs belong to the same document + compare = ( a.ownerDocument || a ) === ( b.ownerDocument || b ) ? + a.compareDocumentPosition( b ) : + + // Otherwise we know they are disconnected + 1; + + // Disconnected nodes + if ( compare & 1 || + (!support.sortDetached && b.compareDocumentPosition( a ) === compare) ) { + + // Choose the first element that is related to our preferred document + if ( a === document || a.ownerDocument === preferredDoc && contains(preferredDoc, a) ) { + return -1; + } + if ( b === document || b.ownerDocument === preferredDoc && contains(preferredDoc, b) ) { + return 1; + } + + // Maintain original order + return sortInput ? + ( indexOf( sortInput, a ) - indexOf( sortInput, b ) ) : + 0; + } + + return compare & 4 ? -1 : 1; + } : + function( a, b ) { + // Exit early if the nodes are identical + if ( a === b ) { + hasDuplicate = true; + return 0; + } + + var cur, + i = 0, + aup = a.parentNode, + bup = b.parentNode, + ap = [ a ], + bp = [ b ]; + + // Parentless nodes are either documents or disconnected + if ( !aup || !bup ) { + return a === document ? -1 : + b === document ? 1 : + aup ? -1 : + bup ? 1 : + sortInput ? + ( indexOf( sortInput, a ) - indexOf( sortInput, b ) ) : + 0; + + // If the nodes are siblings, we can do a quick check + } else if ( aup === bup ) { + return siblingCheck( a, b ); + } + + // Otherwise we need full lists of their ancestors for comparison + cur = a; + while ( (cur = cur.parentNode) ) { + ap.unshift( cur ); + } + cur = b; + while ( (cur = cur.parentNode) ) { + bp.unshift( cur ); + } + + // Walk down the tree looking for a discrepancy + while ( ap[i] === bp[i] ) { + i++; + } + + return i ? + // Do a sibling check if the nodes have a common ancestor + siblingCheck( ap[i], bp[i] ) : + + // Otherwise nodes in our document sort first + ap[i] === preferredDoc ? -1 : + bp[i] === preferredDoc ? 1 : + 0; + }; + + return document; +}; + +Sizzle.matches = function( expr, elements ) { + return Sizzle( expr, null, null, elements ); +}; + +Sizzle.matchesSelector = function( elem, expr ) { + // Set document vars if needed + if ( ( elem.ownerDocument || elem ) !== document ) { + setDocument( elem ); + } + + if ( support.matchesSelector && documentIsHTML && + !nonnativeSelectorCache[ expr + " " ] && + ( !rbuggyMatches || !rbuggyMatches.test( expr ) ) && + ( !rbuggyQSA || !rbuggyQSA.test( expr ) ) ) { + + try { + var ret = matches.call( elem, expr ); + + // IE 9's matchesSelector returns false on disconnected nodes + if ( ret || support.disconnectedMatch || + // As well, disconnected nodes are said to be in a document + // fragment in IE 9 + elem.document && elem.document.nodeType !== 11 ) { + return ret; + } + } catch (e) { + nonnativeSelectorCache( expr, true ); + } + } + + return Sizzle( expr, document, null, [ elem ] ).length > 0; +}; + +Sizzle.contains = function( context, elem ) { + // Set document vars if needed + if ( ( context.ownerDocument || context ) !== document ) { + setDocument( context ); + } + return contains( context, elem ); +}; + +Sizzle.attr = function( elem, name ) { + // Set document vars if needed + if ( ( elem.ownerDocument || elem ) !== document ) { + setDocument( elem ); + } + + var fn = Expr.attrHandle[ name.toLowerCase() ], + // Don't get fooled by Object.prototype properties (jQuery #13807) + val = fn && hasOwn.call( Expr.attrHandle, name.toLowerCase() ) ? + fn( elem, name, !documentIsHTML ) : + undefined; + + return val !== undefined ? + val : + support.attributes || !documentIsHTML ? + elem.getAttribute( name ) : + (val = elem.getAttributeNode(name)) && val.specified ? + val.value : + null; +}; + +Sizzle.escape = function( sel ) { + return (sel + "").replace( rcssescape, fcssescape ); +}; + +Sizzle.error = function( msg ) { + throw new Error( "Syntax error, unrecognized expression: " + msg ); +}; + +/** + * Document sorting and removing duplicates + * @param {ArrayLike} results + */ +Sizzle.uniqueSort = function( results ) { + var elem, + duplicates = [], + j = 0, + i = 0; + + // Unless we *know* we can detect duplicates, assume their presence + hasDuplicate = !support.detectDuplicates; + sortInput = !support.sortStable && results.slice( 0 ); + results.sort( sortOrder ); + + if ( hasDuplicate ) { + while ( (elem = results[i++]) ) { + if ( elem === results[ i ] ) { + j = duplicates.push( i ); + } + } + while ( j-- ) { + results.splice( duplicates[ j ], 1 ); + } + } + + // Clear input after sorting to release objects + // See https://github.com/jquery/sizzle/pull/225 + sortInput = null; + + return results; +}; + +/** + * Utility function for retrieving the text value of an array of DOM nodes + * @param {Array|Element} elem + */ +getText = Sizzle.getText = function( elem ) { + var node, + ret = "", + i = 0, + nodeType = elem.nodeType; + + if ( !nodeType ) { + // If no nodeType, this is expected to be an array + while ( (node = elem[i++]) ) { + // Do not traverse comment nodes + ret += getText( node ); + } + } else if ( nodeType === 1 || nodeType === 9 || nodeType === 11 ) { + // Use textContent for elements + // innerText usage removed for consistency of new lines (jQuery #11153) + if ( typeof elem.textContent === "string" ) { + return elem.textContent; + } else { + // Traverse its children + for ( elem = elem.firstChild; elem; elem = elem.nextSibling ) { + ret += getText( elem ); + } + } + } else if ( nodeType === 3 || nodeType === 4 ) { + return elem.nodeValue; + } + // Do not include comment or processing instruction nodes + + return ret; +}; + +Expr = Sizzle.selectors = { + + // Can be adjusted by the user + cacheLength: 50, + + createPseudo: markFunction, + + match: matchExpr, + + attrHandle: {}, + + find: {}, + + relative: { + ">": { dir: "parentNode", first: true }, + " ": { dir: "parentNode" }, + "+": { dir: "previousSibling", first: true }, + "~": { dir: "previousSibling" } + }, + + preFilter: { + "ATTR": function( match ) { + match[1] = match[1].replace( runescape, funescape ); + + // Move the given value to match[3] whether quoted or unquoted + match[3] = ( match[3] || match[4] || match[5] || "" ).replace( runescape, funescape ); + + if ( match[2] === "~=" ) { + match[3] = " " + match[3] + " "; + } + + return match.slice( 0, 4 ); + }, + + "CHILD": function( match ) { + /* matches from matchExpr["CHILD"] + 1 type (only|nth|...) + 2 what (child|of-type) + 3 argument (even|odd|\d*|\d*n([+-]\d+)?|...) + 4 xn-component of xn+y argument ([+-]?\d*n|) + 5 sign of xn-component + 6 x of xn-component + 7 sign of y-component + 8 y of y-component + */ + match[1] = match[1].toLowerCase(); + + if ( match[1].slice( 0, 3 ) === "nth" ) { + // nth-* requires argument + if ( !match[3] ) { + Sizzle.error( match[0] ); + } + + // numeric x and y parameters for Expr.filter.CHILD + // remember that false/true cast respectively to 0/1 + match[4] = +( match[4] ? match[5] + (match[6] || 1) : 2 * ( match[3] === "even" || match[3] === "odd" ) ); + match[5] = +( ( match[7] + match[8] ) || match[3] === "odd" ); + + // other types prohibit arguments + } else if ( match[3] ) { + Sizzle.error( match[0] ); + } + + return match; + }, + + "PSEUDO": function( match ) { + var excess, + unquoted = !match[6] && match[2]; + + if ( matchExpr["CHILD"].test( match[0] ) ) { + return null; + } + + // Accept quoted arguments as-is + if ( match[3] ) { + match[2] = match[4] || match[5] || ""; + + // Strip excess characters from unquoted arguments + } else if ( unquoted && rpseudo.test( unquoted ) && + // Get excess from tokenize (recursively) + (excess = tokenize( unquoted, true )) && + // advance to the next closing parenthesis + (excess = unquoted.indexOf( ")", unquoted.length - excess ) - unquoted.length) ) { + + // excess is a negative index + match[0] = match[0].slice( 0, excess ); + match[2] = unquoted.slice( 0, excess ); + } + + // Return only captures needed by the pseudo filter method (type and argument) + return match.slice( 0, 3 ); + } + }, + + filter: { + + "TAG": function( nodeNameSelector ) { + var nodeName = nodeNameSelector.replace( runescape, funescape ).toLowerCase(); + return nodeNameSelector === "*" ? + function() { return true; } : + function( elem ) { + return elem.nodeName && elem.nodeName.toLowerCase() === nodeName; + }; + }, + + "CLASS": function( className ) { + var pattern = classCache[ className + " " ]; + + return pattern || + (pattern = new RegExp( "(^|" + whitespace + ")" + className + "(" + whitespace + "|$)" )) && + classCache( className, function( elem ) { + return pattern.test( typeof elem.className === "string" && elem.className || typeof elem.getAttribute !== "undefined" && elem.getAttribute("class") || "" ); + }); + }, + + "ATTR": function( name, operator, check ) { + return function( elem ) { + var result = Sizzle.attr( elem, name ); + + if ( result == null ) { + return operator === "!="; + } + if ( !operator ) { + return true; + } + + result += ""; + + return operator === "=" ? result === check : + operator === "!=" ? result !== check : + operator === "^=" ? check && result.indexOf( check ) === 0 : + operator === "*=" ? check && result.indexOf( check ) > -1 : + operator === "$=" ? check && result.slice( -check.length ) === check : + operator === "~=" ? ( " " + result.replace( rwhitespace, " " ) + " " ).indexOf( check ) > -1 : + operator === "|=" ? result === check || result.slice( 0, check.length + 1 ) === check + "-" : + false; + }; + }, + + "CHILD": function( type, what, argument, first, last ) { + var simple = type.slice( 0, 3 ) !== "nth", + forward = type.slice( -4 ) !== "last", + ofType = what === "of-type"; + + return first === 1 && last === 0 ? + + // Shortcut for :nth-*(n) + function( elem ) { + return !!elem.parentNode; + } : + + function( elem, context, xml ) { + var cache, uniqueCache, outerCache, node, nodeIndex, start, + dir = simple !== forward ? "nextSibling" : "previousSibling", + parent = elem.parentNode, + name = ofType && elem.nodeName.toLowerCase(), + useCache = !xml && !ofType, + diff = false; + + if ( parent ) { + + // :(first|last|only)-(child|of-type) + if ( simple ) { + while ( dir ) { + node = elem; + while ( (node = node[ dir ]) ) { + if ( ofType ? + node.nodeName.toLowerCase() === name : + node.nodeType === 1 ) { + + return false; + } + } + // Reverse direction for :only-* (if we haven't yet done so) + start = dir = type === "only" && !start && "nextSibling"; + } + return true; + } + + start = [ forward ? parent.firstChild : parent.lastChild ]; + + // non-xml :nth-child(...) stores cache data on `parent` + if ( forward && useCache ) { + + // Seek `elem` from a previously-cached index + + // ...in a gzip-friendly way + node = parent; + outerCache = node[ expando ] || (node[ expando ] = {}); + + // Support: IE <9 only + // Defend against cloned attroperties (jQuery gh-1709) + uniqueCache = outerCache[ node.uniqueID ] || + (outerCache[ node.uniqueID ] = {}); + + cache = uniqueCache[ type ] || []; + nodeIndex = cache[ 0 ] === dirruns && cache[ 1 ]; + diff = nodeIndex && cache[ 2 ]; + node = nodeIndex && parent.childNodes[ nodeIndex ]; + + while ( (node = ++nodeIndex && node && node[ dir ] || + + // Fallback to seeking `elem` from the start + (diff = nodeIndex = 0) || start.pop()) ) { + + // When found, cache indexes on `parent` and break + if ( node.nodeType === 1 && ++diff && node === elem ) { + uniqueCache[ type ] = [ dirruns, nodeIndex, diff ]; + break; + } + } + + } else { + // Use previously-cached element index if available + if ( useCache ) { + // ...in a gzip-friendly way + node = elem; + outerCache = node[ expando ] || (node[ expando ] = {}); + + // Support: IE <9 only + // Defend against cloned attroperties (jQuery gh-1709) + uniqueCache = outerCache[ node.uniqueID ] || + (outerCache[ node.uniqueID ] = {}); + + cache = uniqueCache[ type ] || []; + nodeIndex = cache[ 0 ] === dirruns && cache[ 1 ]; + diff = nodeIndex; + } + + // xml :nth-child(...) + // or :nth-last-child(...) or :nth(-last)?-of-type(...) + if ( diff === false ) { + // Use the same loop as above to seek `elem` from the start + while ( (node = ++nodeIndex && node && node[ dir ] || + (diff = nodeIndex = 0) || start.pop()) ) { + + if ( ( ofType ? + node.nodeName.toLowerCase() === name : + node.nodeType === 1 ) && + ++diff ) { + + // Cache the index of each encountered element + if ( useCache ) { + outerCache = node[ expando ] || (node[ expando ] = {}); + + // Support: IE <9 only + // Defend against cloned attroperties (jQuery gh-1709) + uniqueCache = outerCache[ node.uniqueID ] || + (outerCache[ node.uniqueID ] = {}); + + uniqueCache[ type ] = [ dirruns, diff ]; + } + + if ( node === elem ) { + break; + } + } + } + } + } + + // Incorporate the offset, then check against cycle size + diff -= last; + return diff === first || ( diff % first === 0 && diff / first >= 0 ); + } + }; + }, + + "PSEUDO": function( pseudo, argument ) { + // pseudo-class names are case-insensitive + // http://www.w3.org/TR/selectors/#pseudo-classes + // Prioritize by case sensitivity in case custom pseudos are added with uppercase letters + // Remember that setFilters inherits from pseudos + var args, + fn = Expr.pseudos[ pseudo ] || Expr.setFilters[ pseudo.toLowerCase() ] || + Sizzle.error( "unsupported pseudo: " + pseudo ); + + // The user may use createPseudo to indicate that + // arguments are needed to create the filter function + // just as Sizzle does + if ( fn[ expando ] ) { + return fn( argument ); + } + + // But maintain support for old signatures + if ( fn.length > 1 ) { + args = [ pseudo, pseudo, "", argument ]; + return Expr.setFilters.hasOwnProperty( pseudo.toLowerCase() ) ? + markFunction(function( seed, matches ) { + var idx, + matched = fn( seed, argument ), + i = matched.length; + while ( i-- ) { + idx = indexOf( seed, matched[i] ); + seed[ idx ] = !( matches[ idx ] = matched[i] ); + } + }) : + function( elem ) { + return fn( elem, 0, args ); + }; + } + + return fn; + } + }, + + pseudos: { + // Potentially complex pseudos + "not": markFunction(function( selector ) { + // Trim the selector passed to compile + // to avoid treating leading and trailing + // spaces as combinators + var input = [], + results = [], + matcher = compile( selector.replace( rtrim, "$1" ) ); + + return matcher[ expando ] ? + markFunction(function( seed, matches, context, xml ) { + var elem, + unmatched = matcher( seed, null, xml, [] ), + i = seed.length; + + // Match elements unmatched by `matcher` + while ( i-- ) { + if ( (elem = unmatched[i]) ) { + seed[i] = !(matches[i] = elem); + } + } + }) : + function( elem, context, xml ) { + input[0] = elem; + matcher( input, null, xml, results ); + // Don't keep the element (issue #299) + input[0] = null; + return !results.pop(); + }; + }), + + "has": markFunction(function( selector ) { + return function( elem ) { + return Sizzle( selector, elem ).length > 0; + }; + }), + + "contains": markFunction(function( text ) { + text = text.replace( runescape, funescape ); + return function( elem ) { + return ( elem.textContent || getText( elem ) ).indexOf( text ) > -1; + }; + }), + + // "Whether an element is represented by a :lang() selector + // is based solely on the element's language value + // being equal to the identifier C, + // or beginning with the identifier C immediately followed by "-". + // The matching of C against the element's language value is performed case-insensitively. + // The identifier C does not have to be a valid language name." + // http://www.w3.org/TR/selectors/#lang-pseudo + "lang": markFunction( function( lang ) { + // lang value must be a valid identifier + if ( !ridentifier.test(lang || "") ) { + Sizzle.error( "unsupported lang: " + lang ); + } + lang = lang.replace( runescape, funescape ).toLowerCase(); + return function( elem ) { + var elemLang; + do { + if ( (elemLang = documentIsHTML ? + elem.lang : + elem.getAttribute("xml:lang") || elem.getAttribute("lang")) ) { + + elemLang = elemLang.toLowerCase(); + return elemLang === lang || elemLang.indexOf( lang + "-" ) === 0; + } + } while ( (elem = elem.parentNode) && elem.nodeType === 1 ); + return false; + }; + }), + + // Miscellaneous + "target": function( elem ) { + var hash = window.location && window.location.hash; + return hash && hash.slice( 1 ) === elem.id; + }, + + "root": function( elem ) { + return elem === docElem; + }, + + "focus": function( elem ) { + return elem === document.activeElement && (!document.hasFocus || document.hasFocus()) && !!(elem.type || elem.href || ~elem.tabIndex); + }, + + // Boolean properties + "enabled": createDisabledPseudo( false ), + "disabled": createDisabledPseudo( true ), + + "checked": function( elem ) { + // In CSS3, :checked should return both checked and selected elements + // http://www.w3.org/TR/2011/REC-css3-selectors-20110929/#checked + var nodeName = elem.nodeName.toLowerCase(); + return (nodeName === "input" && !!elem.checked) || (nodeName === "option" && !!elem.selected); + }, + + "selected": function( elem ) { + // Accessing this property makes selected-by-default + // options in Safari work properly + if ( elem.parentNode ) { + elem.parentNode.selectedIndex; + } + + return elem.selected === true; + }, + + // Contents + "empty": function( elem ) { + // http://www.w3.org/TR/selectors/#empty-pseudo + // :empty is negated by element (1) or content nodes (text: 3; cdata: 4; entity ref: 5), + // but not by others (comment: 8; processing instruction: 7; etc.) + // nodeType < 6 works because attributes (2) do not appear as children + for ( elem = elem.firstChild; elem; elem = elem.nextSibling ) { + if ( elem.nodeType < 6 ) { + return false; + } + } + return true; + }, + + "parent": function( elem ) { + return !Expr.pseudos["empty"]( elem ); + }, + + // Element/input types + "header": function( elem ) { + return rheader.test( elem.nodeName ); + }, + + "input": function( elem ) { + return rinputs.test( elem.nodeName ); + }, + + "button": function( elem ) { + var name = elem.nodeName.toLowerCase(); + return name === "input" && elem.type === "button" || name === "button"; + }, + + "text": function( elem ) { + var attr; + return elem.nodeName.toLowerCase() === "input" && + elem.type === "text" && + + // Support: IE<8 + // New HTML5 attribute values (e.g., "search") appear with elem.type === "text" + ( (attr = elem.getAttribute("type")) == null || attr.toLowerCase() === "text" ); + }, + + // Position-in-collection + "first": createPositionalPseudo(function() { + return [ 0 ]; + }), + + "last": createPositionalPseudo(function( matchIndexes, length ) { + return [ length - 1 ]; + }), + + "eq": createPositionalPseudo(function( matchIndexes, length, argument ) { + return [ argument < 0 ? argument + length : argument ]; + }), + + "even": createPositionalPseudo(function( matchIndexes, length ) { + var i = 0; + for ( ; i < length; i += 2 ) { + matchIndexes.push( i ); + } + return matchIndexes; + }), + + "odd": createPositionalPseudo(function( matchIndexes, length ) { + var i = 1; + for ( ; i < length; i += 2 ) { + matchIndexes.push( i ); + } + return matchIndexes; + }), + + "lt": createPositionalPseudo(function( matchIndexes, length, argument ) { + var i = argument < 0 ? + argument + length : + argument > length ? + length : + argument; + for ( ; --i >= 0; ) { + matchIndexes.push( i ); + } + return matchIndexes; + }), + + "gt": createPositionalPseudo(function( matchIndexes, length, argument ) { + var i = argument < 0 ? argument + length : argument; + for ( ; ++i < length; ) { + matchIndexes.push( i ); + } + return matchIndexes; + }) + } +}; + +Expr.pseudos["nth"] = Expr.pseudos["eq"]; + +// Add button/input type pseudos +for ( i in { radio: true, checkbox: true, file: true, password: true, image: true } ) { + Expr.pseudos[ i ] = createInputPseudo( i ); +} +for ( i in { submit: true, reset: true } ) { + Expr.pseudos[ i ] = createButtonPseudo( i ); +} + +// Easy API for creating new setFilters +function setFilters() {} +setFilters.prototype = Expr.filters = Expr.pseudos; +Expr.setFilters = new setFilters(); + +tokenize = Sizzle.tokenize = function( selector, parseOnly ) { + var matched, match, tokens, type, + soFar, groups, preFilters, + cached = tokenCache[ selector + " " ]; + + if ( cached ) { + return parseOnly ? 0 : cached.slice( 0 ); + } + + soFar = selector; + groups = []; + preFilters = Expr.preFilter; + + while ( soFar ) { + + // Comma and first run + if ( !matched || (match = rcomma.exec( soFar )) ) { + if ( match ) { + // Don't consume trailing commas as valid + soFar = soFar.slice( match[0].length ) || soFar; + } + groups.push( (tokens = []) ); + } + + matched = false; + + // Combinators + if ( (match = rcombinators.exec( soFar )) ) { + matched = match.shift(); + tokens.push({ + value: matched, + // Cast descendant combinators to space + type: match[0].replace( rtrim, " " ) + }); + soFar = soFar.slice( matched.length ); + } + + // Filters + for ( type in Expr.filter ) { + if ( (match = matchExpr[ type ].exec( soFar )) && (!preFilters[ type ] || + (match = preFilters[ type ]( match ))) ) { + matched = match.shift(); + tokens.push({ + value: matched, + type: type, + matches: match + }); + soFar = soFar.slice( matched.length ); + } + } + + if ( !matched ) { + break; + } + } + + // Return the length of the invalid excess + // if we're just parsing + // Otherwise, throw an error or return tokens + return parseOnly ? + soFar.length : + soFar ? + Sizzle.error( selector ) : + // Cache the tokens + tokenCache( selector, groups ).slice( 0 ); +}; + +function toSelector( tokens ) { + var i = 0, + len = tokens.length, + selector = ""; + for ( ; i < len; i++ ) { + selector += tokens[i].value; + } + return selector; +} + +function addCombinator( matcher, combinator, base ) { + var dir = combinator.dir, + skip = combinator.next, + key = skip || dir, + checkNonElements = base && key === "parentNode", + doneName = done++; + + return combinator.first ? + // Check against closest ancestor/preceding element + function( elem, context, xml ) { + while ( (elem = elem[ dir ]) ) { + if ( elem.nodeType === 1 || checkNonElements ) { + return matcher( elem, context, xml ); + } + } + return false; + } : + + // Check against all ancestor/preceding elements + function( elem, context, xml ) { + var oldCache, uniqueCache, outerCache, + newCache = [ dirruns, doneName ]; + + // We can't set arbitrary data on XML nodes, so they don't benefit from combinator caching + if ( xml ) { + while ( (elem = elem[ dir ]) ) { + if ( elem.nodeType === 1 || checkNonElements ) { + if ( matcher( elem, context, xml ) ) { + return true; + } + } + } + } else { + while ( (elem = elem[ dir ]) ) { + if ( elem.nodeType === 1 || checkNonElements ) { + outerCache = elem[ expando ] || (elem[ expando ] = {}); + + // Support: IE <9 only + // Defend against cloned attroperties (jQuery gh-1709) + uniqueCache = outerCache[ elem.uniqueID ] || (outerCache[ elem.uniqueID ] = {}); + + if ( skip && skip === elem.nodeName.toLowerCase() ) { + elem = elem[ dir ] || elem; + } else if ( (oldCache = uniqueCache[ key ]) && + oldCache[ 0 ] === dirruns && oldCache[ 1 ] === doneName ) { + + // Assign to newCache so results back-propagate to previous elements + return (newCache[ 2 ] = oldCache[ 2 ]); + } else { + // Reuse newcache so results back-propagate to previous elements + uniqueCache[ key ] = newCache; + + // A match means we're done; a fail means we have to keep checking + if ( (newCache[ 2 ] = matcher( elem, context, xml )) ) { + return true; + } + } + } + } + } + return false; + }; +} + +function elementMatcher( matchers ) { + return matchers.length > 1 ? + function( elem, context, xml ) { + var i = matchers.length; + while ( i-- ) { + if ( !matchers[i]( elem, context, xml ) ) { + return false; + } + } + return true; + } : + matchers[0]; +} + +function multipleContexts( selector, contexts, results ) { + var i = 0, + len = contexts.length; + for ( ; i < len; i++ ) { + Sizzle( selector, contexts[i], results ); + } + return results; +} + +function condense( unmatched, map, filter, context, xml ) { + var elem, + newUnmatched = [], + i = 0, + len = unmatched.length, + mapped = map != null; + + for ( ; i < len; i++ ) { + if ( (elem = unmatched[i]) ) { + if ( !filter || filter( elem, context, xml ) ) { + newUnmatched.push( elem ); + if ( mapped ) { + map.push( i ); + } + } + } + } + + return newUnmatched; +} + +function setMatcher( preFilter, selector, matcher, postFilter, postFinder, postSelector ) { + if ( postFilter && !postFilter[ expando ] ) { + postFilter = setMatcher( postFilter ); + } + if ( postFinder && !postFinder[ expando ] ) { + postFinder = setMatcher( postFinder, postSelector ); + } + return markFunction(function( seed, results, context, xml ) { + var temp, i, elem, + preMap = [], + postMap = [], + preexisting = results.length, + + // Get initial elements from seed or context + elems = seed || multipleContexts( selector || "*", context.nodeType ? [ context ] : context, [] ), + + // Prefilter to get matcher input, preserving a map for seed-results synchronization + matcherIn = preFilter && ( seed || !selector ) ? + condense( elems, preMap, preFilter, context, xml ) : + elems, + + matcherOut = matcher ? + // If we have a postFinder, or filtered seed, or non-seed postFilter or preexisting results, + postFinder || ( seed ? preFilter : preexisting || postFilter ) ? + + // ...intermediate processing is necessary + [] : + + // ...otherwise use results directly + results : + matcherIn; + + // Find primary matches + if ( matcher ) { + matcher( matcherIn, matcherOut, context, xml ); + } + + // Apply postFilter + if ( postFilter ) { + temp = condense( matcherOut, postMap ); + postFilter( temp, [], context, xml ); + + // Un-match failing elements by moving them back to matcherIn + i = temp.length; + while ( i-- ) { + if ( (elem = temp[i]) ) { + matcherOut[ postMap[i] ] = !(matcherIn[ postMap[i] ] = elem); + } + } + } + + if ( seed ) { + if ( postFinder || preFilter ) { + if ( postFinder ) { + // Get the final matcherOut by condensing this intermediate into postFinder contexts + temp = []; + i = matcherOut.length; + while ( i-- ) { + if ( (elem = matcherOut[i]) ) { + // Restore matcherIn since elem is not yet a final match + temp.push( (matcherIn[i] = elem) ); + } + } + postFinder( null, (matcherOut = []), temp, xml ); + } + + // Move matched elements from seed to results to keep them synchronized + i = matcherOut.length; + while ( i-- ) { + if ( (elem = matcherOut[i]) && + (temp = postFinder ? indexOf( seed, elem ) : preMap[i]) > -1 ) { + + seed[temp] = !(results[temp] = elem); + } + } + } + + // Add elements to results, through postFinder if defined + } else { + matcherOut = condense( + matcherOut === results ? + matcherOut.splice( preexisting, matcherOut.length ) : + matcherOut + ); + if ( postFinder ) { + postFinder( null, results, matcherOut, xml ); + } else { + push.apply( results, matcherOut ); + } + } + }); +} + +function matcherFromTokens( tokens ) { + var checkContext, matcher, j, + len = tokens.length, + leadingRelative = Expr.relative[ tokens[0].type ], + implicitRelative = leadingRelative || Expr.relative[" "], + i = leadingRelative ? 1 : 0, + + // The foundational matcher ensures that elements are reachable from top-level context(s) + matchContext = addCombinator( function( elem ) { + return elem === checkContext; + }, implicitRelative, true ), + matchAnyContext = addCombinator( function( elem ) { + return indexOf( checkContext, elem ) > -1; + }, implicitRelative, true ), + matchers = [ function( elem, context, xml ) { + var ret = ( !leadingRelative && ( xml || context !== outermostContext ) ) || ( + (checkContext = context).nodeType ? + matchContext( elem, context, xml ) : + matchAnyContext( elem, context, xml ) ); + // Avoid hanging onto element (issue #299) + checkContext = null; + return ret; + } ]; + + for ( ; i < len; i++ ) { + if ( (matcher = Expr.relative[ tokens[i].type ]) ) { + matchers = [ addCombinator(elementMatcher( matchers ), matcher) ]; + } else { + matcher = Expr.filter[ tokens[i].type ].apply( null, tokens[i].matches ); + + // Return special upon seeing a positional matcher + if ( matcher[ expando ] ) { + // Find the next relative operator (if any) for proper handling + j = ++i; + for ( ; j < len; j++ ) { + if ( Expr.relative[ tokens[j].type ] ) { + break; + } + } + return setMatcher( + i > 1 && elementMatcher( matchers ), + i > 1 && toSelector( + // If the preceding token was a descendant combinator, insert an implicit any-element `*` + tokens.slice( 0, i - 1 ).concat({ value: tokens[ i - 2 ].type === " " ? "*" : "" }) + ).replace( rtrim, "$1" ), + matcher, + i < j && matcherFromTokens( tokens.slice( i, j ) ), + j < len && matcherFromTokens( (tokens = tokens.slice( j )) ), + j < len && toSelector( tokens ) + ); + } + matchers.push( matcher ); + } + } + + return elementMatcher( matchers ); +} + +function matcherFromGroupMatchers( elementMatchers, setMatchers ) { + var bySet = setMatchers.length > 0, + byElement = elementMatchers.length > 0, + superMatcher = function( seed, context, xml, results, outermost ) { + var elem, j, matcher, + matchedCount = 0, + i = "0", + unmatched = seed && [], + setMatched = [], + contextBackup = outermostContext, + // We must always have either seed elements or outermost context + elems = seed || byElement && Expr.find["TAG"]( "*", outermost ), + // Use integer dirruns iff this is the outermost matcher + dirrunsUnique = (dirruns += contextBackup == null ? 1 : Math.random() || 0.1), + len = elems.length; + + if ( outermost ) { + outermostContext = context === document || context || outermost; + } + + // Add elements passing elementMatchers directly to results + // Support: IE<9, Safari + // Tolerate NodeList properties (IE: "length"; Safari: ) matching elements by id + for ( ; i !== len && (elem = elems[i]) != null; i++ ) { + if ( byElement && elem ) { + j = 0; + if ( !context && elem.ownerDocument !== document ) { + setDocument( elem ); + xml = !documentIsHTML; + } + while ( (matcher = elementMatchers[j++]) ) { + if ( matcher( elem, context || document, xml) ) { + results.push( elem ); + break; + } + } + if ( outermost ) { + dirruns = dirrunsUnique; + } + } + + // Track unmatched elements for set filters + if ( bySet ) { + // They will have gone through all possible matchers + if ( (elem = !matcher && elem) ) { + matchedCount--; + } + + // Lengthen the array for every element, matched or not + if ( seed ) { + unmatched.push( elem ); + } + } + } + + // `i` is now the count of elements visited above, and adding it to `matchedCount` + // makes the latter nonnegative. + matchedCount += i; + + // Apply set filters to unmatched elements + // NOTE: This can be skipped if there are no unmatched elements (i.e., `matchedCount` + // equals `i`), unless we didn't visit _any_ elements in the above loop because we have + // no element matchers and no seed. + // Incrementing an initially-string "0" `i` allows `i` to remain a string only in that + // case, which will result in a "00" `matchedCount` that differs from `i` but is also + // numerically zero. + if ( bySet && i !== matchedCount ) { + j = 0; + while ( (matcher = setMatchers[j++]) ) { + matcher( unmatched, setMatched, context, xml ); + } + + if ( seed ) { + // Reintegrate element matches to eliminate the need for sorting + if ( matchedCount > 0 ) { + while ( i-- ) { + if ( !(unmatched[i] || setMatched[i]) ) { + setMatched[i] = pop.call( results ); + } + } + } + + // Discard index placeholder values to get only actual matches + setMatched = condense( setMatched ); + } + + // Add matches to results + push.apply( results, setMatched ); + + // Seedless set matches succeeding multiple successful matchers stipulate sorting + if ( outermost && !seed && setMatched.length > 0 && + ( matchedCount + setMatchers.length ) > 1 ) { + + Sizzle.uniqueSort( results ); + } + } + + // Override manipulation of globals by nested matchers + if ( outermost ) { + dirruns = dirrunsUnique; + outermostContext = contextBackup; + } + + return unmatched; + }; + + return bySet ? + markFunction( superMatcher ) : + superMatcher; +} + +compile = Sizzle.compile = function( selector, match /* Internal Use Only */ ) { + var i, + setMatchers = [], + elementMatchers = [], + cached = compilerCache[ selector + " " ]; + + if ( !cached ) { + // Generate a function of recursive functions that can be used to check each element + if ( !match ) { + match = tokenize( selector ); + } + i = match.length; + while ( i-- ) { + cached = matcherFromTokens( match[i] ); + if ( cached[ expando ] ) { + setMatchers.push( cached ); + } else { + elementMatchers.push( cached ); + } + } + + // Cache the compiled function + cached = compilerCache( selector, matcherFromGroupMatchers( elementMatchers, setMatchers ) ); + + // Save selector and tokenization + cached.selector = selector; + } + return cached; +}; + +/** + * A low-level selection function that works with Sizzle's compiled + * selector functions + * @param {String|Function} selector A selector or a pre-compiled + * selector function built with Sizzle.compile + * @param {Element} context + * @param {Array} [results] + * @param {Array} [seed] A set of elements to match against + */ +select = Sizzle.select = function( selector, context, results, seed ) { + var i, tokens, token, type, find, + compiled = typeof selector === "function" && selector, + match = !seed && tokenize( (selector = compiled.selector || selector) ); + + results = results || []; + + // Try to minimize operations if there is only one selector in the list and no seed + // (the latter of which guarantees us context) + if ( match.length === 1 ) { + + // Reduce context if the leading compound selector is an ID + tokens = match[0] = match[0].slice( 0 ); + if ( tokens.length > 2 && (token = tokens[0]).type === "ID" && + context.nodeType === 9 && documentIsHTML && Expr.relative[ tokens[1].type ] ) { + + context = ( Expr.find["ID"]( token.matches[0].replace(runescape, funescape), context ) || [] )[0]; + if ( !context ) { + return results; + + // Precompiled matchers will still verify ancestry, so step up a level + } else if ( compiled ) { + context = context.parentNode; + } + + selector = selector.slice( tokens.shift().value.length ); + } + + // Fetch a seed set for right-to-left matching + i = matchExpr["needsContext"].test( selector ) ? 0 : tokens.length; + while ( i-- ) { + token = tokens[i]; + + // Abort if we hit a combinator + if ( Expr.relative[ (type = token.type) ] ) { + break; + } + if ( (find = Expr.find[ type ]) ) { + // Search, expanding context for leading sibling combinators + if ( (seed = find( + token.matches[0].replace( runescape, funescape ), + rsibling.test( tokens[0].type ) && testContext( context.parentNode ) || context + )) ) { + + // If seed is empty or no tokens remain, we can return early + tokens.splice( i, 1 ); + selector = seed.length && toSelector( tokens ); + if ( !selector ) { + push.apply( results, seed ); + return results; + } + + break; + } + } + } + } + + // Compile and execute a filtering function if one is not provided + // Provide `match` to avoid retokenization if we modified the selector above + ( compiled || compile( selector, match ) )( + seed, + context, + !documentIsHTML, + results, + !context || rsibling.test( selector ) && testContext( context.parentNode ) || context + ); + return results; +}; + +// One-time assignments + +// Sort stability +support.sortStable = expando.split("").sort( sortOrder ).join("") === expando; + +// Support: Chrome 14-35+ +// Always assume duplicates if they aren't passed to the comparison function +support.detectDuplicates = !!hasDuplicate; + +// Initialize against the default document +setDocument(); + +// Support: Webkit<537.32 - Safari 6.0.3/Chrome 25 (fixed in Chrome 27) +// Detached nodes confoundingly follow *each other* +support.sortDetached = assert(function( el ) { + // Should return 1, but returns 4 (following) + return el.compareDocumentPosition( document.createElement("fieldset") ) & 1; +}); + +// Support: IE<8 +// Prevent attribute/property "interpolation" +// https://msdn.microsoft.com/en-us/library/ms536429%28VS.85%29.aspx +if ( !assert(function( el ) { + el.innerHTML = ""; + return el.firstChild.getAttribute("href") === "#" ; +}) ) { + addHandle( "type|href|height|width", function( elem, name, isXML ) { + if ( !isXML ) { + return elem.getAttribute( name, name.toLowerCase() === "type" ? 1 : 2 ); + } + }); +} + +// Support: IE<9 +// Use defaultValue in place of getAttribute("value") +if ( !support.attributes || !assert(function( el ) { + el.innerHTML = ""; + el.firstChild.setAttribute( "value", "" ); + return el.firstChild.getAttribute( "value" ) === ""; +}) ) { + addHandle( "value", function( elem, name, isXML ) { + if ( !isXML && elem.nodeName.toLowerCase() === "input" ) { + return elem.defaultValue; + } + }); +} + +// Support: IE<9 +// Use getAttributeNode to fetch booleans when getAttribute lies +if ( !assert(function( el ) { + return el.getAttribute("disabled") == null; +}) ) { + addHandle( booleans, function( elem, name, isXML ) { + var val; + if ( !isXML ) { + return elem[ name ] === true ? name.toLowerCase() : + (val = elem.getAttributeNode( name )) && val.specified ? + val.value : + null; + } + }); +} + +return Sizzle; + +})( window ); + + + +jQuery.find = Sizzle; +jQuery.expr = Sizzle.selectors; + +// Deprecated +jQuery.expr[ ":" ] = jQuery.expr.pseudos; +jQuery.uniqueSort = jQuery.unique = Sizzle.uniqueSort; +jQuery.text = Sizzle.getText; +jQuery.isXMLDoc = Sizzle.isXML; +jQuery.contains = Sizzle.contains; +jQuery.escapeSelector = Sizzle.escape; + + + + +var dir = function( elem, dir, until ) { + var matched = [], + truncate = until !== undefined; + + while ( ( elem = elem[ dir ] ) && elem.nodeType !== 9 ) { + if ( elem.nodeType === 1 ) { + if ( truncate && jQuery( elem ).is( until ) ) { + break; + } + matched.push( elem ); + } + } + return matched; +}; + + +var siblings = function( n, elem ) { + var matched = []; + + for ( ; n; n = n.nextSibling ) { + if ( n.nodeType === 1 && n !== elem ) { + matched.push( n ); + } + } + + return matched; +}; + + +var rneedsContext = jQuery.expr.match.needsContext; + + + +function nodeName( elem, name ) { + + return elem.nodeName && elem.nodeName.toLowerCase() === name.toLowerCase(); + +}; +var rsingleTag = ( /^<([a-z][^\/\0>:\x20\t\r\n\f]*)[\x20\t\r\n\f]*\/?>(?:<\/\1>|)$/i ); + + + +// Implement the identical functionality for filter and not +function winnow( elements, qualifier, not ) { + if ( isFunction( qualifier ) ) { + return jQuery.grep( elements, function( elem, i ) { + return !!qualifier.call( elem, i, elem ) !== not; + } ); + } + + // Single element + if ( qualifier.nodeType ) { + return jQuery.grep( elements, function( elem ) { + return ( elem === qualifier ) !== not; + } ); + } + + // Arraylike of elements (jQuery, arguments, Array) + if ( typeof qualifier !== "string" ) { + return jQuery.grep( elements, function( elem ) { + return ( indexOf.call( qualifier, elem ) > -1 ) !== not; + } ); + } + + // Filtered directly for both simple and complex selectors + return jQuery.filter( qualifier, elements, not ); +} + +jQuery.filter = function( expr, elems, not ) { + var elem = elems[ 0 ]; + + if ( not ) { + expr = ":not(" + expr + ")"; + } + + if ( elems.length === 1 && elem.nodeType === 1 ) { + return jQuery.find.matchesSelector( elem, expr ) ? [ elem ] : []; + } + + return jQuery.find.matches( expr, jQuery.grep( elems, function( elem ) { + return elem.nodeType === 1; + } ) ); +}; + +jQuery.fn.extend( { + find: function( selector ) { + var i, ret, + len = this.length, + self = this; + + if ( typeof selector !== "string" ) { + return this.pushStack( jQuery( selector ).filter( function() { + for ( i = 0; i < len; i++ ) { + if ( jQuery.contains( self[ i ], this ) ) { + return true; + } + } + } ) ); + } + + ret = this.pushStack( [] ); + + for ( i = 0; i < len; i++ ) { + jQuery.find( selector, self[ i ], ret ); + } + + return len > 1 ? jQuery.uniqueSort( ret ) : ret; + }, + filter: function( selector ) { + return this.pushStack( winnow( this, selector || [], false ) ); + }, + not: function( selector ) { + return this.pushStack( winnow( this, selector || [], true ) ); + }, + is: function( selector ) { + return !!winnow( + this, + + // If this is a positional/relative selector, check membership in the returned set + // so $("p:first").is("p:last") won't return true for a doc with two "p". + typeof selector === "string" && rneedsContext.test( selector ) ? + jQuery( selector ) : + selector || [], + false + ).length; + } +} ); + + +// Initialize a jQuery object + + +// A central reference to the root jQuery(document) +var rootjQuery, + + // A simple way to check for HTML strings + // Prioritize #id over to avoid XSS via location.hash (#9521) + // Strict HTML recognition (#11290: must start with <) + // Shortcut simple #id case for speed + rquickExpr = /^(?:\s*(<[\w\W]+>)[^>]*|#([\w-]+))$/, + + init = jQuery.fn.init = function( selector, context, root ) { + var match, elem; + + // HANDLE: $(""), $(null), $(undefined), $(false) + if ( !selector ) { + return this; + } + + // Method init() accepts an alternate rootjQuery + // so migrate can support jQuery.sub (gh-2101) + root = root || rootjQuery; + + // Handle HTML strings + if ( typeof selector === "string" ) { + if ( selector[ 0 ] === "<" && + selector[ selector.length - 1 ] === ">" && + selector.length >= 3 ) { + + // Assume that strings that start and end with <> are HTML and skip the regex check + match = [ null, selector, null ]; + + } else { + match = rquickExpr.exec( selector ); + } + + // Match html or make sure no context is specified for #id + if ( match && ( match[ 1 ] || !context ) ) { + + // HANDLE: $(html) -> $(array) + if ( match[ 1 ] ) { + context = context instanceof jQuery ? context[ 0 ] : context; + + // Option to run scripts is true for back-compat + // Intentionally let the error be thrown if parseHTML is not present + jQuery.merge( this, jQuery.parseHTML( + match[ 1 ], + context && context.nodeType ? context.ownerDocument || context : document, + true + ) ); + + // HANDLE: $(html, props) + if ( rsingleTag.test( match[ 1 ] ) && jQuery.isPlainObject( context ) ) { + for ( match in context ) { + + // Properties of context are called as methods if possible + if ( isFunction( this[ match ] ) ) { + this[ match ]( context[ match ] ); + + // ...and otherwise set as attributes + } else { + this.attr( match, context[ match ] ); + } + } + } + + return this; + + // HANDLE: $(#id) + } else { + elem = document.getElementById( match[ 2 ] ); + + if ( elem ) { + + // Inject the element directly into the jQuery object + this[ 0 ] = elem; + this.length = 1; + } + return this; + } + + // HANDLE: $(expr, $(...)) + } else if ( !context || context.jquery ) { + return ( context || root ).find( selector ); + + // HANDLE: $(expr, context) + // (which is just equivalent to: $(context).find(expr) + } else { + return this.constructor( context ).find( selector ); + } + + // HANDLE: $(DOMElement) + } else if ( selector.nodeType ) { + this[ 0 ] = selector; + this.length = 1; + return this; + + // HANDLE: $(function) + // Shortcut for document ready + } else if ( isFunction( selector ) ) { + return root.ready !== undefined ? + root.ready( selector ) : + + // Execute immediately if ready is not present + selector( jQuery ); + } + + return jQuery.makeArray( selector, this ); + }; + +// Give the init function the jQuery prototype for later instantiation +init.prototype = jQuery.fn; + +// Initialize central reference +rootjQuery = jQuery( document ); + + +var rparentsprev = /^(?:parents|prev(?:Until|All))/, + + // Methods guaranteed to produce a unique set when starting from a unique set + guaranteedUnique = { + children: true, + contents: true, + next: true, + prev: true + }; + +jQuery.fn.extend( { + has: function( target ) { + var targets = jQuery( target, this ), + l = targets.length; + + return this.filter( function() { + var i = 0; + for ( ; i < l; i++ ) { + if ( jQuery.contains( this, targets[ i ] ) ) { + return true; + } + } + } ); + }, + + closest: function( selectors, context ) { + var cur, + i = 0, + l = this.length, + matched = [], + targets = typeof selectors !== "string" && jQuery( selectors ); + + // Positional selectors never match, since there's no _selection_ context + if ( !rneedsContext.test( selectors ) ) { + for ( ; i < l; i++ ) { + for ( cur = this[ i ]; cur && cur !== context; cur = cur.parentNode ) { + + // Always skip document fragments + if ( cur.nodeType < 11 && ( targets ? + targets.index( cur ) > -1 : + + // Don't pass non-elements to Sizzle + cur.nodeType === 1 && + jQuery.find.matchesSelector( cur, selectors ) ) ) { + + matched.push( cur ); + break; + } + } + } + } + + return this.pushStack( matched.length > 1 ? jQuery.uniqueSort( matched ) : matched ); + }, + + // Determine the position of an element within the set + index: function( elem ) { + + // No argument, return index in parent + if ( !elem ) { + return ( this[ 0 ] && this[ 0 ].parentNode ) ? this.first().prevAll().length : -1; + } + + // Index in selector + if ( typeof elem === "string" ) { + return indexOf.call( jQuery( elem ), this[ 0 ] ); + } + + // Locate the position of the desired element + return indexOf.call( this, + + // If it receives a jQuery object, the first element is used + elem.jquery ? elem[ 0 ] : elem + ); + }, + + add: function( selector, context ) { + return this.pushStack( + jQuery.uniqueSort( + jQuery.merge( this.get(), jQuery( selector, context ) ) + ) + ); + }, + + addBack: function( selector ) { + return this.add( selector == null ? + this.prevObject : this.prevObject.filter( selector ) + ); + } +} ); + +function sibling( cur, dir ) { + while ( ( cur = cur[ dir ] ) && cur.nodeType !== 1 ) {} + return cur; +} + +jQuery.each( { + parent: function( elem ) { + var parent = elem.parentNode; + return parent && parent.nodeType !== 11 ? parent : null; + }, + parents: function( elem ) { + return dir( elem, "parentNode" ); + }, + parentsUntil: function( elem, i, until ) { + return dir( elem, "parentNode", until ); + }, + next: function( elem ) { + return sibling( elem, "nextSibling" ); + }, + prev: function( elem ) { + return sibling( elem, "previousSibling" ); + }, + nextAll: function( elem ) { + return dir( elem, "nextSibling" ); + }, + prevAll: function( elem ) { + return dir( elem, "previousSibling" ); + }, + nextUntil: function( elem, i, until ) { + return dir( elem, "nextSibling", until ); + }, + prevUntil: function( elem, i, until ) { + return dir( elem, "previousSibling", until ); + }, + siblings: function( elem ) { + return siblings( ( elem.parentNode || {} ).firstChild, elem ); + }, + children: function( elem ) { + return siblings( elem.firstChild ); + }, + contents: function( elem ) { + if ( typeof elem.contentDocument !== "undefined" ) { + return elem.contentDocument; + } + + // Support: IE 9 - 11 only, iOS 7 only, Android Browser <=4.3 only + // Treat the template element as a regular one in browsers that + // don't support it. + if ( nodeName( elem, "template" ) ) { + elem = elem.content || elem; + } + + return jQuery.merge( [], elem.childNodes ); + } +}, function( name, fn ) { + jQuery.fn[ name ] = function( until, selector ) { + var matched = jQuery.map( this, fn, until ); + + if ( name.slice( -5 ) !== "Until" ) { + selector = until; + } + + if ( selector && typeof selector === "string" ) { + matched = jQuery.filter( selector, matched ); + } + + if ( this.length > 1 ) { + + // Remove duplicates + if ( !guaranteedUnique[ name ] ) { + jQuery.uniqueSort( matched ); + } + + // Reverse order for parents* and prev-derivatives + if ( rparentsprev.test( name ) ) { + matched.reverse(); + } + } + + return this.pushStack( matched ); + }; +} ); +var rnothtmlwhite = ( /[^\x20\t\r\n\f]+/g ); + + + +// Convert String-formatted options into Object-formatted ones +function createOptions( options ) { + var object = {}; + jQuery.each( options.match( rnothtmlwhite ) || [], function( _, flag ) { + object[ flag ] = true; + } ); + return object; +} + +/* + * Create a callback list using the following parameters: + * + * options: an optional list of space-separated options that will change how + * the callback list behaves or a more traditional option object + * + * By default a callback list will act like an event callback list and can be + * "fired" multiple times. + * + * Possible options: + * + * once: will ensure the callback list can only be fired once (like a Deferred) + * + * memory: will keep track of previous values and will call any callback added + * after the list has been fired right away with the latest "memorized" + * values (like a Deferred) + * + * unique: will ensure a callback can only be added once (no duplicate in the list) + * + * stopOnFalse: interrupt callings when a callback returns false + * + */ +jQuery.Callbacks = function( options ) { + + // Convert options from String-formatted to Object-formatted if needed + // (we check in cache first) + options = typeof options === "string" ? + createOptions( options ) : + jQuery.extend( {}, options ); + + var // Flag to know if list is currently firing + firing, + + // Last fire value for non-forgettable lists + memory, + + // Flag to know if list was already fired + fired, + + // Flag to prevent firing + locked, + + // Actual callback list + list = [], + + // Queue of execution data for repeatable lists + queue = [], + + // Index of currently firing callback (modified by add/remove as needed) + firingIndex = -1, + + // Fire callbacks + fire = function() { + + // Enforce single-firing + locked = locked || options.once; + + // Execute callbacks for all pending executions, + // respecting firingIndex overrides and runtime changes + fired = firing = true; + for ( ; queue.length; firingIndex = -1 ) { + memory = queue.shift(); + while ( ++firingIndex < list.length ) { + + // Run callback and check for early termination + if ( list[ firingIndex ].apply( memory[ 0 ], memory[ 1 ] ) === false && + options.stopOnFalse ) { + + // Jump to end and forget the data so .add doesn't re-fire + firingIndex = list.length; + memory = false; + } + } + } + + // Forget the data if we're done with it + if ( !options.memory ) { + memory = false; + } + + firing = false; + + // Clean up if we're done firing for good + if ( locked ) { + + // Keep an empty list if we have data for future add calls + if ( memory ) { + list = []; + + // Otherwise, this object is spent + } else { + list = ""; + } + } + }, + + // Actual Callbacks object + self = { + + // Add a callback or a collection of callbacks to the list + add: function() { + if ( list ) { + + // If we have memory from a past run, we should fire after adding + if ( memory && !firing ) { + firingIndex = list.length - 1; + queue.push( memory ); + } + + ( function add( args ) { + jQuery.each( args, function( _, arg ) { + if ( isFunction( arg ) ) { + if ( !options.unique || !self.has( arg ) ) { + list.push( arg ); + } + } else if ( arg && arg.length && toType( arg ) !== "string" ) { + + // Inspect recursively + add( arg ); + } + } ); + } )( arguments ); + + if ( memory && !firing ) { + fire(); + } + } + return this; + }, + + // Remove a callback from the list + remove: function() { + jQuery.each( arguments, function( _, arg ) { + var index; + while ( ( index = jQuery.inArray( arg, list, index ) ) > -1 ) { + list.splice( index, 1 ); + + // Handle firing indexes + if ( index <= firingIndex ) { + firingIndex--; + } + } + } ); + return this; + }, + + // Check if a given callback is in the list. + // If no argument is given, return whether or not list has callbacks attached. + has: function( fn ) { + return fn ? + jQuery.inArray( fn, list ) > -1 : + list.length > 0; + }, + + // Remove all callbacks from the list + empty: function() { + if ( list ) { + list = []; + } + return this; + }, + + // Disable .fire and .add + // Abort any current/pending executions + // Clear all callbacks and values + disable: function() { + locked = queue = []; + list = memory = ""; + return this; + }, + disabled: function() { + return !list; + }, + + // Disable .fire + // Also disable .add unless we have memory (since it would have no effect) + // Abort any pending executions + lock: function() { + locked = queue = []; + if ( !memory && !firing ) { + list = memory = ""; + } + return this; + }, + locked: function() { + return !!locked; + }, + + // Call all callbacks with the given context and arguments + fireWith: function( context, args ) { + if ( !locked ) { + args = args || []; + args = [ context, args.slice ? args.slice() : args ]; + queue.push( args ); + if ( !firing ) { + fire(); + } + } + return this; + }, + + // Call all the callbacks with the given arguments + fire: function() { + self.fireWith( this, arguments ); + return this; + }, + + // To know if the callbacks have already been called at least once + fired: function() { + return !!fired; + } + }; + + return self; +}; + + +function Identity( v ) { + return v; +} +function Thrower( ex ) { + throw ex; +} + +function adoptValue( value, resolve, reject, noValue ) { + var method; + + try { + + // Check for promise aspect first to privilege synchronous behavior + if ( value && isFunction( ( method = value.promise ) ) ) { + method.call( value ).done( resolve ).fail( reject ); + + // Other thenables + } else if ( value && isFunction( ( method = value.then ) ) ) { + method.call( value, resolve, reject ); + + // Other non-thenables + } else { + + // Control `resolve` arguments by letting Array#slice cast boolean `noValue` to integer: + // * false: [ value ].slice( 0 ) => resolve( value ) + // * true: [ value ].slice( 1 ) => resolve() + resolve.apply( undefined, [ value ].slice( noValue ) ); + } + + // For Promises/A+, convert exceptions into rejections + // Since jQuery.when doesn't unwrap thenables, we can skip the extra checks appearing in + // Deferred#then to conditionally suppress rejection. + } catch ( value ) { + + // Support: Android 4.0 only + // Strict mode functions invoked without .call/.apply get global-object context + reject.apply( undefined, [ value ] ); + } +} + +jQuery.extend( { + + Deferred: function( func ) { + var tuples = [ + + // action, add listener, callbacks, + // ... .then handlers, argument index, [final state] + [ "notify", "progress", jQuery.Callbacks( "memory" ), + jQuery.Callbacks( "memory" ), 2 ], + [ "resolve", "done", jQuery.Callbacks( "once memory" ), + jQuery.Callbacks( "once memory" ), 0, "resolved" ], + [ "reject", "fail", jQuery.Callbacks( "once memory" ), + jQuery.Callbacks( "once memory" ), 1, "rejected" ] + ], + state = "pending", + promise = { + state: function() { + return state; + }, + always: function() { + deferred.done( arguments ).fail( arguments ); + return this; + }, + "catch": function( fn ) { + return promise.then( null, fn ); + }, + + // Keep pipe for back-compat + pipe: function( /* fnDone, fnFail, fnProgress */ ) { + var fns = arguments; + + return jQuery.Deferred( function( newDefer ) { + jQuery.each( tuples, function( i, tuple ) { + + // Map tuples (progress, done, fail) to arguments (done, fail, progress) + var fn = isFunction( fns[ tuple[ 4 ] ] ) && fns[ tuple[ 4 ] ]; + + // deferred.progress(function() { bind to newDefer or newDefer.notify }) + // deferred.done(function() { bind to newDefer or newDefer.resolve }) + // deferred.fail(function() { bind to newDefer or newDefer.reject }) + deferred[ tuple[ 1 ] ]( function() { + var returned = fn && fn.apply( this, arguments ); + if ( returned && isFunction( returned.promise ) ) { + returned.promise() + .progress( newDefer.notify ) + .done( newDefer.resolve ) + .fail( newDefer.reject ); + } else { + newDefer[ tuple[ 0 ] + "With" ]( + this, + fn ? [ returned ] : arguments + ); + } + } ); + } ); + fns = null; + } ).promise(); + }, + then: function( onFulfilled, onRejected, onProgress ) { + var maxDepth = 0; + function resolve( depth, deferred, handler, special ) { + return function() { + var that = this, + args = arguments, + mightThrow = function() { + var returned, then; + + // Support: Promises/A+ section 2.3.3.3.3 + // https://promisesaplus.com/#point-59 + // Ignore double-resolution attempts + if ( depth < maxDepth ) { + return; + } + + returned = handler.apply( that, args ); + + // Support: Promises/A+ section 2.3.1 + // https://promisesaplus.com/#point-48 + if ( returned === deferred.promise() ) { + throw new TypeError( "Thenable self-resolution" ); + } + + // Support: Promises/A+ sections 2.3.3.1, 3.5 + // https://promisesaplus.com/#point-54 + // https://promisesaplus.com/#point-75 + // Retrieve `then` only once + then = returned && + + // Support: Promises/A+ section 2.3.4 + // https://promisesaplus.com/#point-64 + // Only check objects and functions for thenability + ( typeof returned === "object" || + typeof returned === "function" ) && + returned.then; + + // Handle a returned thenable + if ( isFunction( then ) ) { + + // Special processors (notify) just wait for resolution + if ( special ) { + then.call( + returned, + resolve( maxDepth, deferred, Identity, special ), + resolve( maxDepth, deferred, Thrower, special ) + ); + + // Normal processors (resolve) also hook into progress + } else { + + // ...and disregard older resolution values + maxDepth++; + + then.call( + returned, + resolve( maxDepth, deferred, Identity, special ), + resolve( maxDepth, deferred, Thrower, special ), + resolve( maxDepth, deferred, Identity, + deferred.notifyWith ) + ); + } + + // Handle all other returned values + } else { + + // Only substitute handlers pass on context + // and multiple values (non-spec behavior) + if ( handler !== Identity ) { + that = undefined; + args = [ returned ]; + } + + // Process the value(s) + // Default process is resolve + ( special || deferred.resolveWith )( that, args ); + } + }, + + // Only normal processors (resolve) catch and reject exceptions + process = special ? + mightThrow : + function() { + try { + mightThrow(); + } catch ( e ) { + + if ( jQuery.Deferred.exceptionHook ) { + jQuery.Deferred.exceptionHook( e, + process.stackTrace ); + } + + // Support: Promises/A+ section 2.3.3.3.4.1 + // https://promisesaplus.com/#point-61 + // Ignore post-resolution exceptions + if ( depth + 1 >= maxDepth ) { + + // Only substitute handlers pass on context + // and multiple values (non-spec behavior) + if ( handler !== Thrower ) { + that = undefined; + args = [ e ]; + } + + deferred.rejectWith( that, args ); + } + } + }; + + // Support: Promises/A+ section 2.3.3.3.1 + // https://promisesaplus.com/#point-57 + // Re-resolve promises immediately to dodge false rejection from + // subsequent errors + if ( depth ) { + process(); + } else { + + // Call an optional hook to record the stack, in case of exception + // since it's otherwise lost when execution goes async + if ( jQuery.Deferred.getStackHook ) { + process.stackTrace = jQuery.Deferred.getStackHook(); + } + window.setTimeout( process ); + } + }; + } + + return jQuery.Deferred( function( newDefer ) { + + // progress_handlers.add( ... ) + tuples[ 0 ][ 3 ].add( + resolve( + 0, + newDefer, + isFunction( onProgress ) ? + onProgress : + Identity, + newDefer.notifyWith + ) + ); + + // fulfilled_handlers.add( ... ) + tuples[ 1 ][ 3 ].add( + resolve( + 0, + newDefer, + isFunction( onFulfilled ) ? + onFulfilled : + Identity + ) + ); + + // rejected_handlers.add( ... ) + tuples[ 2 ][ 3 ].add( + resolve( + 0, + newDefer, + isFunction( onRejected ) ? + onRejected : + Thrower + ) + ); + } ).promise(); + }, + + // Get a promise for this deferred + // If obj is provided, the promise aspect is added to the object + promise: function( obj ) { + return obj != null ? jQuery.extend( obj, promise ) : promise; + } + }, + deferred = {}; + + // Add list-specific methods + jQuery.each( tuples, function( i, tuple ) { + var list = tuple[ 2 ], + stateString = tuple[ 5 ]; + + // promise.progress = list.add + // promise.done = list.add + // promise.fail = list.add + promise[ tuple[ 1 ] ] = list.add; + + // Handle state + if ( stateString ) { + list.add( + function() { + + // state = "resolved" (i.e., fulfilled) + // state = "rejected" + state = stateString; + }, + + // rejected_callbacks.disable + // fulfilled_callbacks.disable + tuples[ 3 - i ][ 2 ].disable, + + // rejected_handlers.disable + // fulfilled_handlers.disable + tuples[ 3 - i ][ 3 ].disable, + + // progress_callbacks.lock + tuples[ 0 ][ 2 ].lock, + + // progress_handlers.lock + tuples[ 0 ][ 3 ].lock + ); + } + + // progress_handlers.fire + // fulfilled_handlers.fire + // rejected_handlers.fire + list.add( tuple[ 3 ].fire ); + + // deferred.notify = function() { deferred.notifyWith(...) } + // deferred.resolve = function() { deferred.resolveWith(...) } + // deferred.reject = function() { deferred.rejectWith(...) } + deferred[ tuple[ 0 ] ] = function() { + deferred[ tuple[ 0 ] + "With" ]( this === deferred ? undefined : this, arguments ); + return this; + }; + + // deferred.notifyWith = list.fireWith + // deferred.resolveWith = list.fireWith + // deferred.rejectWith = list.fireWith + deferred[ tuple[ 0 ] + "With" ] = list.fireWith; + } ); + + // Make the deferred a promise + promise.promise( deferred ); + + // Call given func if any + if ( func ) { + func.call( deferred, deferred ); + } + + // All done! + return deferred; + }, + + // Deferred helper + when: function( singleValue ) { + var + + // count of uncompleted subordinates + remaining = arguments.length, + + // count of unprocessed arguments + i = remaining, + + // subordinate fulfillment data + resolveContexts = Array( i ), + resolveValues = slice.call( arguments ), + + // the master Deferred + master = jQuery.Deferred(), + + // subordinate callback factory + updateFunc = function( i ) { + return function( value ) { + resolveContexts[ i ] = this; + resolveValues[ i ] = arguments.length > 1 ? slice.call( arguments ) : value; + if ( !( --remaining ) ) { + master.resolveWith( resolveContexts, resolveValues ); + } + }; + }; + + // Single- and empty arguments are adopted like Promise.resolve + if ( remaining <= 1 ) { + adoptValue( singleValue, master.done( updateFunc( i ) ).resolve, master.reject, + !remaining ); + + // Use .then() to unwrap secondary thenables (cf. gh-3000) + if ( master.state() === "pending" || + isFunction( resolveValues[ i ] && resolveValues[ i ].then ) ) { + + return master.then(); + } + } + + // Multiple arguments are aggregated like Promise.all array elements + while ( i-- ) { + adoptValue( resolveValues[ i ], updateFunc( i ), master.reject ); + } + + return master.promise(); + } +} ); + + +// These usually indicate a programmer mistake during development, +// warn about them ASAP rather than swallowing them by default. +var rerrorNames = /^(Eval|Internal|Range|Reference|Syntax|Type|URI)Error$/; + +jQuery.Deferred.exceptionHook = function( error, stack ) { + + // Support: IE 8 - 9 only + // Console exists when dev tools are open, which can happen at any time + if ( window.console && window.console.warn && error && rerrorNames.test( error.name ) ) { + window.console.warn( "jQuery.Deferred exception: " + error.message, error.stack, stack ); + } +}; + + + + +jQuery.readyException = function( error ) { + window.setTimeout( function() { + throw error; + } ); +}; + + + + +// The deferred used on DOM ready +var readyList = jQuery.Deferred(); + +jQuery.fn.ready = function( fn ) { + + readyList + .then( fn ) + + // Wrap jQuery.readyException in a function so that the lookup + // happens at the time of error handling instead of callback + // registration. + .catch( function( error ) { + jQuery.readyException( error ); + } ); + + return this; +}; + +jQuery.extend( { + + // Is the DOM ready to be used? Set to true once it occurs. + isReady: false, + + // A counter to track how many items to wait for before + // the ready event fires. See #6781 + readyWait: 1, + + // Handle when the DOM is ready + ready: function( wait ) { + + // Abort if there are pending holds or we're already ready + if ( wait === true ? --jQuery.readyWait : jQuery.isReady ) { + return; + } + + // Remember that the DOM is ready + jQuery.isReady = true; + + // If a normal DOM Ready event fired, decrement, and wait if need be + if ( wait !== true && --jQuery.readyWait > 0 ) { + return; + } + + // If there are functions bound, to execute + readyList.resolveWith( document, [ jQuery ] ); + } +} ); + +jQuery.ready.then = readyList.then; + +// The ready event handler and self cleanup method +function completed() { + document.removeEventListener( "DOMContentLoaded", completed ); + window.removeEventListener( "load", completed ); + jQuery.ready(); +} + +// Catch cases where $(document).ready() is called +// after the browser event has already occurred. +// Support: IE <=9 - 10 only +// Older IE sometimes signals "interactive" too soon +if ( document.readyState === "complete" || + ( document.readyState !== "loading" && !document.documentElement.doScroll ) ) { + + // Handle it asynchronously to allow scripts the opportunity to delay ready + window.setTimeout( jQuery.ready ); + +} else { + + // Use the handy event callback + document.addEventListener( "DOMContentLoaded", completed ); + + // A fallback to window.onload, that will always work + window.addEventListener( "load", completed ); +} + + + + +// Multifunctional method to get and set values of a collection +// The value/s can optionally be executed if it's a function +var access = function( elems, fn, key, value, chainable, emptyGet, raw ) { + var i = 0, + len = elems.length, + bulk = key == null; + + // Sets many values + if ( toType( key ) === "object" ) { + chainable = true; + for ( i in key ) { + access( elems, fn, i, key[ i ], true, emptyGet, raw ); + } + + // Sets one value + } else if ( value !== undefined ) { + chainable = true; + + if ( !isFunction( value ) ) { + raw = true; + } + + if ( bulk ) { + + // Bulk operations run against the entire set + if ( raw ) { + fn.call( elems, value ); + fn = null; + + // ...except when executing function values + } else { + bulk = fn; + fn = function( elem, key, value ) { + return bulk.call( jQuery( elem ), value ); + }; + } + } + + if ( fn ) { + for ( ; i < len; i++ ) { + fn( + elems[ i ], key, raw ? + value : + value.call( elems[ i ], i, fn( elems[ i ], key ) ) + ); + } + } + } + + if ( chainable ) { + return elems; + } + + // Gets + if ( bulk ) { + return fn.call( elems ); + } + + return len ? fn( elems[ 0 ], key ) : emptyGet; +}; + + +// Matches dashed string for camelizing +var rmsPrefix = /^-ms-/, + rdashAlpha = /-([a-z])/g; + +// Used by camelCase as callback to replace() +function fcamelCase( all, letter ) { + return letter.toUpperCase(); +} + +// Convert dashed to camelCase; used by the css and data modules +// Support: IE <=9 - 11, Edge 12 - 15 +// Microsoft forgot to hump their vendor prefix (#9572) +function camelCase( string ) { + return string.replace( rmsPrefix, "ms-" ).replace( rdashAlpha, fcamelCase ); +} +var acceptData = function( owner ) { + + // Accepts only: + // - Node + // - Node.ELEMENT_NODE + // - Node.DOCUMENT_NODE + // - Object + // - Any + return owner.nodeType === 1 || owner.nodeType === 9 || !( +owner.nodeType ); +}; + + + + +function Data() { + this.expando = jQuery.expando + Data.uid++; +} + +Data.uid = 1; + +Data.prototype = { + + cache: function( owner ) { + + // Check if the owner object already has a cache + var value = owner[ this.expando ]; + + // If not, create one + if ( !value ) { + value = {}; + + // We can accept data for non-element nodes in modern browsers, + // but we should not, see #8335. + // Always return an empty object. + if ( acceptData( owner ) ) { + + // If it is a node unlikely to be stringify-ed or looped over + // use plain assignment + if ( owner.nodeType ) { + owner[ this.expando ] = value; + + // Otherwise secure it in a non-enumerable property + // configurable must be true to allow the property to be + // deleted when data is removed + } else { + Object.defineProperty( owner, this.expando, { + value: value, + configurable: true + } ); + } + } + } + + return value; + }, + set: function( owner, data, value ) { + var prop, + cache = this.cache( owner ); + + // Handle: [ owner, key, value ] args + // Always use camelCase key (gh-2257) + if ( typeof data === "string" ) { + cache[ camelCase( data ) ] = value; + + // Handle: [ owner, { properties } ] args + } else { + + // Copy the properties one-by-one to the cache object + for ( prop in data ) { + cache[ camelCase( prop ) ] = data[ prop ]; + } + } + return cache; + }, + get: function( owner, key ) { + return key === undefined ? + this.cache( owner ) : + + // Always use camelCase key (gh-2257) + owner[ this.expando ] && owner[ this.expando ][ camelCase( key ) ]; + }, + access: function( owner, key, value ) { + + // In cases where either: + // + // 1. No key was specified + // 2. A string key was specified, but no value provided + // + // Take the "read" path and allow the get method to determine + // which value to return, respectively either: + // + // 1. The entire cache object + // 2. The data stored at the key + // + if ( key === undefined || + ( ( key && typeof key === "string" ) && value === undefined ) ) { + + return this.get( owner, key ); + } + + // When the key is not a string, or both a key and value + // are specified, set or extend (existing objects) with either: + // + // 1. An object of properties + // 2. A key and value + // + this.set( owner, key, value ); + + // Since the "set" path can have two possible entry points + // return the expected data based on which path was taken[*] + return value !== undefined ? value : key; + }, + remove: function( owner, key ) { + var i, + cache = owner[ this.expando ]; + + if ( cache === undefined ) { + return; + } + + if ( key !== undefined ) { + + // Support array or space separated string of keys + if ( Array.isArray( key ) ) { + + // If key is an array of keys... + // We always set camelCase keys, so remove that. + key = key.map( camelCase ); + } else { + key = camelCase( key ); + + // If a key with the spaces exists, use it. + // Otherwise, create an array by matching non-whitespace + key = key in cache ? + [ key ] : + ( key.match( rnothtmlwhite ) || [] ); + } + + i = key.length; + + while ( i-- ) { + delete cache[ key[ i ] ]; + } + } + + // Remove the expando if there's no more data + if ( key === undefined || jQuery.isEmptyObject( cache ) ) { + + // Support: Chrome <=35 - 45 + // Webkit & Blink performance suffers when deleting properties + // from DOM nodes, so set to undefined instead + // https://bugs.chromium.org/p/chromium/issues/detail?id=378607 (bug restricted) + if ( owner.nodeType ) { + owner[ this.expando ] = undefined; + } else { + delete owner[ this.expando ]; + } + } + }, + hasData: function( owner ) { + var cache = owner[ this.expando ]; + return cache !== undefined && !jQuery.isEmptyObject( cache ); + } +}; +var dataPriv = new Data(); + +var dataUser = new Data(); + + + +// Implementation Summary +// +// 1. Enforce API surface and semantic compatibility with 1.9.x branch +// 2. Improve the module's maintainability by reducing the storage +// paths to a single mechanism. +// 3. Use the same single mechanism to support "private" and "user" data. +// 4. _Never_ expose "private" data to user code (TODO: Drop _data, _removeData) +// 5. Avoid exposing implementation details on user objects (eg. expando properties) +// 6. Provide a clear path for implementation upgrade to WeakMap in 2014 + +var rbrace = /^(?:\{[\w\W]*\}|\[[\w\W]*\])$/, + rmultiDash = /[A-Z]/g; + +function getData( data ) { + if ( data === "true" ) { + return true; + } + + if ( data === "false" ) { + return false; + } + + if ( data === "null" ) { + return null; + } + + // Only convert to a number if it doesn't change the string + if ( data === +data + "" ) { + return +data; + } + + if ( rbrace.test( data ) ) { + return JSON.parse( data ); + } + + return data; +} + +function dataAttr( elem, key, data ) { + var name; + + // If nothing was found internally, try to fetch any + // data from the HTML5 data-* attribute + if ( data === undefined && elem.nodeType === 1 ) { + name = "data-" + key.replace( rmultiDash, "-$&" ).toLowerCase(); + data = elem.getAttribute( name ); + + if ( typeof data === "string" ) { + try { + data = getData( data ); + } catch ( e ) {} + + // Make sure we set the data so it isn't changed later + dataUser.set( elem, key, data ); + } else { + data = undefined; + } + } + return data; +} + +jQuery.extend( { + hasData: function( elem ) { + return dataUser.hasData( elem ) || dataPriv.hasData( elem ); + }, + + data: function( elem, name, data ) { + return dataUser.access( elem, name, data ); + }, + + removeData: function( elem, name ) { + dataUser.remove( elem, name ); + }, + + // TODO: Now that all calls to _data and _removeData have been replaced + // with direct calls to dataPriv methods, these can be deprecated. + _data: function( elem, name, data ) { + return dataPriv.access( elem, name, data ); + }, + + _removeData: function( elem, name ) { + dataPriv.remove( elem, name ); + } +} ); + +jQuery.fn.extend( { + data: function( key, value ) { + var i, name, data, + elem = this[ 0 ], + attrs = elem && elem.attributes; + + // Gets all values + if ( key === undefined ) { + if ( this.length ) { + data = dataUser.get( elem ); + + if ( elem.nodeType === 1 && !dataPriv.get( elem, "hasDataAttrs" ) ) { + i = attrs.length; + while ( i-- ) { + + // Support: IE 11 only + // The attrs elements can be null (#14894) + if ( attrs[ i ] ) { + name = attrs[ i ].name; + if ( name.indexOf( "data-" ) === 0 ) { + name = camelCase( name.slice( 5 ) ); + dataAttr( elem, name, data[ name ] ); + } + } + } + dataPriv.set( elem, "hasDataAttrs", true ); + } + } + + return data; + } + + // Sets multiple values + if ( typeof key === "object" ) { + return this.each( function() { + dataUser.set( this, key ); + } ); + } + + return access( this, function( value ) { + var data; + + // The calling jQuery object (element matches) is not empty + // (and therefore has an element appears at this[ 0 ]) and the + // `value` parameter was not undefined. An empty jQuery object + // will result in `undefined` for elem = this[ 0 ] which will + // throw an exception if an attempt to read a data cache is made. + if ( elem && value === undefined ) { + + // Attempt to get data from the cache + // The key will always be camelCased in Data + data = dataUser.get( elem, key ); + if ( data !== undefined ) { + return data; + } + + // Attempt to "discover" the data in + // HTML5 custom data-* attrs + data = dataAttr( elem, key ); + if ( data !== undefined ) { + return data; + } + + // We tried really hard, but the data doesn't exist. + return; + } + + // Set the data... + this.each( function() { + + // We always store the camelCased key + dataUser.set( this, key, value ); + } ); + }, null, value, arguments.length > 1, null, true ); + }, + + removeData: function( key ) { + return this.each( function() { + dataUser.remove( this, key ); + } ); + } +} ); + + +jQuery.extend( { + queue: function( elem, type, data ) { + var queue; + + if ( elem ) { + type = ( type || "fx" ) + "queue"; + queue = dataPriv.get( elem, type ); + + // Speed up dequeue by getting out quickly if this is just a lookup + if ( data ) { + if ( !queue || Array.isArray( data ) ) { + queue = dataPriv.access( elem, type, jQuery.makeArray( data ) ); + } else { + queue.push( data ); + } + } + return queue || []; + } + }, + + dequeue: function( elem, type ) { + type = type || "fx"; + + var queue = jQuery.queue( elem, type ), + startLength = queue.length, + fn = queue.shift(), + hooks = jQuery._queueHooks( elem, type ), + next = function() { + jQuery.dequeue( elem, type ); + }; + + // If the fx queue is dequeued, always remove the progress sentinel + if ( fn === "inprogress" ) { + fn = queue.shift(); + startLength--; + } + + if ( fn ) { + + // Add a progress sentinel to prevent the fx queue from being + // automatically dequeued + if ( type === "fx" ) { + queue.unshift( "inprogress" ); + } + + // Clear up the last queue stop function + delete hooks.stop; + fn.call( elem, next, hooks ); + } + + if ( !startLength && hooks ) { + hooks.empty.fire(); + } + }, + + // Not public - generate a queueHooks object, or return the current one + _queueHooks: function( elem, type ) { + var key = type + "queueHooks"; + return dataPriv.get( elem, key ) || dataPriv.access( elem, key, { + empty: jQuery.Callbacks( "once memory" ).add( function() { + dataPriv.remove( elem, [ type + "queue", key ] ); + } ) + } ); + } +} ); + +jQuery.fn.extend( { + queue: function( type, data ) { + var setter = 2; + + if ( typeof type !== "string" ) { + data = type; + type = "fx"; + setter--; + } + + if ( arguments.length < setter ) { + return jQuery.queue( this[ 0 ], type ); + } + + return data === undefined ? + this : + this.each( function() { + var queue = jQuery.queue( this, type, data ); + + // Ensure a hooks for this queue + jQuery._queueHooks( this, type ); + + if ( type === "fx" && queue[ 0 ] !== "inprogress" ) { + jQuery.dequeue( this, type ); + } + } ); + }, + dequeue: function( type ) { + return this.each( function() { + jQuery.dequeue( this, type ); + } ); + }, + clearQueue: function( type ) { + return this.queue( type || "fx", [] ); + }, + + // Get a promise resolved when queues of a certain type + // are emptied (fx is the type by default) + promise: function( type, obj ) { + var tmp, + count = 1, + defer = jQuery.Deferred(), + elements = this, + i = this.length, + resolve = function() { + if ( !( --count ) ) { + defer.resolveWith( elements, [ elements ] ); + } + }; + + if ( typeof type !== "string" ) { + obj = type; + type = undefined; + } + type = type || "fx"; + + while ( i-- ) { + tmp = dataPriv.get( elements[ i ], type + "queueHooks" ); + if ( tmp && tmp.empty ) { + count++; + tmp.empty.add( resolve ); + } + } + resolve(); + return defer.promise( obj ); + } +} ); +var pnum = ( /[+-]?(?:\d*\.|)\d+(?:[eE][+-]?\d+|)/ ).source; + +var rcssNum = new RegExp( "^(?:([+-])=|)(" + pnum + ")([a-z%]*)$", "i" ); + + +var cssExpand = [ "Top", "Right", "Bottom", "Left" ]; + +var documentElement = document.documentElement; + + + + var isAttached = function( elem ) { + return jQuery.contains( elem.ownerDocument, elem ); + }, + composed = { composed: true }; + + // Support: IE 9 - 11+, Edge 12 - 18+, iOS 10.0 - 10.2 only + // Check attachment across shadow DOM boundaries when possible (gh-3504) + // Support: iOS 10.0-10.2 only + // Early iOS 10 versions support `attachShadow` but not `getRootNode`, + // leading to errors. We need to check for `getRootNode`. + if ( documentElement.getRootNode ) { + isAttached = function( elem ) { + return jQuery.contains( elem.ownerDocument, elem ) || + elem.getRootNode( composed ) === elem.ownerDocument; + }; + } +var isHiddenWithinTree = function( elem, el ) { + + // isHiddenWithinTree might be called from jQuery#filter function; + // in that case, element will be second argument + elem = el || elem; + + // Inline style trumps all + return elem.style.display === "none" || + elem.style.display === "" && + + // Otherwise, check computed style + // Support: Firefox <=43 - 45 + // Disconnected elements can have computed display: none, so first confirm that elem is + // in the document. + isAttached( elem ) && + + jQuery.css( elem, "display" ) === "none"; + }; + +var swap = function( elem, options, callback, args ) { + var ret, name, + old = {}; + + // Remember the old values, and insert the new ones + for ( name in options ) { + old[ name ] = elem.style[ name ]; + elem.style[ name ] = options[ name ]; + } + + ret = callback.apply( elem, args || [] ); + + // Revert the old values + for ( name in options ) { + elem.style[ name ] = old[ name ]; + } + + return ret; +}; + + + + +function adjustCSS( elem, prop, valueParts, tween ) { + var adjusted, scale, + maxIterations = 20, + currentValue = tween ? + function() { + return tween.cur(); + } : + function() { + return jQuery.css( elem, prop, "" ); + }, + initial = currentValue(), + unit = valueParts && valueParts[ 3 ] || ( jQuery.cssNumber[ prop ] ? "" : "px" ), + + // Starting value computation is required for potential unit mismatches + initialInUnit = elem.nodeType && + ( jQuery.cssNumber[ prop ] || unit !== "px" && +initial ) && + rcssNum.exec( jQuery.css( elem, prop ) ); + + if ( initialInUnit && initialInUnit[ 3 ] !== unit ) { + + // Support: Firefox <=54 + // Halve the iteration target value to prevent interference from CSS upper bounds (gh-2144) + initial = initial / 2; + + // Trust units reported by jQuery.css + unit = unit || initialInUnit[ 3 ]; + + // Iteratively approximate from a nonzero starting point + initialInUnit = +initial || 1; + + while ( maxIterations-- ) { + + // Evaluate and update our best guess (doubling guesses that zero out). + // Finish if the scale equals or crosses 1 (making the old*new product non-positive). + jQuery.style( elem, prop, initialInUnit + unit ); + if ( ( 1 - scale ) * ( 1 - ( scale = currentValue() / initial || 0.5 ) ) <= 0 ) { + maxIterations = 0; + } + initialInUnit = initialInUnit / scale; + + } + + initialInUnit = initialInUnit * 2; + jQuery.style( elem, prop, initialInUnit + unit ); + + // Make sure we update the tween properties later on + valueParts = valueParts || []; + } + + if ( valueParts ) { + initialInUnit = +initialInUnit || +initial || 0; + + // Apply relative offset (+=/-=) if specified + adjusted = valueParts[ 1 ] ? + initialInUnit + ( valueParts[ 1 ] + 1 ) * valueParts[ 2 ] : + +valueParts[ 2 ]; + if ( tween ) { + tween.unit = unit; + tween.start = initialInUnit; + tween.end = adjusted; + } + } + return adjusted; +} + + +var defaultDisplayMap = {}; + +function getDefaultDisplay( elem ) { + var temp, + doc = elem.ownerDocument, + nodeName = elem.nodeName, + display = defaultDisplayMap[ nodeName ]; + + if ( display ) { + return display; + } + + temp = doc.body.appendChild( doc.createElement( nodeName ) ); + display = jQuery.css( temp, "display" ); + + temp.parentNode.removeChild( temp ); + + if ( display === "none" ) { + display = "block"; + } + defaultDisplayMap[ nodeName ] = display; + + return display; +} + +function showHide( elements, show ) { + var display, elem, + values = [], + index = 0, + length = elements.length; + + // Determine new display value for elements that need to change + for ( ; index < length; index++ ) { + elem = elements[ index ]; + if ( !elem.style ) { + continue; + } + + display = elem.style.display; + if ( show ) { + + // Since we force visibility upon cascade-hidden elements, an immediate (and slow) + // check is required in this first loop unless we have a nonempty display value (either + // inline or about-to-be-restored) + if ( display === "none" ) { + values[ index ] = dataPriv.get( elem, "display" ) || null; + if ( !values[ index ] ) { + elem.style.display = ""; + } + } + if ( elem.style.display === "" && isHiddenWithinTree( elem ) ) { + values[ index ] = getDefaultDisplay( elem ); + } + } else { + if ( display !== "none" ) { + values[ index ] = "none"; + + // Remember what we're overwriting + dataPriv.set( elem, "display", display ); + } + } + } + + // Set the display of the elements in a second loop to avoid constant reflow + for ( index = 0; index < length; index++ ) { + if ( values[ index ] != null ) { + elements[ index ].style.display = values[ index ]; + } + } + + return elements; +} + +jQuery.fn.extend( { + show: function() { + return showHide( this, true ); + }, + hide: function() { + return showHide( this ); + }, + toggle: function( state ) { + if ( typeof state === "boolean" ) { + return state ? this.show() : this.hide(); + } + + return this.each( function() { + if ( isHiddenWithinTree( this ) ) { + jQuery( this ).show(); + } else { + jQuery( this ).hide(); + } + } ); + } +} ); +var rcheckableType = ( /^(?:checkbox|radio)$/i ); + +var rtagName = ( /<([a-z][^\/\0>\x20\t\r\n\f]*)/i ); + +var rscriptType = ( /^$|^module$|\/(?:java|ecma)script/i ); + + + +// We have to close these tags to support XHTML (#13200) +var wrapMap = { + + // Support: IE <=9 only + option: [ 1, "" ], + + // XHTML parsers do not magically insert elements in the + // same way that tag soup parsers do. So we cannot shorten + // this by omitting or other required elements. + thead: [ 1, "", "
" ], + col: [ 2, "", "
" ], + tr: [ 2, "", "
" ], + td: [ 3, "", "
" ], + + _default: [ 0, "", "" ] +}; + +// Support: IE <=9 only +wrapMap.optgroup = wrapMap.option; + +wrapMap.tbody = wrapMap.tfoot = wrapMap.colgroup = wrapMap.caption = wrapMap.thead; +wrapMap.th = wrapMap.td; + + +function getAll( context, tag ) { + + // Support: IE <=9 - 11 only + // Use typeof to avoid zero-argument method invocation on host objects (#15151) + var ret; + + if ( typeof context.getElementsByTagName !== "undefined" ) { + ret = context.getElementsByTagName( tag || "*" ); + + } else if ( typeof context.querySelectorAll !== "undefined" ) { + ret = context.querySelectorAll( tag || "*" ); + + } else { + ret = []; + } + + if ( tag === undefined || tag && nodeName( context, tag ) ) { + return jQuery.merge( [ context ], ret ); + } + + return ret; +} + + +// Mark scripts as having already been evaluated +function setGlobalEval( elems, refElements ) { + var i = 0, + l = elems.length; + + for ( ; i < l; i++ ) { + dataPriv.set( + elems[ i ], + "globalEval", + !refElements || dataPriv.get( refElements[ i ], "globalEval" ) + ); + } +} + + +var rhtml = /<|&#?\w+;/; + +function buildFragment( elems, context, scripts, selection, ignored ) { + var elem, tmp, tag, wrap, attached, j, + fragment = context.createDocumentFragment(), + nodes = [], + i = 0, + l = elems.length; + + for ( ; i < l; i++ ) { + elem = elems[ i ]; + + if ( elem || elem === 0 ) { + + // Add nodes directly + if ( toType( elem ) === "object" ) { + + // Support: Android <=4.0 only, PhantomJS 1 only + // push.apply(_, arraylike) throws on ancient WebKit + jQuery.merge( nodes, elem.nodeType ? [ elem ] : elem ); + + // Convert non-html into a text node + } else if ( !rhtml.test( elem ) ) { + nodes.push( context.createTextNode( elem ) ); + + // Convert html into DOM nodes + } else { + tmp = tmp || fragment.appendChild( context.createElement( "div" ) ); + + // Deserialize a standard representation + tag = ( rtagName.exec( elem ) || [ "", "" ] )[ 1 ].toLowerCase(); + wrap = wrapMap[ tag ] || wrapMap._default; + tmp.innerHTML = wrap[ 1 ] + jQuery.htmlPrefilter( elem ) + wrap[ 2 ]; + + // Descend through wrappers to the right content + j = wrap[ 0 ]; + while ( j-- ) { + tmp = tmp.lastChild; + } + + // Support: Android <=4.0 only, PhantomJS 1 only + // push.apply(_, arraylike) throws on ancient WebKit + jQuery.merge( nodes, tmp.childNodes ); + + // Remember the top-level container + tmp = fragment.firstChild; + + // Ensure the created nodes are orphaned (#12392) + tmp.textContent = ""; + } + } + } + + // Remove wrapper from fragment + fragment.textContent = ""; + + i = 0; + while ( ( elem = nodes[ i++ ] ) ) { + + // Skip elements already in the context collection (trac-4087) + if ( selection && jQuery.inArray( elem, selection ) > -1 ) { + if ( ignored ) { + ignored.push( elem ); + } + continue; + } + + attached = isAttached( elem ); + + // Append to fragment + tmp = getAll( fragment.appendChild( elem ), "script" ); + + // Preserve script evaluation history + if ( attached ) { + setGlobalEval( tmp ); + } + + // Capture executables + if ( scripts ) { + j = 0; + while ( ( elem = tmp[ j++ ] ) ) { + if ( rscriptType.test( elem.type || "" ) ) { + scripts.push( elem ); + } + } + } + } + + return fragment; +} + + +( function() { + var fragment = document.createDocumentFragment(), + div = fragment.appendChild( document.createElement( "div" ) ), + input = document.createElement( "input" ); + + // Support: Android 4.0 - 4.3 only + // Check state lost if the name is set (#11217) + // Support: Windows Web Apps (WWA) + // `name` and `type` must use .setAttribute for WWA (#14901) + input.setAttribute( "type", "radio" ); + input.setAttribute( "checked", "checked" ); + input.setAttribute( "name", "t" ); + + div.appendChild( input ); + + // Support: Android <=4.1 only + // Older WebKit doesn't clone checked state correctly in fragments + support.checkClone = div.cloneNode( true ).cloneNode( true ).lastChild.checked; + + // Support: IE <=11 only + // Make sure textarea (and checkbox) defaultValue is properly cloned + div.innerHTML = ""; + support.noCloneChecked = !!div.cloneNode( true ).lastChild.defaultValue; +} )(); + + +var + rkeyEvent = /^key/, + rmouseEvent = /^(?:mouse|pointer|contextmenu|drag|drop)|click/, + rtypenamespace = /^([^.]*)(?:\.(.+)|)/; + +function returnTrue() { + return true; +} + +function returnFalse() { + return false; +} + +// Support: IE <=9 - 11+ +// focus() and blur() are asynchronous, except when they are no-op. +// So expect focus to be synchronous when the element is already active, +// and blur to be synchronous when the element is not already active. +// (focus and blur are always synchronous in other supported browsers, +// this just defines when we can count on it). +function expectSync( elem, type ) { + return ( elem === safeActiveElement() ) === ( type === "focus" ); +} + +// Support: IE <=9 only +// Accessing document.activeElement can throw unexpectedly +// https://bugs.jquery.com/ticket/13393 +function safeActiveElement() { + try { + return document.activeElement; + } catch ( err ) { } +} + +function on( elem, types, selector, data, fn, one ) { + var origFn, type; + + // Types can be a map of types/handlers + if ( typeof types === "object" ) { + + // ( types-Object, selector, data ) + if ( typeof selector !== "string" ) { + + // ( types-Object, data ) + data = data || selector; + selector = undefined; + } + for ( type in types ) { + on( elem, type, selector, data, types[ type ], one ); + } + return elem; + } + + if ( data == null && fn == null ) { + + // ( types, fn ) + fn = selector; + data = selector = undefined; + } else if ( fn == null ) { + if ( typeof selector === "string" ) { + + // ( types, selector, fn ) + fn = data; + data = undefined; + } else { + + // ( types, data, fn ) + fn = data; + data = selector; + selector = undefined; + } + } + if ( fn === false ) { + fn = returnFalse; + } else if ( !fn ) { + return elem; + } + + if ( one === 1 ) { + origFn = fn; + fn = function( event ) { + + // Can use an empty set, since event contains the info + jQuery().off( event ); + return origFn.apply( this, arguments ); + }; + + // Use same guid so caller can remove using origFn + fn.guid = origFn.guid || ( origFn.guid = jQuery.guid++ ); + } + return elem.each( function() { + jQuery.event.add( this, types, fn, data, selector ); + } ); +} + +/* + * Helper functions for managing events -- not part of the public interface. + * Props to Dean Edwards' addEvent library for many of the ideas. + */ +jQuery.event = { + + global: {}, + + add: function( elem, types, handler, data, selector ) { + + var handleObjIn, eventHandle, tmp, + events, t, handleObj, + special, handlers, type, namespaces, origType, + elemData = dataPriv.get( elem ); + + // Don't attach events to noData or text/comment nodes (but allow plain objects) + if ( !elemData ) { + return; + } + + // Caller can pass in an object of custom data in lieu of the handler + if ( handler.handler ) { + handleObjIn = handler; + handler = handleObjIn.handler; + selector = handleObjIn.selector; + } + + // Ensure that invalid selectors throw exceptions at attach time + // Evaluate against documentElement in case elem is a non-element node (e.g., document) + if ( selector ) { + jQuery.find.matchesSelector( documentElement, selector ); + } + + // Make sure that the handler has a unique ID, used to find/remove it later + if ( !handler.guid ) { + handler.guid = jQuery.guid++; + } + + // Init the element's event structure and main handler, if this is the first + if ( !( events = elemData.events ) ) { + events = elemData.events = {}; + } + if ( !( eventHandle = elemData.handle ) ) { + eventHandle = elemData.handle = function( e ) { + + // Discard the second event of a jQuery.event.trigger() and + // when an event is called after a page has unloaded + return typeof jQuery !== "undefined" && jQuery.event.triggered !== e.type ? + jQuery.event.dispatch.apply( elem, arguments ) : undefined; + }; + } + + // Handle multiple events separated by a space + types = ( types || "" ).match( rnothtmlwhite ) || [ "" ]; + t = types.length; + while ( t-- ) { + tmp = rtypenamespace.exec( types[ t ] ) || []; + type = origType = tmp[ 1 ]; + namespaces = ( tmp[ 2 ] || "" ).split( "." ).sort(); + + // There *must* be a type, no attaching namespace-only handlers + if ( !type ) { + continue; + } + + // If event changes its type, use the special event handlers for the changed type + special = jQuery.event.special[ type ] || {}; + + // If selector defined, determine special event api type, otherwise given type + type = ( selector ? special.delegateType : special.bindType ) || type; + + // Update special based on newly reset type + special = jQuery.event.special[ type ] || {}; + + // handleObj is passed to all event handlers + handleObj = jQuery.extend( { + type: type, + origType: origType, + data: data, + handler: handler, + guid: handler.guid, + selector: selector, + needsContext: selector && jQuery.expr.match.needsContext.test( selector ), + namespace: namespaces.join( "." ) + }, handleObjIn ); + + // Init the event handler queue if we're the first + if ( !( handlers = events[ type ] ) ) { + handlers = events[ type ] = []; + handlers.delegateCount = 0; + + // Only use addEventListener if the special events handler returns false + if ( !special.setup || + special.setup.call( elem, data, namespaces, eventHandle ) === false ) { + + if ( elem.addEventListener ) { + elem.addEventListener( type, eventHandle ); + } + } + } + + if ( special.add ) { + special.add.call( elem, handleObj ); + + if ( !handleObj.handler.guid ) { + handleObj.handler.guid = handler.guid; + } + } + + // Add to the element's handler list, delegates in front + if ( selector ) { + handlers.splice( handlers.delegateCount++, 0, handleObj ); + } else { + handlers.push( handleObj ); + } + + // Keep track of which events have ever been used, for event optimization + jQuery.event.global[ type ] = true; + } + + }, + + // Detach an event or set of events from an element + remove: function( elem, types, handler, selector, mappedTypes ) { + + var j, origCount, tmp, + events, t, handleObj, + special, handlers, type, namespaces, origType, + elemData = dataPriv.hasData( elem ) && dataPriv.get( elem ); + + if ( !elemData || !( events = elemData.events ) ) { + return; + } + + // Once for each type.namespace in types; type may be omitted + types = ( types || "" ).match( rnothtmlwhite ) || [ "" ]; + t = types.length; + while ( t-- ) { + tmp = rtypenamespace.exec( types[ t ] ) || []; + type = origType = tmp[ 1 ]; + namespaces = ( tmp[ 2 ] || "" ).split( "." ).sort(); + + // Unbind all events (on this namespace, if provided) for the element + if ( !type ) { + for ( type in events ) { + jQuery.event.remove( elem, type + types[ t ], handler, selector, true ); + } + continue; + } + + special = jQuery.event.special[ type ] || {}; + type = ( selector ? special.delegateType : special.bindType ) || type; + handlers = events[ type ] || []; + tmp = tmp[ 2 ] && + new RegExp( "(^|\\.)" + namespaces.join( "\\.(?:.*\\.|)" ) + "(\\.|$)" ); + + // Remove matching events + origCount = j = handlers.length; + while ( j-- ) { + handleObj = handlers[ j ]; + + if ( ( mappedTypes || origType === handleObj.origType ) && + ( !handler || handler.guid === handleObj.guid ) && + ( !tmp || tmp.test( handleObj.namespace ) ) && + ( !selector || selector === handleObj.selector || + selector === "**" && handleObj.selector ) ) { + handlers.splice( j, 1 ); + + if ( handleObj.selector ) { + handlers.delegateCount--; + } + if ( special.remove ) { + special.remove.call( elem, handleObj ); + } + } + } + + // Remove generic event handler if we removed something and no more handlers exist + // (avoids potential for endless recursion during removal of special event handlers) + if ( origCount && !handlers.length ) { + if ( !special.teardown || + special.teardown.call( elem, namespaces, elemData.handle ) === false ) { + + jQuery.removeEvent( elem, type, elemData.handle ); + } + + delete events[ type ]; + } + } + + // Remove data and the expando if it's no longer used + if ( jQuery.isEmptyObject( events ) ) { + dataPriv.remove( elem, "handle events" ); + } + }, + + dispatch: function( nativeEvent ) { + + // Make a writable jQuery.Event from the native event object + var event = jQuery.event.fix( nativeEvent ); + + var i, j, ret, matched, handleObj, handlerQueue, + args = new Array( arguments.length ), + handlers = ( dataPriv.get( this, "events" ) || {} )[ event.type ] || [], + special = jQuery.event.special[ event.type ] || {}; + + // Use the fix-ed jQuery.Event rather than the (read-only) native event + args[ 0 ] = event; + + for ( i = 1; i < arguments.length; i++ ) { + args[ i ] = arguments[ i ]; + } + + event.delegateTarget = this; + + // Call the preDispatch hook for the mapped type, and let it bail if desired + if ( special.preDispatch && special.preDispatch.call( this, event ) === false ) { + return; + } + + // Determine handlers + handlerQueue = jQuery.event.handlers.call( this, event, handlers ); + + // Run delegates first; they may want to stop propagation beneath us + i = 0; + while ( ( matched = handlerQueue[ i++ ] ) && !event.isPropagationStopped() ) { + event.currentTarget = matched.elem; + + j = 0; + while ( ( handleObj = matched.handlers[ j++ ] ) && + !event.isImmediatePropagationStopped() ) { + + // If the event is namespaced, then each handler is only invoked if it is + // specially universal or its namespaces are a superset of the event's. + if ( !event.rnamespace || handleObj.namespace === false || + event.rnamespace.test( handleObj.namespace ) ) { + + event.handleObj = handleObj; + event.data = handleObj.data; + + ret = ( ( jQuery.event.special[ handleObj.origType ] || {} ).handle || + handleObj.handler ).apply( matched.elem, args ); + + if ( ret !== undefined ) { + if ( ( event.result = ret ) === false ) { + event.preventDefault(); + event.stopPropagation(); + } + } + } + } + } + + // Call the postDispatch hook for the mapped type + if ( special.postDispatch ) { + special.postDispatch.call( this, event ); + } + + return event.result; + }, + + handlers: function( event, handlers ) { + var i, handleObj, sel, matchedHandlers, matchedSelectors, + handlerQueue = [], + delegateCount = handlers.delegateCount, + cur = event.target; + + // Find delegate handlers + if ( delegateCount && + + // Support: IE <=9 + // Black-hole SVG instance trees (trac-13180) + cur.nodeType && + + // Support: Firefox <=42 + // Suppress spec-violating clicks indicating a non-primary pointer button (trac-3861) + // https://www.w3.org/TR/DOM-Level-3-Events/#event-type-click + // Support: IE 11 only + // ...but not arrow key "clicks" of radio inputs, which can have `button` -1 (gh-2343) + !( event.type === "click" && event.button >= 1 ) ) { + + for ( ; cur !== this; cur = cur.parentNode || this ) { + + // Don't check non-elements (#13208) + // Don't process clicks on disabled elements (#6911, #8165, #11382, #11764) + if ( cur.nodeType === 1 && !( event.type === "click" && cur.disabled === true ) ) { + matchedHandlers = []; + matchedSelectors = {}; + for ( i = 0; i < delegateCount; i++ ) { + handleObj = handlers[ i ]; + + // Don't conflict with Object.prototype properties (#13203) + sel = handleObj.selector + " "; + + if ( matchedSelectors[ sel ] === undefined ) { + matchedSelectors[ sel ] = handleObj.needsContext ? + jQuery( sel, this ).index( cur ) > -1 : + jQuery.find( sel, this, null, [ cur ] ).length; + } + if ( matchedSelectors[ sel ] ) { + matchedHandlers.push( handleObj ); + } + } + if ( matchedHandlers.length ) { + handlerQueue.push( { elem: cur, handlers: matchedHandlers } ); + } + } + } + } + + // Add the remaining (directly-bound) handlers + cur = this; + if ( delegateCount < handlers.length ) { + handlerQueue.push( { elem: cur, handlers: handlers.slice( delegateCount ) } ); + } + + return handlerQueue; + }, + + addProp: function( name, hook ) { + Object.defineProperty( jQuery.Event.prototype, name, { + enumerable: true, + configurable: true, + + get: isFunction( hook ) ? + function() { + if ( this.originalEvent ) { + return hook( this.originalEvent ); + } + } : + function() { + if ( this.originalEvent ) { + return this.originalEvent[ name ]; + } + }, + + set: function( value ) { + Object.defineProperty( this, name, { + enumerable: true, + configurable: true, + writable: true, + value: value + } ); + } + } ); + }, + + fix: function( originalEvent ) { + return originalEvent[ jQuery.expando ] ? + originalEvent : + new jQuery.Event( originalEvent ); + }, + + special: { + load: { + + // Prevent triggered image.load events from bubbling to window.load + noBubble: true + }, + click: { + + // Utilize native event to ensure correct state for checkable inputs + setup: function( data ) { + + // For mutual compressibility with _default, replace `this` access with a local var. + // `|| data` is dead code meant only to preserve the variable through minification. + var el = this || data; + + // Claim the first handler + if ( rcheckableType.test( el.type ) && + el.click && nodeName( el, "input" ) ) { + + // dataPriv.set( el, "click", ... ) + leverageNative( el, "click", returnTrue ); + } + + // Return false to allow normal processing in the caller + return false; + }, + trigger: function( data ) { + + // For mutual compressibility with _default, replace `this` access with a local var. + // `|| data` is dead code meant only to preserve the variable through minification. + var el = this || data; + + // Force setup before triggering a click + if ( rcheckableType.test( el.type ) && + el.click && nodeName( el, "input" ) ) { + + leverageNative( el, "click" ); + } + + // Return non-false to allow normal event-path propagation + return true; + }, + + // For cross-browser consistency, suppress native .click() on links + // Also prevent it if we're currently inside a leveraged native-event stack + _default: function( event ) { + var target = event.target; + return rcheckableType.test( target.type ) && + target.click && nodeName( target, "input" ) && + dataPriv.get( target, "click" ) || + nodeName( target, "a" ); + } + }, + + beforeunload: { + postDispatch: function( event ) { + + // Support: Firefox 20+ + // Firefox doesn't alert if the returnValue field is not set. + if ( event.result !== undefined && event.originalEvent ) { + event.originalEvent.returnValue = event.result; + } + } + } + } +}; + +// Ensure the presence of an event listener that handles manually-triggered +// synthetic events by interrupting progress until reinvoked in response to +// *native* events that it fires directly, ensuring that state changes have +// already occurred before other listeners are invoked. +function leverageNative( el, type, expectSync ) { + + // Missing expectSync indicates a trigger call, which must force setup through jQuery.event.add + if ( !expectSync ) { + if ( dataPriv.get( el, type ) === undefined ) { + jQuery.event.add( el, type, returnTrue ); + } + return; + } + + // Register the controller as a special universal handler for all event namespaces + dataPriv.set( el, type, false ); + jQuery.event.add( el, type, { + namespace: false, + handler: function( event ) { + var notAsync, result, + saved = dataPriv.get( this, type ); + + if ( ( event.isTrigger & 1 ) && this[ type ] ) { + + // Interrupt processing of the outer synthetic .trigger()ed event + // Saved data should be false in such cases, but might be a leftover capture object + // from an async native handler (gh-4350) + if ( !saved.length ) { + + // Store arguments for use when handling the inner native event + // There will always be at least one argument (an event object), so this array + // will not be confused with a leftover capture object. + saved = slice.call( arguments ); + dataPriv.set( this, type, saved ); + + // Trigger the native event and capture its result + // Support: IE <=9 - 11+ + // focus() and blur() are asynchronous + notAsync = expectSync( this, type ); + this[ type ](); + result = dataPriv.get( this, type ); + if ( saved !== result || notAsync ) { + dataPriv.set( this, type, false ); + } else { + result = {}; + } + if ( saved !== result ) { + + // Cancel the outer synthetic event + event.stopImmediatePropagation(); + event.preventDefault(); + return result.value; + } + + // If this is an inner synthetic event for an event with a bubbling surrogate + // (focus or blur), assume that the surrogate already propagated from triggering the + // native event and prevent that from happening again here. + // This technically gets the ordering wrong w.r.t. to `.trigger()` (in which the + // bubbling surrogate propagates *after* the non-bubbling base), but that seems + // less bad than duplication. + } else if ( ( jQuery.event.special[ type ] || {} ).delegateType ) { + event.stopPropagation(); + } + + // If this is a native event triggered above, everything is now in order + // Fire an inner synthetic event with the original arguments + } else if ( saved.length ) { + + // ...and capture the result + dataPriv.set( this, type, { + value: jQuery.event.trigger( + + // Support: IE <=9 - 11+ + // Extend with the prototype to reset the above stopImmediatePropagation() + jQuery.extend( saved[ 0 ], jQuery.Event.prototype ), + saved.slice( 1 ), + this + ) + } ); + + // Abort handling of the native event + event.stopImmediatePropagation(); + } + } + } ); +} + +jQuery.removeEvent = function( elem, type, handle ) { + + // This "if" is needed for plain objects + if ( elem.removeEventListener ) { + elem.removeEventListener( type, handle ); + } +}; + +jQuery.Event = function( src, props ) { + + // Allow instantiation without the 'new' keyword + if ( !( this instanceof jQuery.Event ) ) { + return new jQuery.Event( src, props ); + } + + // Event object + if ( src && src.type ) { + this.originalEvent = src; + this.type = src.type; + + // Events bubbling up the document may have been marked as prevented + // by a handler lower down the tree; reflect the correct value. + this.isDefaultPrevented = src.defaultPrevented || + src.defaultPrevented === undefined && + + // Support: Android <=2.3 only + src.returnValue === false ? + returnTrue : + returnFalse; + + // Create target properties + // Support: Safari <=6 - 7 only + // Target should not be a text node (#504, #13143) + this.target = ( src.target && src.target.nodeType === 3 ) ? + src.target.parentNode : + src.target; + + this.currentTarget = src.currentTarget; + this.relatedTarget = src.relatedTarget; + + // Event type + } else { + this.type = src; + } + + // Put explicitly provided properties onto the event object + if ( props ) { + jQuery.extend( this, props ); + } + + // Create a timestamp if incoming event doesn't have one + this.timeStamp = src && src.timeStamp || Date.now(); + + // Mark it as fixed + this[ jQuery.expando ] = true; +}; + +// jQuery.Event is based on DOM3 Events as specified by the ECMAScript Language Binding +// https://www.w3.org/TR/2003/WD-DOM-Level-3-Events-20030331/ecma-script-binding.html +jQuery.Event.prototype = { + constructor: jQuery.Event, + isDefaultPrevented: returnFalse, + isPropagationStopped: returnFalse, + isImmediatePropagationStopped: returnFalse, + isSimulated: false, + + preventDefault: function() { + var e = this.originalEvent; + + this.isDefaultPrevented = returnTrue; + + if ( e && !this.isSimulated ) { + e.preventDefault(); + } + }, + stopPropagation: function() { + var e = this.originalEvent; + + this.isPropagationStopped = returnTrue; + + if ( e && !this.isSimulated ) { + e.stopPropagation(); + } + }, + stopImmediatePropagation: function() { + var e = this.originalEvent; + + this.isImmediatePropagationStopped = returnTrue; + + if ( e && !this.isSimulated ) { + e.stopImmediatePropagation(); + } + + this.stopPropagation(); + } +}; + +// Includes all common event props including KeyEvent and MouseEvent specific props +jQuery.each( { + altKey: true, + bubbles: true, + cancelable: true, + changedTouches: true, + ctrlKey: true, + detail: true, + eventPhase: true, + metaKey: true, + pageX: true, + pageY: true, + shiftKey: true, + view: true, + "char": true, + code: true, + charCode: true, + key: true, + keyCode: true, + button: true, + buttons: true, + clientX: true, + clientY: true, + offsetX: true, + offsetY: true, + pointerId: true, + pointerType: true, + screenX: true, + screenY: true, + targetTouches: true, + toElement: true, + touches: true, + + which: function( event ) { + var button = event.button; + + // Add which for key events + if ( event.which == null && rkeyEvent.test( event.type ) ) { + return event.charCode != null ? event.charCode : event.keyCode; + } + + // Add which for click: 1 === left; 2 === middle; 3 === right + if ( !event.which && button !== undefined && rmouseEvent.test( event.type ) ) { + if ( button & 1 ) { + return 1; + } + + if ( button & 2 ) { + return 3; + } + + if ( button & 4 ) { + return 2; + } + + return 0; + } + + return event.which; + } +}, jQuery.event.addProp ); + +jQuery.each( { focus: "focusin", blur: "focusout" }, function( type, delegateType ) { + jQuery.event.special[ type ] = { + + // Utilize native event if possible so blur/focus sequence is correct + setup: function() { + + // Claim the first handler + // dataPriv.set( this, "focus", ... ) + // dataPriv.set( this, "blur", ... ) + leverageNative( this, type, expectSync ); + + // Return false to allow normal processing in the caller + return false; + }, + trigger: function() { + + // Force setup before trigger + leverageNative( this, type ); + + // Return non-false to allow normal event-path propagation + return true; + }, + + delegateType: delegateType + }; +} ); + +// Create mouseenter/leave events using mouseover/out and event-time checks +// so that event delegation works in jQuery. +// Do the same for pointerenter/pointerleave and pointerover/pointerout +// +// Support: Safari 7 only +// Safari sends mouseenter too often; see: +// https://bugs.chromium.org/p/chromium/issues/detail?id=470258 +// for the description of the bug (it existed in older Chrome versions as well). +jQuery.each( { + mouseenter: "mouseover", + mouseleave: "mouseout", + pointerenter: "pointerover", + pointerleave: "pointerout" +}, function( orig, fix ) { + jQuery.event.special[ orig ] = { + delegateType: fix, + bindType: fix, + + handle: function( event ) { + var ret, + target = this, + related = event.relatedTarget, + handleObj = event.handleObj; + + // For mouseenter/leave call the handler if related is outside the target. + // NB: No relatedTarget if the mouse left/entered the browser window + if ( !related || ( related !== target && !jQuery.contains( target, related ) ) ) { + event.type = handleObj.origType; + ret = handleObj.handler.apply( this, arguments ); + event.type = fix; + } + return ret; + } + }; +} ); + +jQuery.fn.extend( { + + on: function( types, selector, data, fn ) { + return on( this, types, selector, data, fn ); + }, + one: function( types, selector, data, fn ) { + return on( this, types, selector, data, fn, 1 ); + }, + off: function( types, selector, fn ) { + var handleObj, type; + if ( types && types.preventDefault && types.handleObj ) { + + // ( event ) dispatched jQuery.Event + handleObj = types.handleObj; + jQuery( types.delegateTarget ).off( + handleObj.namespace ? + handleObj.origType + "." + handleObj.namespace : + handleObj.origType, + handleObj.selector, + handleObj.handler + ); + return this; + } + if ( typeof types === "object" ) { + + // ( types-object [, selector] ) + for ( type in types ) { + this.off( type, selector, types[ type ] ); + } + return this; + } + if ( selector === false || typeof selector === "function" ) { + + // ( types [, fn] ) + fn = selector; + selector = undefined; + } + if ( fn === false ) { + fn = returnFalse; + } + return this.each( function() { + jQuery.event.remove( this, types, fn, selector ); + } ); + } +} ); + + +var + + /* eslint-disable max-len */ + + // See https://github.com/eslint/eslint/issues/3229 + rxhtmlTag = /<(?!area|br|col|embed|hr|img|input|link|meta|param)(([a-z][^\/\0>\x20\t\r\n\f]*)[^>]*)\/>/gi, + + /* eslint-enable */ + + // Support: IE <=10 - 11, Edge 12 - 13 only + // In IE/Edge using regex groups here causes severe slowdowns. + // See https://connect.microsoft.com/IE/feedback/details/1736512/ + rnoInnerhtml = /\s*$/g; + +// Prefer a tbody over its parent table for containing new rows +function manipulationTarget( elem, content ) { + if ( nodeName( elem, "table" ) && + nodeName( content.nodeType !== 11 ? content : content.firstChild, "tr" ) ) { + + return jQuery( elem ).children( "tbody" )[ 0 ] || elem; + } + + return elem; +} + +// Replace/restore the type attribute of script elements for safe DOM manipulation +function disableScript( elem ) { + elem.type = ( elem.getAttribute( "type" ) !== null ) + "/" + elem.type; + return elem; +} +function restoreScript( elem ) { + if ( ( elem.type || "" ).slice( 0, 5 ) === "true/" ) { + elem.type = elem.type.slice( 5 ); + } else { + elem.removeAttribute( "type" ); + } + + return elem; +} + +function cloneCopyEvent( src, dest ) { + var i, l, type, pdataOld, pdataCur, udataOld, udataCur, events; + + if ( dest.nodeType !== 1 ) { + return; + } + + // 1. Copy private data: events, handlers, etc. + if ( dataPriv.hasData( src ) ) { + pdataOld = dataPriv.access( src ); + pdataCur = dataPriv.set( dest, pdataOld ); + events = pdataOld.events; + + if ( events ) { + delete pdataCur.handle; + pdataCur.events = {}; + + for ( type in events ) { + for ( i = 0, l = events[ type ].length; i < l; i++ ) { + jQuery.event.add( dest, type, events[ type ][ i ] ); + } + } + } + } + + // 2. Copy user data + if ( dataUser.hasData( src ) ) { + udataOld = dataUser.access( src ); + udataCur = jQuery.extend( {}, udataOld ); + + dataUser.set( dest, udataCur ); + } +} + +// Fix IE bugs, see support tests +function fixInput( src, dest ) { + var nodeName = dest.nodeName.toLowerCase(); + + // Fails to persist the checked state of a cloned checkbox or radio button. + if ( nodeName === "input" && rcheckableType.test( src.type ) ) { + dest.checked = src.checked; + + // Fails to return the selected option to the default selected state when cloning options + } else if ( nodeName === "input" || nodeName === "textarea" ) { + dest.defaultValue = src.defaultValue; + } +} + +function domManip( collection, args, callback, ignored ) { + + // Flatten any nested arrays + args = concat.apply( [], args ); + + var fragment, first, scripts, hasScripts, node, doc, + i = 0, + l = collection.length, + iNoClone = l - 1, + value = args[ 0 ], + valueIsFunction = isFunction( value ); + + // We can't cloneNode fragments that contain checked, in WebKit + if ( valueIsFunction || + ( l > 1 && typeof value === "string" && + !support.checkClone && rchecked.test( value ) ) ) { + return collection.each( function( index ) { + var self = collection.eq( index ); + if ( valueIsFunction ) { + args[ 0 ] = value.call( this, index, self.html() ); + } + domManip( self, args, callback, ignored ); + } ); + } + + if ( l ) { + fragment = buildFragment( args, collection[ 0 ].ownerDocument, false, collection, ignored ); + first = fragment.firstChild; + + if ( fragment.childNodes.length === 1 ) { + fragment = first; + } + + // Require either new content or an interest in ignored elements to invoke the callback + if ( first || ignored ) { + scripts = jQuery.map( getAll( fragment, "script" ), disableScript ); + hasScripts = scripts.length; + + // Use the original fragment for the last item + // instead of the first because it can end up + // being emptied incorrectly in certain situations (#8070). + for ( ; i < l; i++ ) { + node = fragment; + + if ( i !== iNoClone ) { + node = jQuery.clone( node, true, true ); + + // Keep references to cloned scripts for later restoration + if ( hasScripts ) { + + // Support: Android <=4.0 only, PhantomJS 1 only + // push.apply(_, arraylike) throws on ancient WebKit + jQuery.merge( scripts, getAll( node, "script" ) ); + } + } + + callback.call( collection[ i ], node, i ); + } + + if ( hasScripts ) { + doc = scripts[ scripts.length - 1 ].ownerDocument; + + // Reenable scripts + jQuery.map( scripts, restoreScript ); + + // Evaluate executable scripts on first document insertion + for ( i = 0; i < hasScripts; i++ ) { + node = scripts[ i ]; + if ( rscriptType.test( node.type || "" ) && + !dataPriv.access( node, "globalEval" ) && + jQuery.contains( doc, node ) ) { + + if ( node.src && ( node.type || "" ).toLowerCase() !== "module" ) { + + // Optional AJAX dependency, but won't run scripts if not present + if ( jQuery._evalUrl && !node.noModule ) { + jQuery._evalUrl( node.src, { + nonce: node.nonce || node.getAttribute( "nonce" ) + } ); + } + } else { + DOMEval( node.textContent.replace( rcleanScript, "" ), node, doc ); + } + } + } + } + } + } + + return collection; +} + +function remove( elem, selector, keepData ) { + var node, + nodes = selector ? jQuery.filter( selector, elem ) : elem, + i = 0; + + for ( ; ( node = nodes[ i ] ) != null; i++ ) { + if ( !keepData && node.nodeType === 1 ) { + jQuery.cleanData( getAll( node ) ); + } + + if ( node.parentNode ) { + if ( keepData && isAttached( node ) ) { + setGlobalEval( getAll( node, "script" ) ); + } + node.parentNode.removeChild( node ); + } + } + + return elem; +} + +jQuery.extend( { + htmlPrefilter: function( html ) { + return html.replace( rxhtmlTag, "<$1>" ); + }, + + clone: function( elem, dataAndEvents, deepDataAndEvents ) { + var i, l, srcElements, destElements, + clone = elem.cloneNode( true ), + inPage = isAttached( elem ); + + // Fix IE cloning issues + if ( !support.noCloneChecked && ( elem.nodeType === 1 || elem.nodeType === 11 ) && + !jQuery.isXMLDoc( elem ) ) { + + // We eschew Sizzle here for performance reasons: https://jsperf.com/getall-vs-sizzle/2 + destElements = getAll( clone ); + srcElements = getAll( elem ); + + for ( i = 0, l = srcElements.length; i < l; i++ ) { + fixInput( srcElements[ i ], destElements[ i ] ); + } + } + + // Copy the events from the original to the clone + if ( dataAndEvents ) { + if ( deepDataAndEvents ) { + srcElements = srcElements || getAll( elem ); + destElements = destElements || getAll( clone ); + + for ( i = 0, l = srcElements.length; i < l; i++ ) { + cloneCopyEvent( srcElements[ i ], destElements[ i ] ); + } + } else { + cloneCopyEvent( elem, clone ); + } + } + + // Preserve script evaluation history + destElements = getAll( clone, "script" ); + if ( destElements.length > 0 ) { + setGlobalEval( destElements, !inPage && getAll( elem, "script" ) ); + } + + // Return the cloned set + return clone; + }, + + cleanData: function( elems ) { + var data, elem, type, + special = jQuery.event.special, + i = 0; + + for ( ; ( elem = elems[ i ] ) !== undefined; i++ ) { + if ( acceptData( elem ) ) { + if ( ( data = elem[ dataPriv.expando ] ) ) { + if ( data.events ) { + for ( type in data.events ) { + if ( special[ type ] ) { + jQuery.event.remove( elem, type ); + + // This is a shortcut to avoid jQuery.event.remove's overhead + } else { + jQuery.removeEvent( elem, type, data.handle ); + } + } + } + + // Support: Chrome <=35 - 45+ + // Assign undefined instead of using delete, see Data#remove + elem[ dataPriv.expando ] = undefined; + } + if ( elem[ dataUser.expando ] ) { + + // Support: Chrome <=35 - 45+ + // Assign undefined instead of using delete, see Data#remove + elem[ dataUser.expando ] = undefined; + } + } + } + } +} ); + +jQuery.fn.extend( { + detach: function( selector ) { + return remove( this, selector, true ); + }, + + remove: function( selector ) { + return remove( this, selector ); + }, + + text: function( value ) { + return access( this, function( value ) { + return value === undefined ? + jQuery.text( this ) : + this.empty().each( function() { + if ( this.nodeType === 1 || this.nodeType === 11 || this.nodeType === 9 ) { + this.textContent = value; + } + } ); + }, null, value, arguments.length ); + }, + + append: function() { + return domManip( this, arguments, function( elem ) { + if ( this.nodeType === 1 || this.nodeType === 11 || this.nodeType === 9 ) { + var target = manipulationTarget( this, elem ); + target.appendChild( elem ); + } + } ); + }, + + prepend: function() { + return domManip( this, arguments, function( elem ) { + if ( this.nodeType === 1 || this.nodeType === 11 || this.nodeType === 9 ) { + var target = manipulationTarget( this, elem ); + target.insertBefore( elem, target.firstChild ); + } + } ); + }, + + before: function() { + return domManip( this, arguments, function( elem ) { + if ( this.parentNode ) { + this.parentNode.insertBefore( elem, this ); + } + } ); + }, + + after: function() { + return domManip( this, arguments, function( elem ) { + if ( this.parentNode ) { + this.parentNode.insertBefore( elem, this.nextSibling ); + } + } ); + }, + + empty: function() { + var elem, + i = 0; + + for ( ; ( elem = this[ i ] ) != null; i++ ) { + if ( elem.nodeType === 1 ) { + + // Prevent memory leaks + jQuery.cleanData( getAll( elem, false ) ); + + // Remove any remaining nodes + elem.textContent = ""; + } + } + + return this; + }, + + clone: function( dataAndEvents, deepDataAndEvents ) { + dataAndEvents = dataAndEvents == null ? false : dataAndEvents; + deepDataAndEvents = deepDataAndEvents == null ? dataAndEvents : deepDataAndEvents; + + return this.map( function() { + return jQuery.clone( this, dataAndEvents, deepDataAndEvents ); + } ); + }, + + html: function( value ) { + return access( this, function( value ) { + var elem = this[ 0 ] || {}, + i = 0, + l = this.length; + + if ( value === undefined && elem.nodeType === 1 ) { + return elem.innerHTML; + } + + // See if we can take a shortcut and just use innerHTML + if ( typeof value === "string" && !rnoInnerhtml.test( value ) && + !wrapMap[ ( rtagName.exec( value ) || [ "", "" ] )[ 1 ].toLowerCase() ] ) { + + value = jQuery.htmlPrefilter( value ); + + try { + for ( ; i < l; i++ ) { + elem = this[ i ] || {}; + + // Remove element nodes and prevent memory leaks + if ( elem.nodeType === 1 ) { + jQuery.cleanData( getAll( elem, false ) ); + elem.innerHTML = value; + } + } + + elem = 0; + + // If using innerHTML throws an exception, use the fallback method + } catch ( e ) {} + } + + if ( elem ) { + this.empty().append( value ); + } + }, null, value, arguments.length ); + }, + + replaceWith: function() { + var ignored = []; + + // Make the changes, replacing each non-ignored context element with the new content + return domManip( this, arguments, function( elem ) { + var parent = this.parentNode; + + if ( jQuery.inArray( this, ignored ) < 0 ) { + jQuery.cleanData( getAll( this ) ); + if ( parent ) { + parent.replaceChild( elem, this ); + } + } + + // Force callback invocation + }, ignored ); + } +} ); + +jQuery.each( { + appendTo: "append", + prependTo: "prepend", + insertBefore: "before", + insertAfter: "after", + replaceAll: "replaceWith" +}, function( name, original ) { + jQuery.fn[ name ] = function( selector ) { + var elems, + ret = [], + insert = jQuery( selector ), + last = insert.length - 1, + i = 0; + + for ( ; i <= last; i++ ) { + elems = i === last ? this : this.clone( true ); + jQuery( insert[ i ] )[ original ]( elems ); + + // Support: Android <=4.0 only, PhantomJS 1 only + // .get() because push.apply(_, arraylike) throws on ancient WebKit + push.apply( ret, elems.get() ); + } + + return this.pushStack( ret ); + }; +} ); +var rnumnonpx = new RegExp( "^(" + pnum + ")(?!px)[a-z%]+$", "i" ); + +var getStyles = function( elem ) { + + // Support: IE <=11 only, Firefox <=30 (#15098, #14150) + // IE throws on elements created in popups + // FF meanwhile throws on frame elements through "defaultView.getComputedStyle" + var view = elem.ownerDocument.defaultView; + + if ( !view || !view.opener ) { + view = window; + } + + return view.getComputedStyle( elem ); + }; + +var rboxStyle = new RegExp( cssExpand.join( "|" ), "i" ); + + + +( function() { + + // Executing both pixelPosition & boxSizingReliable tests require only one layout + // so they're executed at the same time to save the second computation. + function computeStyleTests() { + + // This is a singleton, we need to execute it only once + if ( !div ) { + return; + } + + container.style.cssText = "position:absolute;left:-11111px;width:60px;" + + "margin-top:1px;padding:0;border:0"; + div.style.cssText = + "position:relative;display:block;box-sizing:border-box;overflow:scroll;" + + "margin:auto;border:1px;padding:1px;" + + "width:60%;top:1%"; + documentElement.appendChild( container ).appendChild( div ); + + var divStyle = window.getComputedStyle( div ); + pixelPositionVal = divStyle.top !== "1%"; + + // Support: Android 4.0 - 4.3 only, Firefox <=3 - 44 + reliableMarginLeftVal = roundPixelMeasures( divStyle.marginLeft ) === 12; + + // Support: Android 4.0 - 4.3 only, Safari <=9.1 - 10.1, iOS <=7.0 - 9.3 + // Some styles come back with percentage values, even though they shouldn't + div.style.right = "60%"; + pixelBoxStylesVal = roundPixelMeasures( divStyle.right ) === 36; + + // Support: IE 9 - 11 only + // Detect misreporting of content dimensions for box-sizing:border-box elements + boxSizingReliableVal = roundPixelMeasures( divStyle.width ) === 36; + + // Support: IE 9 only + // Detect overflow:scroll screwiness (gh-3699) + // Support: Chrome <=64 + // Don't get tricked when zoom affects offsetWidth (gh-4029) + div.style.position = "absolute"; + scrollboxSizeVal = roundPixelMeasures( div.offsetWidth / 3 ) === 12; + + documentElement.removeChild( container ); + + // Nullify the div so it wouldn't be stored in the memory and + // it will also be a sign that checks already performed + div = null; + } + + function roundPixelMeasures( measure ) { + return Math.round( parseFloat( measure ) ); + } + + var pixelPositionVal, boxSizingReliableVal, scrollboxSizeVal, pixelBoxStylesVal, + reliableMarginLeftVal, + container = document.createElement( "div" ), + div = document.createElement( "div" ); + + // Finish early in limited (non-browser) environments + if ( !div.style ) { + return; + } + + // Support: IE <=9 - 11 only + // Style of cloned element affects source element cloned (#8908) + div.style.backgroundClip = "content-box"; + div.cloneNode( true ).style.backgroundClip = ""; + support.clearCloneStyle = div.style.backgroundClip === "content-box"; + + jQuery.extend( support, { + boxSizingReliable: function() { + computeStyleTests(); + return boxSizingReliableVal; + }, + pixelBoxStyles: function() { + computeStyleTests(); + return pixelBoxStylesVal; + }, + pixelPosition: function() { + computeStyleTests(); + return pixelPositionVal; + }, + reliableMarginLeft: function() { + computeStyleTests(); + return reliableMarginLeftVal; + }, + scrollboxSize: function() { + computeStyleTests(); + return scrollboxSizeVal; + } + } ); +} )(); + + +function curCSS( elem, name, computed ) { + var width, minWidth, maxWidth, ret, + + // Support: Firefox 51+ + // Retrieving style before computed somehow + // fixes an issue with getting wrong values + // on detached elements + style = elem.style; + + computed = computed || getStyles( elem ); + + // getPropertyValue is needed for: + // .css('filter') (IE 9 only, #12537) + // .css('--customProperty) (#3144) + if ( computed ) { + ret = computed.getPropertyValue( name ) || computed[ name ]; + + if ( ret === "" && !isAttached( elem ) ) { + ret = jQuery.style( elem, name ); + } + + // A tribute to the "awesome hack by Dean Edwards" + // Android Browser returns percentage for some values, + // but width seems to be reliably pixels. + // This is against the CSSOM draft spec: + // https://drafts.csswg.org/cssom/#resolved-values + if ( !support.pixelBoxStyles() && rnumnonpx.test( ret ) && rboxStyle.test( name ) ) { + + // Remember the original values + width = style.width; + minWidth = style.minWidth; + maxWidth = style.maxWidth; + + // Put in the new values to get a computed value out + style.minWidth = style.maxWidth = style.width = ret; + ret = computed.width; + + // Revert the changed values + style.width = width; + style.minWidth = minWidth; + style.maxWidth = maxWidth; + } + } + + return ret !== undefined ? + + // Support: IE <=9 - 11 only + // IE returns zIndex value as an integer. + ret + "" : + ret; +} + + +function addGetHookIf( conditionFn, hookFn ) { + + // Define the hook, we'll check on the first run if it's really needed. + return { + get: function() { + if ( conditionFn() ) { + + // Hook not needed (or it's not possible to use it due + // to missing dependency), remove it. + delete this.get; + return; + } + + // Hook needed; redefine it so that the support test is not executed again. + return ( this.get = hookFn ).apply( this, arguments ); + } + }; +} + + +var cssPrefixes = [ "Webkit", "Moz", "ms" ], + emptyStyle = document.createElement( "div" ).style, + vendorProps = {}; + +// Return a vendor-prefixed property or undefined +function vendorPropName( name ) { + + // Check for vendor prefixed names + var capName = name[ 0 ].toUpperCase() + name.slice( 1 ), + i = cssPrefixes.length; + + while ( i-- ) { + name = cssPrefixes[ i ] + capName; + if ( name in emptyStyle ) { + return name; + } + } +} + +// Return a potentially-mapped jQuery.cssProps or vendor prefixed property +function finalPropName( name ) { + var final = jQuery.cssProps[ name ] || vendorProps[ name ]; + + if ( final ) { + return final; + } + if ( name in emptyStyle ) { + return name; + } + return vendorProps[ name ] = vendorPropName( name ) || name; +} + + +var + + // Swappable if display is none or starts with table + // except "table", "table-cell", or "table-caption" + // See here for display values: https://developer.mozilla.org/en-US/docs/CSS/display + rdisplayswap = /^(none|table(?!-c[ea]).+)/, + rcustomProp = /^--/, + cssShow = { position: "absolute", visibility: "hidden", display: "block" }, + cssNormalTransform = { + letterSpacing: "0", + fontWeight: "400" + }; + +function setPositiveNumber( elem, value, subtract ) { + + // Any relative (+/-) values have already been + // normalized at this point + var matches = rcssNum.exec( value ); + return matches ? + + // Guard against undefined "subtract", e.g., when used as in cssHooks + Math.max( 0, matches[ 2 ] - ( subtract || 0 ) ) + ( matches[ 3 ] || "px" ) : + value; +} + +function boxModelAdjustment( elem, dimension, box, isBorderBox, styles, computedVal ) { + var i = dimension === "width" ? 1 : 0, + extra = 0, + delta = 0; + + // Adjustment may not be necessary + if ( box === ( isBorderBox ? "border" : "content" ) ) { + return 0; + } + + for ( ; i < 4; i += 2 ) { + + // Both box models exclude margin + if ( box === "margin" ) { + delta += jQuery.css( elem, box + cssExpand[ i ], true, styles ); + } + + // If we get here with a content-box, we're seeking "padding" or "border" or "margin" + if ( !isBorderBox ) { + + // Add padding + delta += jQuery.css( elem, "padding" + cssExpand[ i ], true, styles ); + + // For "border" or "margin", add border + if ( box !== "padding" ) { + delta += jQuery.css( elem, "border" + cssExpand[ i ] + "Width", true, styles ); + + // But still keep track of it otherwise + } else { + extra += jQuery.css( elem, "border" + cssExpand[ i ] + "Width", true, styles ); + } + + // If we get here with a border-box (content + padding + border), we're seeking "content" or + // "padding" or "margin" + } else { + + // For "content", subtract padding + if ( box === "content" ) { + delta -= jQuery.css( elem, "padding" + cssExpand[ i ], true, styles ); + } + + // For "content" or "padding", subtract border + if ( box !== "margin" ) { + delta -= jQuery.css( elem, "border" + cssExpand[ i ] + "Width", true, styles ); + } + } + } + + // Account for positive content-box scroll gutter when requested by providing computedVal + if ( !isBorderBox && computedVal >= 0 ) { + + // offsetWidth/offsetHeight is a rounded sum of content, padding, scroll gutter, and border + // Assuming integer scroll gutter, subtract the rest and round down + delta += Math.max( 0, Math.ceil( + elem[ "offset" + dimension[ 0 ].toUpperCase() + dimension.slice( 1 ) ] - + computedVal - + delta - + extra - + 0.5 + + // If offsetWidth/offsetHeight is unknown, then we can't determine content-box scroll gutter + // Use an explicit zero to avoid NaN (gh-3964) + ) ) || 0; + } + + return delta; +} + +function getWidthOrHeight( elem, dimension, extra ) { + + // Start with computed style + var styles = getStyles( elem ), + + // To avoid forcing a reflow, only fetch boxSizing if we need it (gh-4322). + // Fake content-box until we know it's needed to know the true value. + boxSizingNeeded = !support.boxSizingReliable() || extra, + isBorderBox = boxSizingNeeded && + jQuery.css( elem, "boxSizing", false, styles ) === "border-box", + valueIsBorderBox = isBorderBox, + + val = curCSS( elem, dimension, styles ), + offsetProp = "offset" + dimension[ 0 ].toUpperCase() + dimension.slice( 1 ); + + // Support: Firefox <=54 + // Return a confounding non-pixel value or feign ignorance, as appropriate. + if ( rnumnonpx.test( val ) ) { + if ( !extra ) { + return val; + } + val = "auto"; + } + + + // Fall back to offsetWidth/offsetHeight when value is "auto" + // This happens for inline elements with no explicit setting (gh-3571) + // Support: Android <=4.1 - 4.3 only + // Also use offsetWidth/offsetHeight for misreported inline dimensions (gh-3602) + // Support: IE 9-11 only + // Also use offsetWidth/offsetHeight for when box sizing is unreliable + // We use getClientRects() to check for hidden/disconnected. + // In those cases, the computed value can be trusted to be border-box + if ( ( !support.boxSizingReliable() && isBorderBox || + val === "auto" || + !parseFloat( val ) && jQuery.css( elem, "display", false, styles ) === "inline" ) && + elem.getClientRects().length ) { + + isBorderBox = jQuery.css( elem, "boxSizing", false, styles ) === "border-box"; + + // Where available, offsetWidth/offsetHeight approximate border box dimensions. + // Where not available (e.g., SVG), assume unreliable box-sizing and interpret the + // retrieved value as a content box dimension. + valueIsBorderBox = offsetProp in elem; + if ( valueIsBorderBox ) { + val = elem[ offsetProp ]; + } + } + + // Normalize "" and auto + val = parseFloat( val ) || 0; + + // Adjust for the element's box model + return ( val + + boxModelAdjustment( + elem, + dimension, + extra || ( isBorderBox ? "border" : "content" ), + valueIsBorderBox, + styles, + + // Provide the current computed size to request scroll gutter calculation (gh-3589) + val + ) + ) + "px"; +} + +jQuery.extend( { + + // Add in style property hooks for overriding the default + // behavior of getting and setting a style property + cssHooks: { + opacity: { + get: function( elem, computed ) { + if ( computed ) { + + // We should always get a number back from opacity + var ret = curCSS( elem, "opacity" ); + return ret === "" ? "1" : ret; + } + } + } + }, + + // Don't automatically add "px" to these possibly-unitless properties + cssNumber: { + "animationIterationCount": true, + "columnCount": true, + "fillOpacity": true, + "flexGrow": true, + "flexShrink": true, + "fontWeight": true, + "gridArea": true, + "gridColumn": true, + "gridColumnEnd": true, + "gridColumnStart": true, + "gridRow": true, + "gridRowEnd": true, + "gridRowStart": true, + "lineHeight": true, + "opacity": true, + "order": true, + "orphans": true, + "widows": true, + "zIndex": true, + "zoom": true + }, + + // Add in properties whose names you wish to fix before + // setting or getting the value + cssProps: {}, + + // Get and set the style property on a DOM Node + style: function( elem, name, value, extra ) { + + // Don't set styles on text and comment nodes + if ( !elem || elem.nodeType === 3 || elem.nodeType === 8 || !elem.style ) { + return; + } + + // Make sure that we're working with the right name + var ret, type, hooks, + origName = camelCase( name ), + isCustomProp = rcustomProp.test( name ), + style = elem.style; + + // Make sure that we're working with the right name. We don't + // want to query the value if it is a CSS custom property + // since they are user-defined. + if ( !isCustomProp ) { + name = finalPropName( origName ); + } + + // Gets hook for the prefixed version, then unprefixed version + hooks = jQuery.cssHooks[ name ] || jQuery.cssHooks[ origName ]; + + // Check if we're setting a value + if ( value !== undefined ) { + type = typeof value; + + // Convert "+=" or "-=" to relative numbers (#7345) + if ( type === "string" && ( ret = rcssNum.exec( value ) ) && ret[ 1 ] ) { + value = adjustCSS( elem, name, ret ); + + // Fixes bug #9237 + type = "number"; + } + + // Make sure that null and NaN values aren't set (#7116) + if ( value == null || value !== value ) { + return; + } + + // If a number was passed in, add the unit (except for certain CSS properties) + // The isCustomProp check can be removed in jQuery 4.0 when we only auto-append + // "px" to a few hardcoded values. + if ( type === "number" && !isCustomProp ) { + value += ret && ret[ 3 ] || ( jQuery.cssNumber[ origName ] ? "" : "px" ); + } + + // background-* props affect original clone's values + if ( !support.clearCloneStyle && value === "" && name.indexOf( "background" ) === 0 ) { + style[ name ] = "inherit"; + } + + // If a hook was provided, use that value, otherwise just set the specified value + if ( !hooks || !( "set" in hooks ) || + ( value = hooks.set( elem, value, extra ) ) !== undefined ) { + + if ( isCustomProp ) { + style.setProperty( name, value ); + } else { + style[ name ] = value; + } + } + + } else { + + // If a hook was provided get the non-computed value from there + if ( hooks && "get" in hooks && + ( ret = hooks.get( elem, false, extra ) ) !== undefined ) { + + return ret; + } + + // Otherwise just get the value from the style object + return style[ name ]; + } + }, + + css: function( elem, name, extra, styles ) { + var val, num, hooks, + origName = camelCase( name ), + isCustomProp = rcustomProp.test( name ); + + // Make sure that we're working with the right name. We don't + // want to modify the value if it is a CSS custom property + // since they are user-defined. + if ( !isCustomProp ) { + name = finalPropName( origName ); + } + + // Try prefixed name followed by the unprefixed name + hooks = jQuery.cssHooks[ name ] || jQuery.cssHooks[ origName ]; + + // If a hook was provided get the computed value from there + if ( hooks && "get" in hooks ) { + val = hooks.get( elem, true, extra ); + } + + // Otherwise, if a way to get the computed value exists, use that + if ( val === undefined ) { + val = curCSS( elem, name, styles ); + } + + // Convert "normal" to computed value + if ( val === "normal" && name in cssNormalTransform ) { + val = cssNormalTransform[ name ]; + } + + // Make numeric if forced or a qualifier was provided and val looks numeric + if ( extra === "" || extra ) { + num = parseFloat( val ); + return extra === true || isFinite( num ) ? num || 0 : val; + } + + return val; + } +} ); + +jQuery.each( [ "height", "width" ], function( i, dimension ) { + jQuery.cssHooks[ dimension ] = { + get: function( elem, computed, extra ) { + if ( computed ) { + + // Certain elements can have dimension info if we invisibly show them + // but it must have a current display style that would benefit + return rdisplayswap.test( jQuery.css( elem, "display" ) ) && + + // Support: Safari 8+ + // Table columns in Safari have non-zero offsetWidth & zero + // getBoundingClientRect().width unless display is changed. + // Support: IE <=11 only + // Running getBoundingClientRect on a disconnected node + // in IE throws an error. + ( !elem.getClientRects().length || !elem.getBoundingClientRect().width ) ? + swap( elem, cssShow, function() { + return getWidthOrHeight( elem, dimension, extra ); + } ) : + getWidthOrHeight( elem, dimension, extra ); + } + }, + + set: function( elem, value, extra ) { + var matches, + styles = getStyles( elem ), + + // Only read styles.position if the test has a chance to fail + // to avoid forcing a reflow. + scrollboxSizeBuggy = !support.scrollboxSize() && + styles.position === "absolute", + + // To avoid forcing a reflow, only fetch boxSizing if we need it (gh-3991) + boxSizingNeeded = scrollboxSizeBuggy || extra, + isBorderBox = boxSizingNeeded && + jQuery.css( elem, "boxSizing", false, styles ) === "border-box", + subtract = extra ? + boxModelAdjustment( + elem, + dimension, + extra, + isBorderBox, + styles + ) : + 0; + + // Account for unreliable border-box dimensions by comparing offset* to computed and + // faking a content-box to get border and padding (gh-3699) + if ( isBorderBox && scrollboxSizeBuggy ) { + subtract -= Math.ceil( + elem[ "offset" + dimension[ 0 ].toUpperCase() + dimension.slice( 1 ) ] - + parseFloat( styles[ dimension ] ) - + boxModelAdjustment( elem, dimension, "border", false, styles ) - + 0.5 + ); + } + + // Convert to pixels if value adjustment is needed + if ( subtract && ( matches = rcssNum.exec( value ) ) && + ( matches[ 3 ] || "px" ) !== "px" ) { + + elem.style[ dimension ] = value; + value = jQuery.css( elem, dimension ); + } + + return setPositiveNumber( elem, value, subtract ); + } + }; +} ); + +jQuery.cssHooks.marginLeft = addGetHookIf( support.reliableMarginLeft, + function( elem, computed ) { + if ( computed ) { + return ( parseFloat( curCSS( elem, "marginLeft" ) ) || + elem.getBoundingClientRect().left - + swap( elem, { marginLeft: 0 }, function() { + return elem.getBoundingClientRect().left; + } ) + ) + "px"; + } + } +); + +// These hooks are used by animate to expand properties +jQuery.each( { + margin: "", + padding: "", + border: "Width" +}, function( prefix, suffix ) { + jQuery.cssHooks[ prefix + suffix ] = { + expand: function( value ) { + var i = 0, + expanded = {}, + + // Assumes a single number if not a string + parts = typeof value === "string" ? value.split( " " ) : [ value ]; + + for ( ; i < 4; i++ ) { + expanded[ prefix + cssExpand[ i ] + suffix ] = + parts[ i ] || parts[ i - 2 ] || parts[ 0 ]; + } + + return expanded; + } + }; + + if ( prefix !== "margin" ) { + jQuery.cssHooks[ prefix + suffix ].set = setPositiveNumber; + } +} ); + +jQuery.fn.extend( { + css: function( name, value ) { + return access( this, function( elem, name, value ) { + var styles, len, + map = {}, + i = 0; + + if ( Array.isArray( name ) ) { + styles = getStyles( elem ); + len = name.length; + + for ( ; i < len; i++ ) { + map[ name[ i ] ] = jQuery.css( elem, name[ i ], false, styles ); + } + + return map; + } + + return value !== undefined ? + jQuery.style( elem, name, value ) : + jQuery.css( elem, name ); + }, name, value, arguments.length > 1 ); + } +} ); + + +function Tween( elem, options, prop, end, easing ) { + return new Tween.prototype.init( elem, options, prop, end, easing ); +} +jQuery.Tween = Tween; + +Tween.prototype = { + constructor: Tween, + init: function( elem, options, prop, end, easing, unit ) { + this.elem = elem; + this.prop = prop; + this.easing = easing || jQuery.easing._default; + this.options = options; + this.start = this.now = this.cur(); + this.end = end; + this.unit = unit || ( jQuery.cssNumber[ prop ] ? "" : "px" ); + }, + cur: function() { + var hooks = Tween.propHooks[ this.prop ]; + + return hooks && hooks.get ? + hooks.get( this ) : + Tween.propHooks._default.get( this ); + }, + run: function( percent ) { + var eased, + hooks = Tween.propHooks[ this.prop ]; + + if ( this.options.duration ) { + this.pos = eased = jQuery.easing[ this.easing ]( + percent, this.options.duration * percent, 0, 1, this.options.duration + ); + } else { + this.pos = eased = percent; + } + this.now = ( this.end - this.start ) * eased + this.start; + + if ( this.options.step ) { + this.options.step.call( this.elem, this.now, this ); + } + + if ( hooks && hooks.set ) { + hooks.set( this ); + } else { + Tween.propHooks._default.set( this ); + } + return this; + } +}; + +Tween.prototype.init.prototype = Tween.prototype; + +Tween.propHooks = { + _default: { + get: function( tween ) { + var result; + + // Use a property on the element directly when it is not a DOM element, + // or when there is no matching style property that exists. + if ( tween.elem.nodeType !== 1 || + tween.elem[ tween.prop ] != null && tween.elem.style[ tween.prop ] == null ) { + return tween.elem[ tween.prop ]; + } + + // Passing an empty string as a 3rd parameter to .css will automatically + // attempt a parseFloat and fallback to a string if the parse fails. + // Simple values such as "10px" are parsed to Float; + // complex values such as "rotate(1rad)" are returned as-is. + result = jQuery.css( tween.elem, tween.prop, "" ); + + // Empty strings, null, undefined and "auto" are converted to 0. + return !result || result === "auto" ? 0 : result; + }, + set: function( tween ) { + + // Use step hook for back compat. + // Use cssHook if its there. + // Use .style if available and use plain properties where available. + if ( jQuery.fx.step[ tween.prop ] ) { + jQuery.fx.step[ tween.prop ]( tween ); + } else if ( tween.elem.nodeType === 1 && ( + jQuery.cssHooks[ tween.prop ] || + tween.elem.style[ finalPropName( tween.prop ) ] != null ) ) { + jQuery.style( tween.elem, tween.prop, tween.now + tween.unit ); + } else { + tween.elem[ tween.prop ] = tween.now; + } + } + } +}; + +// Support: IE <=9 only +// Panic based approach to setting things on disconnected nodes +Tween.propHooks.scrollTop = Tween.propHooks.scrollLeft = { + set: function( tween ) { + if ( tween.elem.nodeType && tween.elem.parentNode ) { + tween.elem[ tween.prop ] = tween.now; + } + } +}; + +jQuery.easing = { + linear: function( p ) { + return p; + }, + swing: function( p ) { + return 0.5 - Math.cos( p * Math.PI ) / 2; + }, + _default: "swing" +}; + +jQuery.fx = Tween.prototype.init; + +// Back compat <1.8 extension point +jQuery.fx.step = {}; + + + + +var + fxNow, inProgress, + rfxtypes = /^(?:toggle|show|hide)$/, + rrun = /queueHooks$/; + +function schedule() { + if ( inProgress ) { + if ( document.hidden === false && window.requestAnimationFrame ) { + window.requestAnimationFrame( schedule ); + } else { + window.setTimeout( schedule, jQuery.fx.interval ); + } + + jQuery.fx.tick(); + } +} + +// Animations created synchronously will run synchronously +function createFxNow() { + window.setTimeout( function() { + fxNow = undefined; + } ); + return ( fxNow = Date.now() ); +} + +// Generate parameters to create a standard animation +function genFx( type, includeWidth ) { + var which, + i = 0, + attrs = { height: type }; + + // If we include width, step value is 1 to do all cssExpand values, + // otherwise step value is 2 to skip over Left and Right + includeWidth = includeWidth ? 1 : 0; + for ( ; i < 4; i += 2 - includeWidth ) { + which = cssExpand[ i ]; + attrs[ "margin" + which ] = attrs[ "padding" + which ] = type; + } + + if ( includeWidth ) { + attrs.opacity = attrs.width = type; + } + + return attrs; +} + +function createTween( value, prop, animation ) { + var tween, + collection = ( Animation.tweeners[ prop ] || [] ).concat( Animation.tweeners[ "*" ] ), + index = 0, + length = collection.length; + for ( ; index < length; index++ ) { + if ( ( tween = collection[ index ].call( animation, prop, value ) ) ) { + + // We're done with this property + return tween; + } + } +} + +function defaultPrefilter( elem, props, opts ) { + var prop, value, toggle, hooks, oldfire, propTween, restoreDisplay, display, + isBox = "width" in props || "height" in props, + anim = this, + orig = {}, + style = elem.style, + hidden = elem.nodeType && isHiddenWithinTree( elem ), + dataShow = dataPriv.get( elem, "fxshow" ); + + // Queue-skipping animations hijack the fx hooks + if ( !opts.queue ) { + hooks = jQuery._queueHooks( elem, "fx" ); + if ( hooks.unqueued == null ) { + hooks.unqueued = 0; + oldfire = hooks.empty.fire; + hooks.empty.fire = function() { + if ( !hooks.unqueued ) { + oldfire(); + } + }; + } + hooks.unqueued++; + + anim.always( function() { + + // Ensure the complete handler is called before this completes + anim.always( function() { + hooks.unqueued--; + if ( !jQuery.queue( elem, "fx" ).length ) { + hooks.empty.fire(); + } + } ); + } ); + } + + // Detect show/hide animations + for ( prop in props ) { + value = props[ prop ]; + if ( rfxtypes.test( value ) ) { + delete props[ prop ]; + toggle = toggle || value === "toggle"; + if ( value === ( hidden ? "hide" : "show" ) ) { + + // Pretend to be hidden if this is a "show" and + // there is still data from a stopped show/hide + if ( value === "show" && dataShow && dataShow[ prop ] !== undefined ) { + hidden = true; + + // Ignore all other no-op show/hide data + } else { + continue; + } + } + orig[ prop ] = dataShow && dataShow[ prop ] || jQuery.style( elem, prop ); + } + } + + // Bail out if this is a no-op like .hide().hide() + propTween = !jQuery.isEmptyObject( props ); + if ( !propTween && jQuery.isEmptyObject( orig ) ) { + return; + } + + // Restrict "overflow" and "display" styles during box animations + if ( isBox && elem.nodeType === 1 ) { + + // Support: IE <=9 - 11, Edge 12 - 15 + // Record all 3 overflow attributes because IE does not infer the shorthand + // from identically-valued overflowX and overflowY and Edge just mirrors + // the overflowX value there. + opts.overflow = [ style.overflow, style.overflowX, style.overflowY ]; + + // Identify a display type, preferring old show/hide data over the CSS cascade + restoreDisplay = dataShow && dataShow.display; + if ( restoreDisplay == null ) { + restoreDisplay = dataPriv.get( elem, "display" ); + } + display = jQuery.css( elem, "display" ); + if ( display === "none" ) { + if ( restoreDisplay ) { + display = restoreDisplay; + } else { + + // Get nonempty value(s) by temporarily forcing visibility + showHide( [ elem ], true ); + restoreDisplay = elem.style.display || restoreDisplay; + display = jQuery.css( elem, "display" ); + showHide( [ elem ] ); + } + } + + // Animate inline elements as inline-block + if ( display === "inline" || display === "inline-block" && restoreDisplay != null ) { + if ( jQuery.css( elem, "float" ) === "none" ) { + + // Restore the original display value at the end of pure show/hide animations + if ( !propTween ) { + anim.done( function() { + style.display = restoreDisplay; + } ); + if ( restoreDisplay == null ) { + display = style.display; + restoreDisplay = display === "none" ? "" : display; + } + } + style.display = "inline-block"; + } + } + } + + if ( opts.overflow ) { + style.overflow = "hidden"; + anim.always( function() { + style.overflow = opts.overflow[ 0 ]; + style.overflowX = opts.overflow[ 1 ]; + style.overflowY = opts.overflow[ 2 ]; + } ); + } + + // Implement show/hide animations + propTween = false; + for ( prop in orig ) { + + // General show/hide setup for this element animation + if ( !propTween ) { + if ( dataShow ) { + if ( "hidden" in dataShow ) { + hidden = dataShow.hidden; + } + } else { + dataShow = dataPriv.access( elem, "fxshow", { display: restoreDisplay } ); + } + + // Store hidden/visible for toggle so `.stop().toggle()` "reverses" + if ( toggle ) { + dataShow.hidden = !hidden; + } + + // Show elements before animating them + if ( hidden ) { + showHide( [ elem ], true ); + } + + /* eslint-disable no-loop-func */ + + anim.done( function() { + + /* eslint-enable no-loop-func */ + + // The final step of a "hide" animation is actually hiding the element + if ( !hidden ) { + showHide( [ elem ] ); + } + dataPriv.remove( elem, "fxshow" ); + for ( prop in orig ) { + jQuery.style( elem, prop, orig[ prop ] ); + } + } ); + } + + // Per-property setup + propTween = createTween( hidden ? dataShow[ prop ] : 0, prop, anim ); + if ( !( prop in dataShow ) ) { + dataShow[ prop ] = propTween.start; + if ( hidden ) { + propTween.end = propTween.start; + propTween.start = 0; + } + } + } +} + +function propFilter( props, specialEasing ) { + var index, name, easing, value, hooks; + + // camelCase, specialEasing and expand cssHook pass + for ( index in props ) { + name = camelCase( index ); + easing = specialEasing[ name ]; + value = props[ index ]; + if ( Array.isArray( value ) ) { + easing = value[ 1 ]; + value = props[ index ] = value[ 0 ]; + } + + if ( index !== name ) { + props[ name ] = value; + delete props[ index ]; + } + + hooks = jQuery.cssHooks[ name ]; + if ( hooks && "expand" in hooks ) { + value = hooks.expand( value ); + delete props[ name ]; + + // Not quite $.extend, this won't overwrite existing keys. + // Reusing 'index' because we have the correct "name" + for ( index in value ) { + if ( !( index in props ) ) { + props[ index ] = value[ index ]; + specialEasing[ index ] = easing; + } + } + } else { + specialEasing[ name ] = easing; + } + } +} + +function Animation( elem, properties, options ) { + var result, + stopped, + index = 0, + length = Animation.prefilters.length, + deferred = jQuery.Deferred().always( function() { + + // Don't match elem in the :animated selector + delete tick.elem; + } ), + tick = function() { + if ( stopped ) { + return false; + } + var currentTime = fxNow || createFxNow(), + remaining = Math.max( 0, animation.startTime + animation.duration - currentTime ), + + // Support: Android 2.3 only + // Archaic crash bug won't allow us to use `1 - ( 0.5 || 0 )` (#12497) + temp = remaining / animation.duration || 0, + percent = 1 - temp, + index = 0, + length = animation.tweens.length; + + for ( ; index < length; index++ ) { + animation.tweens[ index ].run( percent ); + } + + deferred.notifyWith( elem, [ animation, percent, remaining ] ); + + // If there's more to do, yield + if ( percent < 1 && length ) { + return remaining; + } + + // If this was an empty animation, synthesize a final progress notification + if ( !length ) { + deferred.notifyWith( elem, [ animation, 1, 0 ] ); + } + + // Resolve the animation and report its conclusion + deferred.resolveWith( elem, [ animation ] ); + return false; + }, + animation = deferred.promise( { + elem: elem, + props: jQuery.extend( {}, properties ), + opts: jQuery.extend( true, { + specialEasing: {}, + easing: jQuery.easing._default + }, options ), + originalProperties: properties, + originalOptions: options, + startTime: fxNow || createFxNow(), + duration: options.duration, + tweens: [], + createTween: function( prop, end ) { + var tween = jQuery.Tween( elem, animation.opts, prop, end, + animation.opts.specialEasing[ prop ] || animation.opts.easing ); + animation.tweens.push( tween ); + return tween; + }, + stop: function( gotoEnd ) { + var index = 0, + + // If we are going to the end, we want to run all the tweens + // otherwise we skip this part + length = gotoEnd ? animation.tweens.length : 0; + if ( stopped ) { + return this; + } + stopped = true; + for ( ; index < length; index++ ) { + animation.tweens[ index ].run( 1 ); + } + + // Resolve when we played the last frame; otherwise, reject + if ( gotoEnd ) { + deferred.notifyWith( elem, [ animation, 1, 0 ] ); + deferred.resolveWith( elem, [ animation, gotoEnd ] ); + } else { + deferred.rejectWith( elem, [ animation, gotoEnd ] ); + } + return this; + } + } ), + props = animation.props; + + propFilter( props, animation.opts.specialEasing ); + + for ( ; index < length; index++ ) { + result = Animation.prefilters[ index ].call( animation, elem, props, animation.opts ); + if ( result ) { + if ( isFunction( result.stop ) ) { + jQuery._queueHooks( animation.elem, animation.opts.queue ).stop = + result.stop.bind( result ); + } + return result; + } + } + + jQuery.map( props, createTween, animation ); + + if ( isFunction( animation.opts.start ) ) { + animation.opts.start.call( elem, animation ); + } + + // Attach callbacks from options + animation + .progress( animation.opts.progress ) + .done( animation.opts.done, animation.opts.complete ) + .fail( animation.opts.fail ) + .always( animation.opts.always ); + + jQuery.fx.timer( + jQuery.extend( tick, { + elem: elem, + anim: animation, + queue: animation.opts.queue + } ) + ); + + return animation; +} + +jQuery.Animation = jQuery.extend( Animation, { + + tweeners: { + "*": [ function( prop, value ) { + var tween = this.createTween( prop, value ); + adjustCSS( tween.elem, prop, rcssNum.exec( value ), tween ); + return tween; + } ] + }, + + tweener: function( props, callback ) { + if ( isFunction( props ) ) { + callback = props; + props = [ "*" ]; + } else { + props = props.match( rnothtmlwhite ); + } + + var prop, + index = 0, + length = props.length; + + for ( ; index < length; index++ ) { + prop = props[ index ]; + Animation.tweeners[ prop ] = Animation.tweeners[ prop ] || []; + Animation.tweeners[ prop ].unshift( callback ); + } + }, + + prefilters: [ defaultPrefilter ], + + prefilter: function( callback, prepend ) { + if ( prepend ) { + Animation.prefilters.unshift( callback ); + } else { + Animation.prefilters.push( callback ); + } + } +} ); + +jQuery.speed = function( speed, easing, fn ) { + var opt = speed && typeof speed === "object" ? jQuery.extend( {}, speed ) : { + complete: fn || !fn && easing || + isFunction( speed ) && speed, + duration: speed, + easing: fn && easing || easing && !isFunction( easing ) && easing + }; + + // Go to the end state if fx are off + if ( jQuery.fx.off ) { + opt.duration = 0; + + } else { + if ( typeof opt.duration !== "number" ) { + if ( opt.duration in jQuery.fx.speeds ) { + opt.duration = jQuery.fx.speeds[ opt.duration ]; + + } else { + opt.duration = jQuery.fx.speeds._default; + } + } + } + + // Normalize opt.queue - true/undefined/null -> "fx" + if ( opt.queue == null || opt.queue === true ) { + opt.queue = "fx"; + } + + // Queueing + opt.old = opt.complete; + + opt.complete = function() { + if ( isFunction( opt.old ) ) { + opt.old.call( this ); + } + + if ( opt.queue ) { + jQuery.dequeue( this, opt.queue ); + } + }; + + return opt; +}; + +jQuery.fn.extend( { + fadeTo: function( speed, to, easing, callback ) { + + // Show any hidden elements after setting opacity to 0 + return this.filter( isHiddenWithinTree ).css( "opacity", 0 ).show() + + // Animate to the value specified + .end().animate( { opacity: to }, speed, easing, callback ); + }, + animate: function( prop, speed, easing, callback ) { + var empty = jQuery.isEmptyObject( prop ), + optall = jQuery.speed( speed, easing, callback ), + doAnimation = function() { + + // Operate on a copy of prop so per-property easing won't be lost + var anim = Animation( this, jQuery.extend( {}, prop ), optall ); + + // Empty animations, or finishing resolves immediately + if ( empty || dataPriv.get( this, "finish" ) ) { + anim.stop( true ); + } + }; + doAnimation.finish = doAnimation; + + return empty || optall.queue === false ? + this.each( doAnimation ) : + this.queue( optall.queue, doAnimation ); + }, + stop: function( type, clearQueue, gotoEnd ) { + var stopQueue = function( hooks ) { + var stop = hooks.stop; + delete hooks.stop; + stop( gotoEnd ); + }; + + if ( typeof type !== "string" ) { + gotoEnd = clearQueue; + clearQueue = type; + type = undefined; + } + if ( clearQueue && type !== false ) { + this.queue( type || "fx", [] ); + } + + return this.each( function() { + var dequeue = true, + index = type != null && type + "queueHooks", + timers = jQuery.timers, + data = dataPriv.get( this ); + + if ( index ) { + if ( data[ index ] && data[ index ].stop ) { + stopQueue( data[ index ] ); + } + } else { + for ( index in data ) { + if ( data[ index ] && data[ index ].stop && rrun.test( index ) ) { + stopQueue( data[ index ] ); + } + } + } + + for ( index = timers.length; index--; ) { + if ( timers[ index ].elem === this && + ( type == null || timers[ index ].queue === type ) ) { + + timers[ index ].anim.stop( gotoEnd ); + dequeue = false; + timers.splice( index, 1 ); + } + } + + // Start the next in the queue if the last step wasn't forced. + // Timers currently will call their complete callbacks, which + // will dequeue but only if they were gotoEnd. + if ( dequeue || !gotoEnd ) { + jQuery.dequeue( this, type ); + } + } ); + }, + finish: function( type ) { + if ( type !== false ) { + type = type || "fx"; + } + return this.each( function() { + var index, + data = dataPriv.get( this ), + queue = data[ type + "queue" ], + hooks = data[ type + "queueHooks" ], + timers = jQuery.timers, + length = queue ? queue.length : 0; + + // Enable finishing flag on private data + data.finish = true; + + // Empty the queue first + jQuery.queue( this, type, [] ); + + if ( hooks && hooks.stop ) { + hooks.stop.call( this, true ); + } + + // Look for any active animations, and finish them + for ( index = timers.length; index--; ) { + if ( timers[ index ].elem === this && timers[ index ].queue === type ) { + timers[ index ].anim.stop( true ); + timers.splice( index, 1 ); + } + } + + // Look for any animations in the old queue and finish them + for ( index = 0; index < length; index++ ) { + if ( queue[ index ] && queue[ index ].finish ) { + queue[ index ].finish.call( this ); + } + } + + // Turn off finishing flag + delete data.finish; + } ); + } +} ); + +jQuery.each( [ "toggle", "show", "hide" ], function( i, name ) { + var cssFn = jQuery.fn[ name ]; + jQuery.fn[ name ] = function( speed, easing, callback ) { + return speed == null || typeof speed === "boolean" ? + cssFn.apply( this, arguments ) : + this.animate( genFx( name, true ), speed, easing, callback ); + }; +} ); + +// Generate shortcuts for custom animations +jQuery.each( { + slideDown: genFx( "show" ), + slideUp: genFx( "hide" ), + slideToggle: genFx( "toggle" ), + fadeIn: { opacity: "show" }, + fadeOut: { opacity: "hide" }, + fadeToggle: { opacity: "toggle" } +}, function( name, props ) { + jQuery.fn[ name ] = function( speed, easing, callback ) { + return this.animate( props, speed, easing, callback ); + }; +} ); + +jQuery.timers = []; +jQuery.fx.tick = function() { + var timer, + i = 0, + timers = jQuery.timers; + + fxNow = Date.now(); + + for ( ; i < timers.length; i++ ) { + timer = timers[ i ]; + + // Run the timer and safely remove it when done (allowing for external removal) + if ( !timer() && timers[ i ] === timer ) { + timers.splice( i--, 1 ); + } + } + + if ( !timers.length ) { + jQuery.fx.stop(); + } + fxNow = undefined; +}; + +jQuery.fx.timer = function( timer ) { + jQuery.timers.push( timer ); + jQuery.fx.start(); +}; + +jQuery.fx.interval = 13; +jQuery.fx.start = function() { + if ( inProgress ) { + return; + } + + inProgress = true; + schedule(); +}; + +jQuery.fx.stop = function() { + inProgress = null; +}; + +jQuery.fx.speeds = { + slow: 600, + fast: 200, + + // Default speed + _default: 400 +}; + + +// Based off of the plugin by Clint Helfers, with permission. +// https://web.archive.org/web/20100324014747/http://blindsignals.com/index.php/2009/07/jquery-delay/ +jQuery.fn.delay = function( time, type ) { + time = jQuery.fx ? jQuery.fx.speeds[ time ] || time : time; + type = type || "fx"; + + return this.queue( type, function( next, hooks ) { + var timeout = window.setTimeout( next, time ); + hooks.stop = function() { + window.clearTimeout( timeout ); + }; + } ); +}; + + +( function() { + var input = document.createElement( "input" ), + select = document.createElement( "select" ), + opt = select.appendChild( document.createElement( "option" ) ); + + input.type = "checkbox"; + + // Support: Android <=4.3 only + // Default value for a checkbox should be "on" + support.checkOn = input.value !== ""; + + // Support: IE <=11 only + // Must access selectedIndex to make default options select + support.optSelected = opt.selected; + + // Support: IE <=11 only + // An input loses its value after becoming a radio + input = document.createElement( "input" ); + input.value = "t"; + input.type = "radio"; + support.radioValue = input.value === "t"; +} )(); + + +var boolHook, + attrHandle = jQuery.expr.attrHandle; + +jQuery.fn.extend( { + attr: function( name, value ) { + return access( this, jQuery.attr, name, value, arguments.length > 1 ); + }, + + removeAttr: function( name ) { + return this.each( function() { + jQuery.removeAttr( this, name ); + } ); + } +} ); + +jQuery.extend( { + attr: function( elem, name, value ) { + var ret, hooks, + nType = elem.nodeType; + + // Don't get/set attributes on text, comment and attribute nodes + if ( nType === 3 || nType === 8 || nType === 2 ) { + return; + } + + // Fallback to prop when attributes are not supported + if ( typeof elem.getAttribute === "undefined" ) { + return jQuery.prop( elem, name, value ); + } + + // Attribute hooks are determined by the lowercase version + // Grab necessary hook if one is defined + if ( nType !== 1 || !jQuery.isXMLDoc( elem ) ) { + hooks = jQuery.attrHooks[ name.toLowerCase() ] || + ( jQuery.expr.match.bool.test( name ) ? boolHook : undefined ); + } + + if ( value !== undefined ) { + if ( value === null ) { + jQuery.removeAttr( elem, name ); + return; + } + + if ( hooks && "set" in hooks && + ( ret = hooks.set( elem, value, name ) ) !== undefined ) { + return ret; + } + + elem.setAttribute( name, value + "" ); + return value; + } + + if ( hooks && "get" in hooks && ( ret = hooks.get( elem, name ) ) !== null ) { + return ret; + } + + ret = jQuery.find.attr( elem, name ); + + // Non-existent attributes return null, we normalize to undefined + return ret == null ? undefined : ret; + }, + + attrHooks: { + type: { + set: function( elem, value ) { + if ( !support.radioValue && value === "radio" && + nodeName( elem, "input" ) ) { + var val = elem.value; + elem.setAttribute( "type", value ); + if ( val ) { + elem.value = val; + } + return value; + } + } + } + }, + + removeAttr: function( elem, value ) { + var name, + i = 0, + + // Attribute names can contain non-HTML whitespace characters + // https://html.spec.whatwg.org/multipage/syntax.html#attributes-2 + attrNames = value && value.match( rnothtmlwhite ); + + if ( attrNames && elem.nodeType === 1 ) { + while ( ( name = attrNames[ i++ ] ) ) { + elem.removeAttribute( name ); + } + } + } +} ); + +// Hooks for boolean attributes +boolHook = { + set: function( elem, value, name ) { + if ( value === false ) { + + // Remove boolean attributes when set to false + jQuery.removeAttr( elem, name ); + } else { + elem.setAttribute( name, name ); + } + return name; + } +}; + +jQuery.each( jQuery.expr.match.bool.source.match( /\w+/g ), function( i, name ) { + var getter = attrHandle[ name ] || jQuery.find.attr; + + attrHandle[ name ] = function( elem, name, isXML ) { + var ret, handle, + lowercaseName = name.toLowerCase(); + + if ( !isXML ) { + + // Avoid an infinite loop by temporarily removing this function from the getter + handle = attrHandle[ lowercaseName ]; + attrHandle[ lowercaseName ] = ret; + ret = getter( elem, name, isXML ) != null ? + lowercaseName : + null; + attrHandle[ lowercaseName ] = handle; + } + return ret; + }; +} ); + + + + +var rfocusable = /^(?:input|select|textarea|button)$/i, + rclickable = /^(?:a|area)$/i; + +jQuery.fn.extend( { + prop: function( name, value ) { + return access( this, jQuery.prop, name, value, arguments.length > 1 ); + }, + + removeProp: function( name ) { + return this.each( function() { + delete this[ jQuery.propFix[ name ] || name ]; + } ); + } +} ); + +jQuery.extend( { + prop: function( elem, name, value ) { + var ret, hooks, + nType = elem.nodeType; + + // Don't get/set properties on text, comment and attribute nodes + if ( nType === 3 || nType === 8 || nType === 2 ) { + return; + } + + if ( nType !== 1 || !jQuery.isXMLDoc( elem ) ) { + + // Fix name and attach hooks + name = jQuery.propFix[ name ] || name; + hooks = jQuery.propHooks[ name ]; + } + + if ( value !== undefined ) { + if ( hooks && "set" in hooks && + ( ret = hooks.set( elem, value, name ) ) !== undefined ) { + return ret; + } + + return ( elem[ name ] = value ); + } + + if ( hooks && "get" in hooks && ( ret = hooks.get( elem, name ) ) !== null ) { + return ret; + } + + return elem[ name ]; + }, + + propHooks: { + tabIndex: { + get: function( elem ) { + + // Support: IE <=9 - 11 only + // elem.tabIndex doesn't always return the + // correct value when it hasn't been explicitly set + // https://web.archive.org/web/20141116233347/http://fluidproject.org/blog/2008/01/09/getting-setting-and-removing-tabindex-values-with-javascript/ + // Use proper attribute retrieval(#12072) + var tabindex = jQuery.find.attr( elem, "tabindex" ); + + if ( tabindex ) { + return parseInt( tabindex, 10 ); + } + + if ( + rfocusable.test( elem.nodeName ) || + rclickable.test( elem.nodeName ) && + elem.href + ) { + return 0; + } + + return -1; + } + } + }, + + propFix: { + "for": "htmlFor", + "class": "className" + } +} ); + +// Support: IE <=11 only +// Accessing the selectedIndex property +// forces the browser to respect setting selected +// on the option +// The getter ensures a default option is selected +// when in an optgroup +// eslint rule "no-unused-expressions" is disabled for this code +// since it considers such accessions noop +if ( !support.optSelected ) { + jQuery.propHooks.selected = { + get: function( elem ) { + + /* eslint no-unused-expressions: "off" */ + + var parent = elem.parentNode; + if ( parent && parent.parentNode ) { + parent.parentNode.selectedIndex; + } + return null; + }, + set: function( elem ) { + + /* eslint no-unused-expressions: "off" */ + + var parent = elem.parentNode; + if ( parent ) { + parent.selectedIndex; + + if ( parent.parentNode ) { + parent.parentNode.selectedIndex; + } + } + } + }; +} + +jQuery.each( [ + "tabIndex", + "readOnly", + "maxLength", + "cellSpacing", + "cellPadding", + "rowSpan", + "colSpan", + "useMap", + "frameBorder", + "contentEditable" +], function() { + jQuery.propFix[ this.toLowerCase() ] = this; +} ); + + + + + // Strip and collapse whitespace according to HTML spec + // https://infra.spec.whatwg.org/#strip-and-collapse-ascii-whitespace + function stripAndCollapse( value ) { + var tokens = value.match( rnothtmlwhite ) || []; + return tokens.join( " " ); + } + + +function getClass( elem ) { + return elem.getAttribute && elem.getAttribute( "class" ) || ""; +} + +function classesToArray( value ) { + if ( Array.isArray( value ) ) { + return value; + } + if ( typeof value === "string" ) { + return value.match( rnothtmlwhite ) || []; + } + return []; +} + +jQuery.fn.extend( { + addClass: function( value ) { + var classes, elem, cur, curValue, clazz, j, finalValue, + i = 0; + + if ( isFunction( value ) ) { + return this.each( function( j ) { + jQuery( this ).addClass( value.call( this, j, getClass( this ) ) ); + } ); + } + + classes = classesToArray( value ); + + if ( classes.length ) { + while ( ( elem = this[ i++ ] ) ) { + curValue = getClass( elem ); + cur = elem.nodeType === 1 && ( " " + stripAndCollapse( curValue ) + " " ); + + if ( cur ) { + j = 0; + while ( ( clazz = classes[ j++ ] ) ) { + if ( cur.indexOf( " " + clazz + " " ) < 0 ) { + cur += clazz + " "; + } + } + + // Only assign if different to avoid unneeded rendering. + finalValue = stripAndCollapse( cur ); + if ( curValue !== finalValue ) { + elem.setAttribute( "class", finalValue ); + } + } + } + } + + return this; + }, + + removeClass: function( value ) { + var classes, elem, cur, curValue, clazz, j, finalValue, + i = 0; + + if ( isFunction( value ) ) { + return this.each( function( j ) { + jQuery( this ).removeClass( value.call( this, j, getClass( this ) ) ); + } ); + } + + if ( !arguments.length ) { + return this.attr( "class", "" ); + } + + classes = classesToArray( value ); + + if ( classes.length ) { + while ( ( elem = this[ i++ ] ) ) { + curValue = getClass( elem ); + + // This expression is here for better compressibility (see addClass) + cur = elem.nodeType === 1 && ( " " + stripAndCollapse( curValue ) + " " ); + + if ( cur ) { + j = 0; + while ( ( clazz = classes[ j++ ] ) ) { + + // Remove *all* instances + while ( cur.indexOf( " " + clazz + " " ) > -1 ) { + cur = cur.replace( " " + clazz + " ", " " ); + } + } + + // Only assign if different to avoid unneeded rendering. + finalValue = stripAndCollapse( cur ); + if ( curValue !== finalValue ) { + elem.setAttribute( "class", finalValue ); + } + } + } + } + + return this; + }, + + toggleClass: function( value, stateVal ) { + var type = typeof value, + isValidValue = type === "string" || Array.isArray( value ); + + if ( typeof stateVal === "boolean" && isValidValue ) { + return stateVal ? this.addClass( value ) : this.removeClass( value ); + } + + if ( isFunction( value ) ) { + return this.each( function( i ) { + jQuery( this ).toggleClass( + value.call( this, i, getClass( this ), stateVal ), + stateVal + ); + } ); + } + + return this.each( function() { + var className, i, self, classNames; + + if ( isValidValue ) { + + // Toggle individual class names + i = 0; + self = jQuery( this ); + classNames = classesToArray( value ); + + while ( ( className = classNames[ i++ ] ) ) { + + // Check each className given, space separated list + if ( self.hasClass( className ) ) { + self.removeClass( className ); + } else { + self.addClass( className ); + } + } + + // Toggle whole class name + } else if ( value === undefined || type === "boolean" ) { + className = getClass( this ); + if ( className ) { + + // Store className if set + dataPriv.set( this, "__className__", className ); + } + + // If the element has a class name or if we're passed `false`, + // then remove the whole classname (if there was one, the above saved it). + // Otherwise bring back whatever was previously saved (if anything), + // falling back to the empty string if nothing was stored. + if ( this.setAttribute ) { + this.setAttribute( "class", + className || value === false ? + "" : + dataPriv.get( this, "__className__" ) || "" + ); + } + } + } ); + }, + + hasClass: function( selector ) { + var className, elem, + i = 0; + + className = " " + selector + " "; + while ( ( elem = this[ i++ ] ) ) { + if ( elem.nodeType === 1 && + ( " " + stripAndCollapse( getClass( elem ) ) + " " ).indexOf( className ) > -1 ) { + return true; + } + } + + return false; + } +} ); + + + + +var rreturn = /\r/g; + +jQuery.fn.extend( { + val: function( value ) { + var hooks, ret, valueIsFunction, + elem = this[ 0 ]; + + if ( !arguments.length ) { + if ( elem ) { + hooks = jQuery.valHooks[ elem.type ] || + jQuery.valHooks[ elem.nodeName.toLowerCase() ]; + + if ( hooks && + "get" in hooks && + ( ret = hooks.get( elem, "value" ) ) !== undefined + ) { + return ret; + } + + ret = elem.value; + + // Handle most common string cases + if ( typeof ret === "string" ) { + return ret.replace( rreturn, "" ); + } + + // Handle cases where value is null/undef or number + return ret == null ? "" : ret; + } + + return; + } + + valueIsFunction = isFunction( value ); + + return this.each( function( i ) { + var val; + + if ( this.nodeType !== 1 ) { + return; + } + + if ( valueIsFunction ) { + val = value.call( this, i, jQuery( this ).val() ); + } else { + val = value; + } + + // Treat null/undefined as ""; convert numbers to string + if ( val == null ) { + val = ""; + + } else if ( typeof val === "number" ) { + val += ""; + + } else if ( Array.isArray( val ) ) { + val = jQuery.map( val, function( value ) { + return value == null ? "" : value + ""; + } ); + } + + hooks = jQuery.valHooks[ this.type ] || jQuery.valHooks[ this.nodeName.toLowerCase() ]; + + // If set returns undefined, fall back to normal setting + if ( !hooks || !( "set" in hooks ) || hooks.set( this, val, "value" ) === undefined ) { + this.value = val; + } + } ); + } +} ); + +jQuery.extend( { + valHooks: { + option: { + get: function( elem ) { + + var val = jQuery.find.attr( elem, "value" ); + return val != null ? + val : + + // Support: IE <=10 - 11 only + // option.text throws exceptions (#14686, #14858) + // Strip and collapse whitespace + // https://html.spec.whatwg.org/#strip-and-collapse-whitespace + stripAndCollapse( jQuery.text( elem ) ); + } + }, + select: { + get: function( elem ) { + var value, option, i, + options = elem.options, + index = elem.selectedIndex, + one = elem.type === "select-one", + values = one ? null : [], + max = one ? index + 1 : options.length; + + if ( index < 0 ) { + i = max; + + } else { + i = one ? index : 0; + } + + // Loop through all the selected options + for ( ; i < max; i++ ) { + option = options[ i ]; + + // Support: IE <=9 only + // IE8-9 doesn't update selected after form reset (#2551) + if ( ( option.selected || i === index ) && + + // Don't return options that are disabled or in a disabled optgroup + !option.disabled && + ( !option.parentNode.disabled || + !nodeName( option.parentNode, "optgroup" ) ) ) { + + // Get the specific value for the option + value = jQuery( option ).val(); + + // We don't need an array for one selects + if ( one ) { + return value; + } + + // Multi-Selects return an array + values.push( value ); + } + } + + return values; + }, + + set: function( elem, value ) { + var optionSet, option, + options = elem.options, + values = jQuery.makeArray( value ), + i = options.length; + + while ( i-- ) { + option = options[ i ]; + + /* eslint-disable no-cond-assign */ + + if ( option.selected = + jQuery.inArray( jQuery.valHooks.option.get( option ), values ) > -1 + ) { + optionSet = true; + } + + /* eslint-enable no-cond-assign */ + } + + // Force browsers to behave consistently when non-matching value is set + if ( !optionSet ) { + elem.selectedIndex = -1; + } + return values; + } + } + } +} ); + +// Radios and checkboxes getter/setter +jQuery.each( [ "radio", "checkbox" ], function() { + jQuery.valHooks[ this ] = { + set: function( elem, value ) { + if ( Array.isArray( value ) ) { + return ( elem.checked = jQuery.inArray( jQuery( elem ).val(), value ) > -1 ); + } + } + }; + if ( !support.checkOn ) { + jQuery.valHooks[ this ].get = function( elem ) { + return elem.getAttribute( "value" ) === null ? "on" : elem.value; + }; + } +} ); + + + + +// Return jQuery for attributes-only inclusion + + +support.focusin = "onfocusin" in window; + + +var rfocusMorph = /^(?:focusinfocus|focusoutblur)$/, + stopPropagationCallback = function( e ) { + e.stopPropagation(); + }; + +jQuery.extend( jQuery.event, { + + trigger: function( event, data, elem, onlyHandlers ) { + + var i, cur, tmp, bubbleType, ontype, handle, special, lastElement, + eventPath = [ elem || document ], + type = hasOwn.call( event, "type" ) ? event.type : event, + namespaces = hasOwn.call( event, "namespace" ) ? event.namespace.split( "." ) : []; + + cur = lastElement = tmp = elem = elem || document; + + // Don't do events on text and comment nodes + if ( elem.nodeType === 3 || elem.nodeType === 8 ) { + return; + } + + // focus/blur morphs to focusin/out; ensure we're not firing them right now + if ( rfocusMorph.test( type + jQuery.event.triggered ) ) { + return; + } + + if ( type.indexOf( "." ) > -1 ) { + + // Namespaced trigger; create a regexp to match event type in handle() + namespaces = type.split( "." ); + type = namespaces.shift(); + namespaces.sort(); + } + ontype = type.indexOf( ":" ) < 0 && "on" + type; + + // Caller can pass in a jQuery.Event object, Object, or just an event type string + event = event[ jQuery.expando ] ? + event : + new jQuery.Event( type, typeof event === "object" && event ); + + // Trigger bitmask: & 1 for native handlers; & 2 for jQuery (always true) + event.isTrigger = onlyHandlers ? 2 : 3; + event.namespace = namespaces.join( "." ); + event.rnamespace = event.namespace ? + new RegExp( "(^|\\.)" + namespaces.join( "\\.(?:.*\\.|)" ) + "(\\.|$)" ) : + null; + + // Clean up the event in case it is being reused + event.result = undefined; + if ( !event.target ) { + event.target = elem; + } + + // Clone any incoming data and prepend the event, creating the handler arg list + data = data == null ? + [ event ] : + jQuery.makeArray( data, [ event ] ); + + // Allow special events to draw outside the lines + special = jQuery.event.special[ type ] || {}; + if ( !onlyHandlers && special.trigger && special.trigger.apply( elem, data ) === false ) { + return; + } + + // Determine event propagation path in advance, per W3C events spec (#9951) + // Bubble up to document, then to window; watch for a global ownerDocument var (#9724) + if ( !onlyHandlers && !special.noBubble && !isWindow( elem ) ) { + + bubbleType = special.delegateType || type; + if ( !rfocusMorph.test( bubbleType + type ) ) { + cur = cur.parentNode; + } + for ( ; cur; cur = cur.parentNode ) { + eventPath.push( cur ); + tmp = cur; + } + + // Only add window if we got to document (e.g., not plain obj or detached DOM) + if ( tmp === ( elem.ownerDocument || document ) ) { + eventPath.push( tmp.defaultView || tmp.parentWindow || window ); + } + } + + // Fire handlers on the event path + i = 0; + while ( ( cur = eventPath[ i++ ] ) && !event.isPropagationStopped() ) { + lastElement = cur; + event.type = i > 1 ? + bubbleType : + special.bindType || type; + + // jQuery handler + handle = ( dataPriv.get( cur, "events" ) || {} )[ event.type ] && + dataPriv.get( cur, "handle" ); + if ( handle ) { + handle.apply( cur, data ); + } + + // Native handler + handle = ontype && cur[ ontype ]; + if ( handle && handle.apply && acceptData( cur ) ) { + event.result = handle.apply( cur, data ); + if ( event.result === false ) { + event.preventDefault(); + } + } + } + event.type = type; + + // If nobody prevented the default action, do it now + if ( !onlyHandlers && !event.isDefaultPrevented() ) { + + if ( ( !special._default || + special._default.apply( eventPath.pop(), data ) === false ) && + acceptData( elem ) ) { + + // Call a native DOM method on the target with the same name as the event. + // Don't do default actions on window, that's where global variables be (#6170) + if ( ontype && isFunction( elem[ type ] ) && !isWindow( elem ) ) { + + // Don't re-trigger an onFOO event when we call its FOO() method + tmp = elem[ ontype ]; + + if ( tmp ) { + elem[ ontype ] = null; + } + + // Prevent re-triggering of the same event, since we already bubbled it above + jQuery.event.triggered = type; + + if ( event.isPropagationStopped() ) { + lastElement.addEventListener( type, stopPropagationCallback ); + } + + elem[ type ](); + + if ( event.isPropagationStopped() ) { + lastElement.removeEventListener( type, stopPropagationCallback ); + } + + jQuery.event.triggered = undefined; + + if ( tmp ) { + elem[ ontype ] = tmp; + } + } + } + } + + return event.result; + }, + + // Piggyback on a donor event to simulate a different one + // Used only for `focus(in | out)` events + simulate: function( type, elem, event ) { + var e = jQuery.extend( + new jQuery.Event(), + event, + { + type: type, + isSimulated: true + } + ); + + jQuery.event.trigger( e, null, elem ); + } + +} ); + +jQuery.fn.extend( { + + trigger: function( type, data ) { + return this.each( function() { + jQuery.event.trigger( type, data, this ); + } ); + }, + triggerHandler: function( type, data ) { + var elem = this[ 0 ]; + if ( elem ) { + return jQuery.event.trigger( type, data, elem, true ); + } + } +} ); + + +// Support: Firefox <=44 +// Firefox doesn't have focus(in | out) events +// Related ticket - https://bugzilla.mozilla.org/show_bug.cgi?id=687787 +// +// Support: Chrome <=48 - 49, Safari <=9.0 - 9.1 +// focus(in | out) events fire after focus & blur events, +// which is spec violation - http://www.w3.org/TR/DOM-Level-3-Events/#events-focusevent-event-order +// Related ticket - https://bugs.chromium.org/p/chromium/issues/detail?id=449857 +if ( !support.focusin ) { + jQuery.each( { focus: "focusin", blur: "focusout" }, function( orig, fix ) { + + // Attach a single capturing handler on the document while someone wants focusin/focusout + var handler = function( event ) { + jQuery.event.simulate( fix, event.target, jQuery.event.fix( event ) ); + }; + + jQuery.event.special[ fix ] = { + setup: function() { + var doc = this.ownerDocument || this, + attaches = dataPriv.access( doc, fix ); + + if ( !attaches ) { + doc.addEventListener( orig, handler, true ); + } + dataPriv.access( doc, fix, ( attaches || 0 ) + 1 ); + }, + teardown: function() { + var doc = this.ownerDocument || this, + attaches = dataPriv.access( doc, fix ) - 1; + + if ( !attaches ) { + doc.removeEventListener( orig, handler, true ); + dataPriv.remove( doc, fix ); + + } else { + dataPriv.access( doc, fix, attaches ); + } + } + }; + } ); +} +var location = window.location; + +var nonce = Date.now(); + +var rquery = ( /\?/ ); + + + +// Cross-browser xml parsing +jQuery.parseXML = function( data ) { + var xml; + if ( !data || typeof data !== "string" ) { + return null; + } + + // Support: IE 9 - 11 only + // IE throws on parseFromString with invalid input. + try { + xml = ( new window.DOMParser() ).parseFromString( data, "text/xml" ); + } catch ( e ) { + xml = undefined; + } + + if ( !xml || xml.getElementsByTagName( "parsererror" ).length ) { + jQuery.error( "Invalid XML: " + data ); + } + return xml; +}; + + +var + rbracket = /\[\]$/, + rCRLF = /\r?\n/g, + rsubmitterTypes = /^(?:submit|button|image|reset|file)$/i, + rsubmittable = /^(?:input|select|textarea|keygen)/i; + +function buildParams( prefix, obj, traditional, add ) { + var name; + + if ( Array.isArray( obj ) ) { + + // Serialize array item. + jQuery.each( obj, function( i, v ) { + if ( traditional || rbracket.test( prefix ) ) { + + // Treat each array item as a scalar. + add( prefix, v ); + + } else { + + // Item is non-scalar (array or object), encode its numeric index. + buildParams( + prefix + "[" + ( typeof v === "object" && v != null ? i : "" ) + "]", + v, + traditional, + add + ); + } + } ); + + } else if ( !traditional && toType( obj ) === "object" ) { + + // Serialize object item. + for ( name in obj ) { + buildParams( prefix + "[" + name + "]", obj[ name ], traditional, add ); + } + + } else { + + // Serialize scalar item. + add( prefix, obj ); + } +} + +// Serialize an array of form elements or a set of +// key/values into a query string +jQuery.param = function( a, traditional ) { + var prefix, + s = [], + add = function( key, valueOrFunction ) { + + // If value is a function, invoke it and use its return value + var value = isFunction( valueOrFunction ) ? + valueOrFunction() : + valueOrFunction; + + s[ s.length ] = encodeURIComponent( key ) + "=" + + encodeURIComponent( value == null ? "" : value ); + }; + + if ( a == null ) { + return ""; + } + + // If an array was passed in, assume that it is an array of form elements. + if ( Array.isArray( a ) || ( a.jquery && !jQuery.isPlainObject( a ) ) ) { + + // Serialize the form elements + jQuery.each( a, function() { + add( this.name, this.value ); + } ); + + } else { + + // If traditional, encode the "old" way (the way 1.3.2 or older + // did it), otherwise encode params recursively. + for ( prefix in a ) { + buildParams( prefix, a[ prefix ], traditional, add ); + } + } + + // Return the resulting serialization + return s.join( "&" ); +}; + +jQuery.fn.extend( { + serialize: function() { + return jQuery.param( this.serializeArray() ); + }, + serializeArray: function() { + return this.map( function() { + + // Can add propHook for "elements" to filter or add form elements + var elements = jQuery.prop( this, "elements" ); + return elements ? jQuery.makeArray( elements ) : this; + } ) + .filter( function() { + var type = this.type; + + // Use .is( ":disabled" ) so that fieldset[disabled] works + return this.name && !jQuery( this ).is( ":disabled" ) && + rsubmittable.test( this.nodeName ) && !rsubmitterTypes.test( type ) && + ( this.checked || !rcheckableType.test( type ) ); + } ) + .map( function( i, elem ) { + var val = jQuery( this ).val(); + + if ( val == null ) { + return null; + } + + if ( Array.isArray( val ) ) { + return jQuery.map( val, function( val ) { + return { name: elem.name, value: val.replace( rCRLF, "\r\n" ) }; + } ); + } + + return { name: elem.name, value: val.replace( rCRLF, "\r\n" ) }; + } ).get(); + } +} ); + + +var + r20 = /%20/g, + rhash = /#.*$/, + rantiCache = /([?&])_=[^&]*/, + rheaders = /^(.*?):[ \t]*([^\r\n]*)$/mg, + + // #7653, #8125, #8152: local protocol detection + rlocalProtocol = /^(?:about|app|app-storage|.+-extension|file|res|widget):$/, + rnoContent = /^(?:GET|HEAD)$/, + rprotocol = /^\/\//, + + /* Prefilters + * 1) They are useful to introduce custom dataTypes (see ajax/jsonp.js for an example) + * 2) These are called: + * - BEFORE asking for a transport + * - AFTER param serialization (s.data is a string if s.processData is true) + * 3) key is the dataType + * 4) the catchall symbol "*" can be used + * 5) execution will start with transport dataType and THEN continue down to "*" if needed + */ + prefilters = {}, + + /* Transports bindings + * 1) key is the dataType + * 2) the catchall symbol "*" can be used + * 3) selection will start with transport dataType and THEN go to "*" if needed + */ + transports = {}, + + // Avoid comment-prolog char sequence (#10098); must appease lint and evade compression + allTypes = "*/".concat( "*" ), + + // Anchor tag for parsing the document origin + originAnchor = document.createElement( "a" ); + originAnchor.href = location.href; + +// Base "constructor" for jQuery.ajaxPrefilter and jQuery.ajaxTransport +function addToPrefiltersOrTransports( structure ) { + + // dataTypeExpression is optional and defaults to "*" + return function( dataTypeExpression, func ) { + + if ( typeof dataTypeExpression !== "string" ) { + func = dataTypeExpression; + dataTypeExpression = "*"; + } + + var dataType, + i = 0, + dataTypes = dataTypeExpression.toLowerCase().match( rnothtmlwhite ) || []; + + if ( isFunction( func ) ) { + + // For each dataType in the dataTypeExpression + while ( ( dataType = dataTypes[ i++ ] ) ) { + + // Prepend if requested + if ( dataType[ 0 ] === "+" ) { + dataType = dataType.slice( 1 ) || "*"; + ( structure[ dataType ] = structure[ dataType ] || [] ).unshift( func ); + + // Otherwise append + } else { + ( structure[ dataType ] = structure[ dataType ] || [] ).push( func ); + } + } + } + }; +} + +// Base inspection function for prefilters and transports +function inspectPrefiltersOrTransports( structure, options, originalOptions, jqXHR ) { + + var inspected = {}, + seekingTransport = ( structure === transports ); + + function inspect( dataType ) { + var selected; + inspected[ dataType ] = true; + jQuery.each( structure[ dataType ] || [], function( _, prefilterOrFactory ) { + var dataTypeOrTransport = prefilterOrFactory( options, originalOptions, jqXHR ); + if ( typeof dataTypeOrTransport === "string" && + !seekingTransport && !inspected[ dataTypeOrTransport ] ) { + + options.dataTypes.unshift( dataTypeOrTransport ); + inspect( dataTypeOrTransport ); + return false; + } else if ( seekingTransport ) { + return !( selected = dataTypeOrTransport ); + } + } ); + return selected; + } + + return inspect( options.dataTypes[ 0 ] ) || !inspected[ "*" ] && inspect( "*" ); +} + +// A special extend for ajax options +// that takes "flat" options (not to be deep extended) +// Fixes #9887 +function ajaxExtend( target, src ) { + var key, deep, + flatOptions = jQuery.ajaxSettings.flatOptions || {}; + + for ( key in src ) { + if ( src[ key ] !== undefined ) { + ( flatOptions[ key ] ? target : ( deep || ( deep = {} ) ) )[ key ] = src[ key ]; + } + } + if ( deep ) { + jQuery.extend( true, target, deep ); + } + + return target; +} + +/* Handles responses to an ajax request: + * - finds the right dataType (mediates between content-type and expected dataType) + * - returns the corresponding response + */ +function ajaxHandleResponses( s, jqXHR, responses ) { + + var ct, type, finalDataType, firstDataType, + contents = s.contents, + dataTypes = s.dataTypes; + + // Remove auto dataType and get content-type in the process + while ( dataTypes[ 0 ] === "*" ) { + dataTypes.shift(); + if ( ct === undefined ) { + ct = s.mimeType || jqXHR.getResponseHeader( "Content-Type" ); + } + } + + // Check if we're dealing with a known content-type + if ( ct ) { + for ( type in contents ) { + if ( contents[ type ] && contents[ type ].test( ct ) ) { + dataTypes.unshift( type ); + break; + } + } + } + + // Check to see if we have a response for the expected dataType + if ( dataTypes[ 0 ] in responses ) { + finalDataType = dataTypes[ 0 ]; + } else { + + // Try convertible dataTypes + for ( type in responses ) { + if ( !dataTypes[ 0 ] || s.converters[ type + " " + dataTypes[ 0 ] ] ) { + finalDataType = type; + break; + } + if ( !firstDataType ) { + firstDataType = type; + } + } + + // Or just use first one + finalDataType = finalDataType || firstDataType; + } + + // If we found a dataType + // We add the dataType to the list if needed + // and return the corresponding response + if ( finalDataType ) { + if ( finalDataType !== dataTypes[ 0 ] ) { + dataTypes.unshift( finalDataType ); + } + return responses[ finalDataType ]; + } +} + +/* Chain conversions given the request and the original response + * Also sets the responseXXX fields on the jqXHR instance + */ +function ajaxConvert( s, response, jqXHR, isSuccess ) { + var conv2, current, conv, tmp, prev, + converters = {}, + + // Work with a copy of dataTypes in case we need to modify it for conversion + dataTypes = s.dataTypes.slice(); + + // Create converters map with lowercased keys + if ( dataTypes[ 1 ] ) { + for ( conv in s.converters ) { + converters[ conv.toLowerCase() ] = s.converters[ conv ]; + } + } + + current = dataTypes.shift(); + + // Convert to each sequential dataType + while ( current ) { + + if ( s.responseFields[ current ] ) { + jqXHR[ s.responseFields[ current ] ] = response; + } + + // Apply the dataFilter if provided + if ( !prev && isSuccess && s.dataFilter ) { + response = s.dataFilter( response, s.dataType ); + } + + prev = current; + current = dataTypes.shift(); + + if ( current ) { + + // There's only work to do if current dataType is non-auto + if ( current === "*" ) { + + current = prev; + + // Convert response if prev dataType is non-auto and differs from current + } else if ( prev !== "*" && prev !== current ) { + + // Seek a direct converter + conv = converters[ prev + " " + current ] || converters[ "* " + current ]; + + // If none found, seek a pair + if ( !conv ) { + for ( conv2 in converters ) { + + // If conv2 outputs current + tmp = conv2.split( " " ); + if ( tmp[ 1 ] === current ) { + + // If prev can be converted to accepted input + conv = converters[ prev + " " + tmp[ 0 ] ] || + converters[ "* " + tmp[ 0 ] ]; + if ( conv ) { + + // Condense equivalence converters + if ( conv === true ) { + conv = converters[ conv2 ]; + + // Otherwise, insert the intermediate dataType + } else if ( converters[ conv2 ] !== true ) { + current = tmp[ 0 ]; + dataTypes.unshift( tmp[ 1 ] ); + } + break; + } + } + } + } + + // Apply converter (if not an equivalence) + if ( conv !== true ) { + + // Unless errors are allowed to bubble, catch and return them + if ( conv && s.throws ) { + response = conv( response ); + } else { + try { + response = conv( response ); + } catch ( e ) { + return { + state: "parsererror", + error: conv ? e : "No conversion from " + prev + " to " + current + }; + } + } + } + } + } + } + + return { state: "success", data: response }; +} + +jQuery.extend( { + + // Counter for holding the number of active queries + active: 0, + + // Last-Modified header cache for next request + lastModified: {}, + etag: {}, + + ajaxSettings: { + url: location.href, + type: "GET", + isLocal: rlocalProtocol.test( location.protocol ), + global: true, + processData: true, + async: true, + contentType: "application/x-www-form-urlencoded; charset=UTF-8", + + /* + timeout: 0, + data: null, + dataType: null, + username: null, + password: null, + cache: null, + throws: false, + traditional: false, + headers: {}, + */ + + accepts: { + "*": allTypes, + text: "text/plain", + html: "text/html", + xml: "application/xml, text/xml", + json: "application/json, text/javascript" + }, + + contents: { + xml: /\bxml\b/, + html: /\bhtml/, + json: /\bjson\b/ + }, + + responseFields: { + xml: "responseXML", + text: "responseText", + json: "responseJSON" + }, + + // Data converters + // Keys separate source (or catchall "*") and destination types with a single space + converters: { + + // Convert anything to text + "* text": String, + + // Text to html (true = no transformation) + "text html": true, + + // Evaluate text as a json expression + "text json": JSON.parse, + + // Parse text as xml + "text xml": jQuery.parseXML + }, + + // For options that shouldn't be deep extended: + // you can add your own custom options here if + // and when you create one that shouldn't be + // deep extended (see ajaxExtend) + flatOptions: { + url: true, + context: true + } + }, + + // Creates a full fledged settings object into target + // with both ajaxSettings and settings fields. + // If target is omitted, writes into ajaxSettings. + ajaxSetup: function( target, settings ) { + return settings ? + + // Building a settings object + ajaxExtend( ajaxExtend( target, jQuery.ajaxSettings ), settings ) : + + // Extending ajaxSettings + ajaxExtend( jQuery.ajaxSettings, target ); + }, + + ajaxPrefilter: addToPrefiltersOrTransports( prefilters ), + ajaxTransport: addToPrefiltersOrTransports( transports ), + + // Main method + ajax: function( url, options ) { + + // If url is an object, simulate pre-1.5 signature + if ( typeof url === "object" ) { + options = url; + url = undefined; + } + + // Force options to be an object + options = options || {}; + + var transport, + + // URL without anti-cache param + cacheURL, + + // Response headers + responseHeadersString, + responseHeaders, + + // timeout handle + timeoutTimer, + + // Url cleanup var + urlAnchor, + + // Request state (becomes false upon send and true upon completion) + completed, + + // To know if global events are to be dispatched + fireGlobals, + + // Loop variable + i, + + // uncached part of the url + uncached, + + // Create the final options object + s = jQuery.ajaxSetup( {}, options ), + + // Callbacks context + callbackContext = s.context || s, + + // Context for global events is callbackContext if it is a DOM node or jQuery collection + globalEventContext = s.context && + ( callbackContext.nodeType || callbackContext.jquery ) ? + jQuery( callbackContext ) : + jQuery.event, + + // Deferreds + deferred = jQuery.Deferred(), + completeDeferred = jQuery.Callbacks( "once memory" ), + + // Status-dependent callbacks + statusCode = s.statusCode || {}, + + // Headers (they are sent all at once) + requestHeaders = {}, + requestHeadersNames = {}, + + // Default abort message + strAbort = "canceled", + + // Fake xhr + jqXHR = { + readyState: 0, + + // Builds headers hashtable if needed + getResponseHeader: function( key ) { + var match; + if ( completed ) { + if ( !responseHeaders ) { + responseHeaders = {}; + while ( ( match = rheaders.exec( responseHeadersString ) ) ) { + responseHeaders[ match[ 1 ].toLowerCase() + " " ] = + ( responseHeaders[ match[ 1 ].toLowerCase() + " " ] || [] ) + .concat( match[ 2 ] ); + } + } + match = responseHeaders[ key.toLowerCase() + " " ]; + } + return match == null ? null : match.join( ", " ); + }, + + // Raw string + getAllResponseHeaders: function() { + return completed ? responseHeadersString : null; + }, + + // Caches the header + setRequestHeader: function( name, value ) { + if ( completed == null ) { + name = requestHeadersNames[ name.toLowerCase() ] = + requestHeadersNames[ name.toLowerCase() ] || name; + requestHeaders[ name ] = value; + } + return this; + }, + + // Overrides response content-type header + overrideMimeType: function( type ) { + if ( completed == null ) { + s.mimeType = type; + } + return this; + }, + + // Status-dependent callbacks + statusCode: function( map ) { + var code; + if ( map ) { + if ( completed ) { + + // Execute the appropriate callbacks + jqXHR.always( map[ jqXHR.status ] ); + } else { + + // Lazy-add the new callbacks in a way that preserves old ones + for ( code in map ) { + statusCode[ code ] = [ statusCode[ code ], map[ code ] ]; + } + } + } + return this; + }, + + // Cancel the request + abort: function( statusText ) { + var finalText = statusText || strAbort; + if ( transport ) { + transport.abort( finalText ); + } + done( 0, finalText ); + return this; + } + }; + + // Attach deferreds + deferred.promise( jqXHR ); + + // Add protocol if not provided (prefilters might expect it) + // Handle falsy url in the settings object (#10093: consistency with old signature) + // We also use the url parameter if available + s.url = ( ( url || s.url || location.href ) + "" ) + .replace( rprotocol, location.protocol + "//" ); + + // Alias method option to type as per ticket #12004 + s.type = options.method || options.type || s.method || s.type; + + // Extract dataTypes list + s.dataTypes = ( s.dataType || "*" ).toLowerCase().match( rnothtmlwhite ) || [ "" ]; + + // A cross-domain request is in order when the origin doesn't match the current origin. + if ( s.crossDomain == null ) { + urlAnchor = document.createElement( "a" ); + + // Support: IE <=8 - 11, Edge 12 - 15 + // IE throws exception on accessing the href property if url is malformed, + // e.g. http://example.com:80x/ + try { + urlAnchor.href = s.url; + + // Support: IE <=8 - 11 only + // Anchor's host property isn't correctly set when s.url is relative + urlAnchor.href = urlAnchor.href; + s.crossDomain = originAnchor.protocol + "//" + originAnchor.host !== + urlAnchor.protocol + "//" + urlAnchor.host; + } catch ( e ) { + + // If there is an error parsing the URL, assume it is crossDomain, + // it can be rejected by the transport if it is invalid + s.crossDomain = true; + } + } + + // Convert data if not already a string + if ( s.data && s.processData && typeof s.data !== "string" ) { + s.data = jQuery.param( s.data, s.traditional ); + } + + // Apply prefilters + inspectPrefiltersOrTransports( prefilters, s, options, jqXHR ); + + // If request was aborted inside a prefilter, stop there + if ( completed ) { + return jqXHR; + } + + // We can fire global events as of now if asked to + // Don't fire events if jQuery.event is undefined in an AMD-usage scenario (#15118) + fireGlobals = jQuery.event && s.global; + + // Watch for a new set of requests + if ( fireGlobals && jQuery.active++ === 0 ) { + jQuery.event.trigger( "ajaxStart" ); + } + + // Uppercase the type + s.type = s.type.toUpperCase(); + + // Determine if request has content + s.hasContent = !rnoContent.test( s.type ); + + // Save the URL in case we're toying with the If-Modified-Since + // and/or If-None-Match header later on + // Remove hash to simplify url manipulation + cacheURL = s.url.replace( rhash, "" ); + + // More options handling for requests with no content + if ( !s.hasContent ) { + + // Remember the hash so we can put it back + uncached = s.url.slice( cacheURL.length ); + + // If data is available and should be processed, append data to url + if ( s.data && ( s.processData || typeof s.data === "string" ) ) { + cacheURL += ( rquery.test( cacheURL ) ? "&" : "?" ) + s.data; + + // #9682: remove data so that it's not used in an eventual retry + delete s.data; + } + + // Add or update anti-cache param if needed + if ( s.cache === false ) { + cacheURL = cacheURL.replace( rantiCache, "$1" ); + uncached = ( rquery.test( cacheURL ) ? "&" : "?" ) + "_=" + ( nonce++ ) + uncached; + } + + // Put hash and anti-cache on the URL that will be requested (gh-1732) + s.url = cacheURL + uncached; + + // Change '%20' to '+' if this is encoded form body content (gh-2658) + } else if ( s.data && s.processData && + ( s.contentType || "" ).indexOf( "application/x-www-form-urlencoded" ) === 0 ) { + s.data = s.data.replace( r20, "+" ); + } + + // Set the If-Modified-Since and/or If-None-Match header, if in ifModified mode. + if ( s.ifModified ) { + if ( jQuery.lastModified[ cacheURL ] ) { + jqXHR.setRequestHeader( "If-Modified-Since", jQuery.lastModified[ cacheURL ] ); + } + if ( jQuery.etag[ cacheURL ] ) { + jqXHR.setRequestHeader( "If-None-Match", jQuery.etag[ cacheURL ] ); + } + } + + // Set the correct header, if data is being sent + if ( s.data && s.hasContent && s.contentType !== false || options.contentType ) { + jqXHR.setRequestHeader( "Content-Type", s.contentType ); + } + + // Set the Accepts header for the server, depending on the dataType + jqXHR.setRequestHeader( + "Accept", + s.dataTypes[ 0 ] && s.accepts[ s.dataTypes[ 0 ] ] ? + s.accepts[ s.dataTypes[ 0 ] ] + + ( s.dataTypes[ 0 ] !== "*" ? ", " + allTypes + "; q=0.01" : "" ) : + s.accepts[ "*" ] + ); + + // Check for headers option + for ( i in s.headers ) { + jqXHR.setRequestHeader( i, s.headers[ i ] ); + } + + // Allow custom headers/mimetypes and early abort + if ( s.beforeSend && + ( s.beforeSend.call( callbackContext, jqXHR, s ) === false || completed ) ) { + + // Abort if not done already and return + return jqXHR.abort(); + } + + // Aborting is no longer a cancellation + strAbort = "abort"; + + // Install callbacks on deferreds + completeDeferred.add( s.complete ); + jqXHR.done( s.success ); + jqXHR.fail( s.error ); + + // Get transport + transport = inspectPrefiltersOrTransports( transports, s, options, jqXHR ); + + // If no transport, we auto-abort + if ( !transport ) { + done( -1, "No Transport" ); + } else { + jqXHR.readyState = 1; + + // Send global event + if ( fireGlobals ) { + globalEventContext.trigger( "ajaxSend", [ jqXHR, s ] ); + } + + // If request was aborted inside ajaxSend, stop there + if ( completed ) { + return jqXHR; + } + + // Timeout + if ( s.async && s.timeout > 0 ) { + timeoutTimer = window.setTimeout( function() { + jqXHR.abort( "timeout" ); + }, s.timeout ); + } + + try { + completed = false; + transport.send( requestHeaders, done ); + } catch ( e ) { + + // Rethrow post-completion exceptions + if ( completed ) { + throw e; + } + + // Propagate others as results + done( -1, e ); + } + } + + // Callback for when everything is done + function done( status, nativeStatusText, responses, headers ) { + var isSuccess, success, error, response, modified, + statusText = nativeStatusText; + + // Ignore repeat invocations + if ( completed ) { + return; + } + + completed = true; + + // Clear timeout if it exists + if ( timeoutTimer ) { + window.clearTimeout( timeoutTimer ); + } + + // Dereference transport for early garbage collection + // (no matter how long the jqXHR object will be used) + transport = undefined; + + // Cache response headers + responseHeadersString = headers || ""; + + // Set readyState + jqXHR.readyState = status > 0 ? 4 : 0; + + // Determine if successful + isSuccess = status >= 200 && status < 300 || status === 304; + + // Get response data + if ( responses ) { + response = ajaxHandleResponses( s, jqXHR, responses ); + } + + // Convert no matter what (that way responseXXX fields are always set) + response = ajaxConvert( s, response, jqXHR, isSuccess ); + + // If successful, handle type chaining + if ( isSuccess ) { + + // Set the If-Modified-Since and/or If-None-Match header, if in ifModified mode. + if ( s.ifModified ) { + modified = jqXHR.getResponseHeader( "Last-Modified" ); + if ( modified ) { + jQuery.lastModified[ cacheURL ] = modified; + } + modified = jqXHR.getResponseHeader( "etag" ); + if ( modified ) { + jQuery.etag[ cacheURL ] = modified; + } + } + + // if no content + if ( status === 204 || s.type === "HEAD" ) { + statusText = "nocontent"; + + // if not modified + } else if ( status === 304 ) { + statusText = "notmodified"; + + // If we have data, let's convert it + } else { + statusText = response.state; + success = response.data; + error = response.error; + isSuccess = !error; + } + } else { + + // Extract error from statusText and normalize for non-aborts + error = statusText; + if ( status || !statusText ) { + statusText = "error"; + if ( status < 0 ) { + status = 0; + } + } + } + + // Set data for the fake xhr object + jqXHR.status = status; + jqXHR.statusText = ( nativeStatusText || statusText ) + ""; + + // Success/Error + if ( isSuccess ) { + deferred.resolveWith( callbackContext, [ success, statusText, jqXHR ] ); + } else { + deferred.rejectWith( callbackContext, [ jqXHR, statusText, error ] ); + } + + // Status-dependent callbacks + jqXHR.statusCode( statusCode ); + statusCode = undefined; + + if ( fireGlobals ) { + globalEventContext.trigger( isSuccess ? "ajaxSuccess" : "ajaxError", + [ jqXHR, s, isSuccess ? success : error ] ); + } + + // Complete + completeDeferred.fireWith( callbackContext, [ jqXHR, statusText ] ); + + if ( fireGlobals ) { + globalEventContext.trigger( "ajaxComplete", [ jqXHR, s ] ); + + // Handle the global AJAX counter + if ( !( --jQuery.active ) ) { + jQuery.event.trigger( "ajaxStop" ); + } + } + } + + return jqXHR; + }, + + getJSON: function( url, data, callback ) { + return jQuery.get( url, data, callback, "json" ); + }, + + getScript: function( url, callback ) { + return jQuery.get( url, undefined, callback, "script" ); + } +} ); + +jQuery.each( [ "get", "post" ], function( i, method ) { + jQuery[ method ] = function( url, data, callback, type ) { + + // Shift arguments if data argument was omitted + if ( isFunction( data ) ) { + type = type || callback; + callback = data; + data = undefined; + } + + // The url can be an options object (which then must have .url) + return jQuery.ajax( jQuery.extend( { + url: url, + type: method, + dataType: type, + data: data, + success: callback + }, jQuery.isPlainObject( url ) && url ) ); + }; +} ); + + +jQuery._evalUrl = function( url, options ) { + return jQuery.ajax( { + url: url, + + // Make this explicit, since user can override this through ajaxSetup (#11264) + type: "GET", + dataType: "script", + cache: true, + async: false, + global: false, + + // Only evaluate the response if it is successful (gh-4126) + // dataFilter is not invoked for failure responses, so using it instead + // of the default converter is kludgy but it works. + converters: { + "text script": function() {} + }, + dataFilter: function( response ) { + jQuery.globalEval( response, options ); + } + } ); +}; + + +jQuery.fn.extend( { + wrapAll: function( html ) { + var wrap; + + if ( this[ 0 ] ) { + if ( isFunction( html ) ) { + html = html.call( this[ 0 ] ); + } + + // The elements to wrap the target around + wrap = jQuery( html, this[ 0 ].ownerDocument ).eq( 0 ).clone( true ); + + if ( this[ 0 ].parentNode ) { + wrap.insertBefore( this[ 0 ] ); + } + + wrap.map( function() { + var elem = this; + + while ( elem.firstElementChild ) { + elem = elem.firstElementChild; + } + + return elem; + } ).append( this ); + } + + return this; + }, + + wrapInner: function( html ) { + if ( isFunction( html ) ) { + return this.each( function( i ) { + jQuery( this ).wrapInner( html.call( this, i ) ); + } ); + } + + return this.each( function() { + var self = jQuery( this ), + contents = self.contents(); + + if ( contents.length ) { + contents.wrapAll( html ); + + } else { + self.append( html ); + } + } ); + }, + + wrap: function( html ) { + var htmlIsFunction = isFunction( html ); + + return this.each( function( i ) { + jQuery( this ).wrapAll( htmlIsFunction ? html.call( this, i ) : html ); + } ); + }, + + unwrap: function( selector ) { + this.parent( selector ).not( "body" ).each( function() { + jQuery( this ).replaceWith( this.childNodes ); + } ); + return this; + } +} ); + + +jQuery.expr.pseudos.hidden = function( elem ) { + return !jQuery.expr.pseudos.visible( elem ); +}; +jQuery.expr.pseudos.visible = function( elem ) { + return !!( elem.offsetWidth || elem.offsetHeight || elem.getClientRects().length ); +}; + + + + +jQuery.ajaxSettings.xhr = function() { + try { + return new window.XMLHttpRequest(); + } catch ( e ) {} +}; + +var xhrSuccessStatus = { + + // File protocol always yields status code 0, assume 200 + 0: 200, + + // Support: IE <=9 only + // #1450: sometimes IE returns 1223 when it should be 204 + 1223: 204 + }, + xhrSupported = jQuery.ajaxSettings.xhr(); + +support.cors = !!xhrSupported && ( "withCredentials" in xhrSupported ); +support.ajax = xhrSupported = !!xhrSupported; + +jQuery.ajaxTransport( function( options ) { + var callback, errorCallback; + + // Cross domain only allowed if supported through XMLHttpRequest + if ( support.cors || xhrSupported && !options.crossDomain ) { + return { + send: function( headers, complete ) { + var i, + xhr = options.xhr(); + + xhr.open( + options.type, + options.url, + options.async, + options.username, + options.password + ); + + // Apply custom fields if provided + if ( options.xhrFields ) { + for ( i in options.xhrFields ) { + xhr[ i ] = options.xhrFields[ i ]; + } + } + + // Override mime type if needed + if ( options.mimeType && xhr.overrideMimeType ) { + xhr.overrideMimeType( options.mimeType ); + } + + // X-Requested-With header + // For cross-domain requests, seeing as conditions for a preflight are + // akin to a jigsaw puzzle, we simply never set it to be sure. + // (it can always be set on a per-request basis or even using ajaxSetup) + // For same-domain requests, won't change header if already provided. + if ( !options.crossDomain && !headers[ "X-Requested-With" ] ) { + headers[ "X-Requested-With" ] = "XMLHttpRequest"; + } + + // Set headers + for ( i in headers ) { + xhr.setRequestHeader( i, headers[ i ] ); + } + + // Callback + callback = function( type ) { + return function() { + if ( callback ) { + callback = errorCallback = xhr.onload = + xhr.onerror = xhr.onabort = xhr.ontimeout = + xhr.onreadystatechange = null; + + if ( type === "abort" ) { + xhr.abort(); + } else if ( type === "error" ) { + + // Support: IE <=9 only + // On a manual native abort, IE9 throws + // errors on any property access that is not readyState + if ( typeof xhr.status !== "number" ) { + complete( 0, "error" ); + } else { + complete( + + // File: protocol always yields status 0; see #8605, #14207 + xhr.status, + xhr.statusText + ); + } + } else { + complete( + xhrSuccessStatus[ xhr.status ] || xhr.status, + xhr.statusText, + + // Support: IE <=9 only + // IE9 has no XHR2 but throws on binary (trac-11426) + // For XHR2 non-text, let the caller handle it (gh-2498) + ( xhr.responseType || "text" ) !== "text" || + typeof xhr.responseText !== "string" ? + { binary: xhr.response } : + { text: xhr.responseText }, + xhr.getAllResponseHeaders() + ); + } + } + }; + }; + + // Listen to events + xhr.onload = callback(); + errorCallback = xhr.onerror = xhr.ontimeout = callback( "error" ); + + // Support: IE 9 only + // Use onreadystatechange to replace onabort + // to handle uncaught aborts + if ( xhr.onabort !== undefined ) { + xhr.onabort = errorCallback; + } else { + xhr.onreadystatechange = function() { + + // Check readyState before timeout as it changes + if ( xhr.readyState === 4 ) { + + // Allow onerror to be called first, + // but that will not handle a native abort + // Also, save errorCallback to a variable + // as xhr.onerror cannot be accessed + window.setTimeout( function() { + if ( callback ) { + errorCallback(); + } + } ); + } + }; + } + + // Create the abort callback + callback = callback( "abort" ); + + try { + + // Do send the request (this may raise an exception) + xhr.send( options.hasContent && options.data || null ); + } catch ( e ) { + + // #14683: Only rethrow if this hasn't been notified as an error yet + if ( callback ) { + throw e; + } + } + }, + + abort: function() { + if ( callback ) { + callback(); + } + } + }; + } +} ); + + + + +// Prevent auto-execution of scripts when no explicit dataType was provided (See gh-2432) +jQuery.ajaxPrefilter( function( s ) { + if ( s.crossDomain ) { + s.contents.script = false; + } +} ); + +// Install script dataType +jQuery.ajaxSetup( { + accepts: { + script: "text/javascript, application/javascript, " + + "application/ecmascript, application/x-ecmascript" + }, + contents: { + script: /\b(?:java|ecma)script\b/ + }, + converters: { + "text script": function( text ) { + jQuery.globalEval( text ); + return text; + } + } +} ); + +// Handle cache's special case and crossDomain +jQuery.ajaxPrefilter( "script", function( s ) { + if ( s.cache === undefined ) { + s.cache = false; + } + if ( s.crossDomain ) { + s.type = "GET"; + } +} ); + +// Bind script tag hack transport +jQuery.ajaxTransport( "script", function( s ) { + + // This transport only deals with cross domain or forced-by-attrs requests + if ( s.crossDomain || s.scriptAttrs ) { + var script, callback; + return { + send: function( _, complete ) { + script = jQuery( " + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+ +
+
+
+
+ +
+

O365 API

+
+

Contents:

+ +
+
+ + +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/docs/latest/api/account.html b/docs/latest/api/account.html new file mode 100644 index 00000000..d0a7ccb8 --- /dev/null +++ b/docs/latest/api/account.html @@ -0,0 +1,468 @@ + + + + + + + + + Account — O365 documentation + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+ +
+
+
+
+ +
+

Account

+
+
+class O365.account.Account(credentials: str | tuple[str, str], *, username: str | None = None, protocol: Protocol | None = None, main_resource: str | None = None, **kwargs)[source]
+

Bases: object

+
+
+__init__(credentials: str | tuple[str, str], *, username: str | None = None, protocol: Protocol | None = None, main_resource: str | None = None, **kwargs)[source]
+

Creates an object which is used to access resources related to the specified credentials.

+
+
Parameters:
+
    +
  • credentials – a tuple containing the client_id and client_secret

  • +
  • username – the username to be used by this account

  • +
  • protocol – the protocol to be used in this account

  • +
  • main_resource – the resource to be used by this account (‘me’ or ‘users’, etc.)

  • +
  • kwargs – any extra args to be passed to the Connection instance

  • +
+
+
Raises:
+

ValueError – if an invalid protocol is passed

+
+
+
+ +
+
+address_book(*, resource: str | None = None, address_book: str = 'personal')[source]
+

Get an instance to the specified address book for the +specified account resource

+
+
Parameters:
+
    +
  • resource – Custom resource to be used in this address book +(Defaults to parent main_resource)

  • +
  • address_book – Choose from ‘Personal’ or ‘Directory’

  • +
+
+
Returns:
+

a representation of the specified address book

+
+
Return type:
+

AddressBook or GlobalAddressList

+
+
Raises:
+

RuntimeError – if invalid address_book is specified

+
+
+
+ +
+
+authenticate(*, requested_scopes: list | None = None, redirect_uri: str | None = None, handle_consent: Callable = <function consent_input_token>, **kwargs) bool[source]
+

Performs the console authentication flow resulting in a stored token. +It uses the credentials passed on instantiation. +Returns True if succeeded otherwise False.

+
+
Parameters:
+
    +
  • requested_scopes (list[str]) – list of protocol user scopes to be converted +by the protocol or scope helpers or raw scopes

  • +
  • redirect_uri (str) – redirect url configured in registered app

  • +
  • handle_consent – a function to handle the consent process by default just input for the token url

  • +
  • kwargs – other configurations to be passed to the +Connection.get_authorization_url and Connection.request_token methods

  • +
+
+
+
+ +
+
+directory(resource: str | None = None)[source]
+

Returns the active directory instance

+
+ +
+
+get_authenticated_usernames() list[str][source]
+

Returns a list of usernames that are authenticated and have a valid access token or a refresh token.

+
+ +
+
+get_authorization_url(requested_scopes: List[str], redirect_uri: str | None = None, **kwargs) Tuple[str, dict][source]
+

Initializes the oauth authorization flow, getting the +authorization url that the user must approve.

+
+
Parameters:
+
    +
  • requested_scopes (list[str]) – list of scopes to request access for

  • +
  • redirect_uri (str) – redirect url configured in registered app

  • +
  • kwargs – allow to pass unused params in conjunction with Connection

  • +
+
+
Returns:
+

authorization url and the flow dict

+
+
+
+ +
+
+get_current_user_data()[source]
+

Returns the current user data from the active directory

+
+ +
+
+groups(*, resource: str = '')[source]
+

Get an instance to read information from Microsoft Groups

+
+ +
+
+mailbox(resource: str | None = None)[source]
+

Get an instance to the mailbox for the specified account resource

+
+
Parameters:
+

resource – Custom resource to be used in this mailbox +(Defaults to parent main_resource)

+
+
Returns:
+

a representation of account mailbox

+
+
Return type:
+

O365.mailbox.MailBox

+
+
+
+ +
+
+new_message(resource: str | None = None)[source]
+

Creates a new message to be sent or stored

+
+
Parameters:
+

resource (str) – Custom resource to be used in this message +(Defaults to parent main_resource)

+
+
Returns:
+

New empty message

+
+
Return type:
+

Message

+
+
+
+ +
+
+outlook_categories(*, resource: str = '')[source]
+

Returns a Categories object to handle the available Outlook Categories

+
+ +
+
+planner(*, resource: str = '')[source]
+

Get an instance to read information from Microsoft planner

+
+ +
+
+request_token(authorization_url: str | None, *, flow: dict = None, requested_scopes: List[str] | None = None, store_token: bool = True, **kwargs) bool[source]
+

Authenticates for the specified url and gets the oauth token data. Saves the +token in the backend if store_token is True. This will replace any other tokens stored +for the same username and scopes requested. +If the token data is successfully requested, then this method will try to set the username if +not previously set.

+
+
Parameters:
+
    +
  • authorization_url (str or None) – url given by the authorization flow or None if it’s client credentials

  • +
  • flow (dict) – dict object holding the data used in get_authorization_url

  • +
  • requested_scopes (list[str]) – list of scopes to request access for

  • +
  • store_token (bool) – True to store the token in the token backend, +so you don’t have to keep opening the auth link and +authenticating every time

  • +
  • kwargs – allow to pass unused params in conjunction with Connection

  • +
+
+
Returns:
+

Success/Failure

+
+
Return type:
+

bool

+
+
+
+ +
+
+schedule(*, resource: str | None = None)[source]
+

Get an instance to work with calendar events for the +specified account resource

+
+
Parameters:
+

resource – Custom resource to be used in this schedule object +(Defaults to parent main_resource)

+
+
Returns:
+

a representation of calendar events

+
+
Return type:
+

Schedule

+
+
+
+ +
+
+sharepoint(*, resource: str = '')[source]
+

Get an instance to read information from Sharepoint sites for the +specified account resource

+
+
Parameters:
+

resource – Custom resource to be used in this sharepoint +object (Defaults to parent main_resource)

+
+
Returns:
+

a representation of Sharepoint Sites

+
+
Return type:
+

Sharepoint

+
+
Raises:
+

RuntimeError – if protocol doesn’t support the feature

+
+
+
+ +
+
+storage(*, resource: str | None = None)[source]
+

Get an instance to handle file storage (OneDrive / Sharepoint) +for the specified account resource

+
+
Parameters:
+

resource – Custom resource to be used in this drive object +(Defaults to parent main_resource)

+
+
Returns:
+

a representation of OneDrive File Storage

+
+
Return type:
+

Storage

+
+
Raises:
+

RuntimeError – if protocol doesn’t support the feature

+
+
+
+ +
+
+subscriptions(*, resource: str = '')[source]
+

Get an instance to manage MS Graph subscriptions

+
+ +
+
+tasks(*, resource: str = '')[source]
+

Get an instance to read information from Microsoft ToDo

+
+ +
+
+teams(*, resource: str = '')[source]
+

Get an instance to read information from Microsoft Teams

+
+ +
+
+property connection
+

Alias for self.con

+
+
Return type:
+

type(self.connection_constructor)

+
+
+
+ +
+
+property is_authenticated: bool
+

Checks whether the library has the authentication data and that is not expired for the current username. +This will try to load the token from the backend if not already loaded. +Return True if authenticated, False otherwise.

+
+ +
+
+main_resource: str
+

The resource in use for the account.

   Type: str

+
+ +
+
+property username: str | None
+

Returns the username in use for the account

+
+ +
+ +
+ + +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/docs/latest/api/address_book.html b/docs/latest/api/address_book.html new file mode 100644 index 00000000..d323a130 --- /dev/null +++ b/docs/latest/api/address_book.html @@ -0,0 +1,942 @@ + + + + + + + + + Address Book — O365 documentation + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+ +
+
+
+
+ +
+

Address Book

+
+
+class O365.address_book.AddressBook(*, parent=None, con=None, **kwargs)[source]
+

Bases: ContactFolder

+

A class representing an address book

+
+
+__init__(*, parent=None, con=None, **kwargs)[source]
+

Create a contact folder component

+
+
Parameters:
+
    +
  • parent (BaseContactFolder or Account) – parent folder/account for this folder

  • +
  • con (Connection) – connection to use if no parent specified

  • +
  • protocol (Protocol) – protocol to use if no parent specified +(kwargs)

  • +
  • main_resource (str) – use this resource instead of parent resource +(kwargs)

  • +
+
+
+
+ +
+ +
+
+class O365.address_book.BaseContactFolder(*, parent=None, con=None, **kwargs)[source]
+

Bases: ApiComponent

+

Base Contact Folder Grouping Functionality

+
+
+__init__(*, parent=None, con=None, **kwargs)[source]
+

Create a contact folder component

+
+
Parameters:
+
    +
  • parent (BaseContactFolder or Account) – parent folder/account for this folder

  • +
  • con (Connection) – connection to use if no parent specified

  • +
  • protocol (Protocol) – protocol to use if no parent specified +(kwargs)

  • +
  • main_resource (str) – use this resource instead of parent resource +(kwargs)

  • +
+
+
+
+ +
+
+get_contact_by_email(email)[source]
+

Returns a Contact by it’s email

+
+
Parameters:
+

email – email to get contact for

+
+
Returns:
+

Contact for specified email

+
+
Return type:
+

Contact

+
+
+
+ +
+
+get_contacts(limit=100, *, query=None, order_by=None, batch=None)[source]
+

Gets a list of contacts from this address book

+

To use query an order_by check the OData specification here: +http://docs.oasis-open.org/odata/odata/v4.0/errata03/os/complete/ +part2-url-conventions/odata-v4.0-errata03-os-part2-url-conventions +-complete.html

+
+
Parameters:
+
    +
  • limit (int or None) – max no. of contacts to get. Over 999 uses batch.

  • +
  • query (Query or str) – applies a OData filter to the request

  • +
  • order_by (Query or str) – orders the result set based on this condition

  • +
  • batch (int) – batch size, retrieves items in +batches allowing to retrieve more items than the limit.

  • +
+
+
Returns:
+

list of contacts

+
+
Return type:
+

list[Contact] or Pagination

+
+
+
+ +
+
+folder_id
+

Unique identifier of the contact folder.

   Type: str

+
+ +
+
+name
+

The folder’s display name.

   Type: str

+
+ +
+
+parent_id
+

The ID of the folder’s parent folder.

   Type: str

+
+ +
+
+root
+

Indicates if this is the root folder.

   Type: bool

+
+ +
+ +
+
+class O365.address_book.Contact(*, parent=None, con=None, **kwargs)[source]
+

Bases: ApiComponent, AttachableMixin

+

Contact manages lists of events on associated contact on Microsoft 365.

+
+
+__init__(*, parent=None, con=None, **kwargs)[source]
+

Create a contact API component

+
+
Parameters:
+
    +
  • parent (Account) – parent account for this folder

  • +
  • con (Connection) – connection to use if no parent specified

  • +
  • protocol (Protocol) – protocol to use if no parent specified +(kwargs)

  • +
  • main_resource (str) – use this resource instead of parent resource +(kwargs)

  • +
+
+
+
+ +
+
+delete()[source]
+

Deletes this contact

+
+
Returns:
+

Success or Failure

+
+
Return type:
+

bool

+
+
Raises:
+

RuntimeError – if contact is not yet saved to cloud

+
+
+
+ +
+
+get_profile_photo(size=None)[source]
+

Returns this contact profile photo

+
+
Parameters:
+

size (str) – 48x48, 64x64, 96x96, 120x120, 240x240, +360x360, 432x432, 504x504, and 648x648

+
+
+
+ +
+
+new_message(recipient=None, *, recipient_type=RecipientType.TO)[source]
+

This method returns a new draft Message instance with +contacts first email as a recipient

+
+
Parameters:
+
    +
  • recipient (Recipient) – a Recipient instance where to send this +message. If None first email of this contact will be used

  • +
  • recipient_type (RecipientType) – section to add recipient into

  • +
+
+
Returns:
+

newly created message

+
+
Return type:
+

Message or None

+
+
+
+ +
+
+save()[source]
+

Saves this contact to the cloud (create or update existing one +based on what values have changed)

+
+
Returns:
+

Saved or Not

+
+
Return type:
+

bool

+
+
+
+ +
+
+to_api_data(restrict_keys=None)[source]
+

Returns a dictionary in cloud format

+
+
Parameters:
+

restrict_keys – a set of keys to restrict the returned data to.

+
+
+
+ +
+
+update_profile_photo(photo)[source]
+

Updates this contact profile photo +:param bytes photo: the photo data in bytes

+
+ +
+
+property business_address
+

Business Address

+
+
Getter:
+

Get the address of contact

+
+
Setter:
+

Update the address

+
+
Type:
+

dict

+
+
+
+ +
+
+property business_phones
+

Business Contact numbers

+
+
Getter:
+

Get the contact numbers of contact

+
+
Setter:
+

Update the contact numbers

+
+
Type:
+

list[str]

+
+
+
+ +
+
+property categories
+

Assigned Categories

+
+
Getter:
+

Get the categories

+
+
Setter:
+

Update the categories

+
+
Type:
+

list[str]

+
+
+
+ +
+
+property company_name
+

Name of the company

+
+
Getter:
+

Get the company name of contact

+
+
Setter:
+

Update the company name

+
+
Type:
+

str

+
+
+
+ +
+
+property created
+

Created Time

+
+
Return type:
+

datetime

+
+
+
+ +
+
+property department
+

Department

+
+
Getter:
+

Get the department of contact

+
+
Setter:
+

Update the department

+
+
Type:
+

str

+
+
+
+ +
+
+property display_name
+

Display Name

+
+
Getter:
+

Get the display name of the contact

+
+
Setter:
+

Update the display name

+
+
Type:
+

str

+
+
+
+ +
+
+property emails
+

List of email ids of the Contact

+
+
Return type:
+

Recipients

+
+
+
+ +
+
+property fileAs
+

File As

+
+
Getter:
+

Get the fileAs of the contact

+
+
Setter:
+

Update the fileAs

+
+
Type:
+

str

+
+
+
+ +
+
+property folder_id
+

ID of the containing folder

+
+
Return type:
+

str

+
+
+
+ +
+
+property full_name
+

Full Name (Name + Surname)

+
+
Return type:
+

str

+
+
+
+ +
+
+property home_address
+

Home Address

+
+
Getter:
+

Get the address of contact

+
+
Setter:
+

Update the address

+
+
Type:
+

dict

+
+
+
+ +
+
+property home_phones
+

Home Contact numbers

+
+
Getter:
+

Get the contact numbers of contact

+
+
Setter:
+

Update the contact numbers

+
+
Type:
+

list[str]

+
+
+
+ +
+
+property job_title
+

Job Title

+
+
Getter:
+

Get the job title of contact

+
+
Setter:
+

Update the job title

+
+
Type:
+

str

+
+
+
+ +
+
+property main_email
+

Primary(First) email id of the Contact

+
+
Return type:
+

str

+
+
+
+ +
+
+property mobile_phone
+

Personal Contact numbers

+
+
Getter:
+

Get the contact numbers of contact

+
+
Setter:
+

Update the contact numbers

+
+
Type:
+

list[str]

+
+
+
+ +
+
+property modified
+

Last Modified Time

+
+
Return type:
+

datetime

+
+
+
+ +
+
+property name
+

First Name

+
+
Getter:
+

Get the name of the contact

+
+
Setter:
+

Update the name

+
+
Type:
+

str

+
+
+
+ +
+
+object_id
+

The contact’s unique identifier.

   Type: str

+
+ +
+
+property office_location
+

Office Location

+
+
Getter:
+

Get the office location of contact

+
+
Setter:
+

Update the office location

+
+
Type:
+

str

+
+
+
+ +
+
+property other_address
+

Other Address

+
+
Getter:
+

Get the address of contact

+
+
Setter:
+

Update the address

+
+
Type:
+

dict

+
+
+
+ +
+
+property personal_notes
+
+ +
+
+property preferred_language
+

Preferred Language

+
+
Getter:
+

Get the language of contact

+
+
Setter:
+

Update the language

+
+
Type:
+

str

+
+
+
+ +
+
+property surname
+

Surname of Contact

+
+
Getter:
+

Get the surname of the contact

+
+
Setter:
+

Update the surname

+
+
Type:
+

str

+
+
+
+ +
+
+property title
+

Title (Mr., Ms., etc..)

+
+
Getter:
+

Get the title of the contact

+
+
Setter:
+

Update the title

+
+
Type:
+

str

+
+
+
+ +
+ +
+
+class O365.address_book.ContactFolder(*, parent=None, con=None, **kwargs)[source]
+

Bases: BaseContactFolder

+

A Contact Folder representation

+
+
+create_child_folder(folder_name)[source]
+

Creates a new child folder

+
+
Parameters:
+

folder_name (str) – name of the new folder to create

+
+
Returns:
+

newly created folder

+
+
Return type:
+

ContactFolder or None

+
+
+
+ +
+
+delete()[source]
+

Deletes this folder

+
+
Returns:
+

Deleted or Not

+
+
Return type:
+

bool

+
+
+
+ +
+
+get_folder(folder_id=None, folder_name=None)[source]
+

Returns a Contact Folder by it’s id or child folders by name

+
+
Parameters:
+
    +
  • folder_id – the folder_id to be retrieved. +Can be any folder Id (child or not)

  • +
  • folder_name – the folder name to be retrieved. +Must be a child of this folder

  • +
+
+
Returns:
+

a single contact folder

+
+
Return type:
+

ContactFolder

+
+
+
+ +
+
+get_folders(limit=None, *, query=None, order_by=None)[source]
+

Returns a list of child folders

+
+
Parameters:
+
    +
  • limit (int) – max no. of folders to get. Over 999 uses batch.

  • +
  • query (Query or str) – applies a OData filter to the request

  • +
  • order_by (Query or str) – orders the result set based on this condition

  • +
+
+
Returns:
+

list of folders

+
+
Return type:
+

list[ContactFolder]

+
+
+
+ +
+
+move_folder(to_folder)[source]
+

Change this folder name

+
+
Parameters:
+

to_folder (str or ContactFolder) – folder_id/ContactFolder to move into

+
+
Returns:
+

Moved or Not

+
+
Return type:
+

bool

+
+
+
+ +
+
+new_contact()[source]
+

Creates a new contact to be saved into it’s parent folder

+
+
Returns:
+

newly created contact

+
+
Return type:
+

Contact

+
+
+
+ +
+
+new_message(recipient_type=RecipientType.TO, *, query=None)[source]
+

This method returns a new draft Message instance with all the +contacts first email as a recipient

+
+
Parameters:
+
    +
  • recipient_type (RecipientType) – section to add recipient into

  • +
  • query (Query or str) – applies a OData filter to the request

  • +
+
+
Returns:
+

newly created message

+
+
Return type:
+

Message or None

+
+
+
+ +
+
+update_folder_name(name)[source]
+

Change this folder name

+
+
Parameters:
+

name (str) – new name to change to

+
+
Returns:
+

Updated or Not

+
+
Return type:
+

bool

+
+
+
+ +
+ +
+ + +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/docs/latest/api/attachment.html b/docs/latest/api/attachment.html new file mode 100644 index 00000000..603569b6 --- /dev/null +++ b/docs/latest/api/attachment.html @@ -0,0 +1,401 @@ + + + + + + + + + Attachment — O365 documentation + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+ +
+
+
+
+ +
+

Attachment

+
+
+class O365.utils.attachment.AttachableMixin(attachment_name_property=None, attachment_type=None)[source]
+

Bases: object

+
+
+__init__(attachment_name_property=None, attachment_type=None)[source]
+

Defines the functionality for an object to be attachable. +Any object that inherits from this class will be attachable +(if the underlying api allows that)

+
+ +
+
+property attachment_name
+

Name of the attachment

+
+
Getter:
+

get attachment name

+
+
Setter:
+

set new name for the attachment

+
+
Type:
+

str

+
+
+
+ +
+
+property attachment_type
+

Type of attachment

+
+
Return type:
+

str

+
+
+
+ +
+
+to_api_data()[source]
+

Returns a dict to communicate with the server

+
+
Return type:
+

dict

+
+
+
+ +
+ +
+
+class O365.utils.attachment.BaseAttachment(attachment=None, *, parent=None, **kwargs)[source]
+

Bases: ApiComponent

+

BaseAttachment class is the base object for dealing with attachments

+
+
+__init__(attachment=None, *, parent=None, **kwargs)[source]
+

Creates a new attachment, optionally from existing cloud data

+
+
Parameters:
+
    +
  • attachment (dict or str or Path or list[str] or AttachableMixin) – attachment data (dict = cloud data, +other = user data)

  • +
  • parent (BaseAttachments) – the parent Attachments

  • +
  • protocol (Protocol) – protocol to use if no parent specified +(kwargs)

  • +
  • main_resource (str) – use this resource instead of parent resource +(kwargs)

  • +
+
+
+
+ +
+
+attach(api_object, on_cloud=False)[source]
+

Attach this attachment to an existing api_object. This +BaseAttachment object must be an orphan BaseAttachment created for the +sole purpose of attach it to something and therefore run this method.

+
+
Parameters:
+
    +
  • api_object – object to attach to

  • +
  • on_cloud – if the attachment is on cloud or not

  • +
+
+
Returns:
+

Success / Failure

+
+
Return type:
+

bool

+
+
+
+ +
+
+save(location=None, custom_name=None)[source]
+

Save the attachment locally to disk

+
+
Parameters:
+
    +
  • location (str) – path string to where the file is to be saved.

  • +
  • custom_name (str) – a custom name to be saved as

  • +
+
+
Returns:
+

Success / Failure

+
+
Return type:
+

bool

+
+
+
+ +
+
+to_api_data()[source]
+

Returns a dict to communicate with the server

+
+
Return type:
+

dict

+
+
+
+ +
+ +
+
+class O365.utils.attachment.BaseAttachments(parent, attachments=None)[source]
+

Bases: ApiComponent

+

A Collection of BaseAttachments

+
+
+__init__(parent, attachments=None)[source]
+

Attachments must be a list of path strings or dictionary elements

+
+
Parameters:
+
    +
  • parent (Account) – parent object

  • +
  • attachments (list[str] or list[Path] or str or Path or dict) – list of attachments

  • +
+
+
+
+ +
+
+add(attachments)[source]
+

Add more attachments

+
+
Parameters:
+

attachments (list[str] or list[Path] or str or Path or dict) – list of attachments

+
+
+
+ +
+
+clear()[source]
+

Clear the attachments

+
+ +
+
+download_attachments()[source]
+

Downloads this message attachments into memory. +Need a call to ‘attachment.save’ to save them on disk.

+
+
Returns:
+

Success / Failure

+
+
Return type:
+

bool

+
+
+
+ +
+
+remove(attachments)[source]
+

Remove the specified attachments

+
+
Parameters:
+

attachments (list[str] or list[Path] or str or Path or dict) – list of attachments

+
+
+
+ +
+
+to_api_data()[source]
+

Returns a dict to communicate with the server

+
+
Return type:
+

dict

+
+
+
+ +
+ +
+
+class O365.utils.attachment.UploadSessionRequest(parent, attachment)[source]
+

Bases: ApiComponent

+
+
+__init__(parent, attachment)[source]
+

Object initialization

+
+
Parameters:
+
    +
  • protocol (Protocol) – A protocol class or instance to be used with +this connection

  • +
  • main_resource (str) – main_resource to be used in these API +communications

  • +
+
+
+
+ +
+
+to_api_data()[source]
+
+ +
+ +
+ + +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/docs/latest/api/calendar.html b/docs/latest/api/calendar.html new file mode 100644 index 00000000..1ab40ca0 --- /dev/null +++ b/docs/latest/api/calendar.html @@ -0,0 +1,1993 @@ + + + + + + + + + Calendar — O365 documentation + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+ +
+
+
+
+ +
+

Calendar

+
+
+class O365.calendar.Attendee(address, *, name=None, attendee_type=None, response_status=None, event=None)[source]
+

Bases: object

+

A Event attendee

+
+
+__init__(address, *, name=None, attendee_type=None, response_status=None, event=None)[source]
+

Create a event attendee

+
+
Parameters:
+
    +
  • address (str) – email address of the attendee

  • +
  • name (str) – name of the attendee

  • +
  • attendee_type (AttendeeType) – requirement of attendee

  • +
  • response_status (Response) – response status requirement

  • +
  • event (Event) – event for which to assign the attendee

  • +
+
+
+
+ +
+
+property address
+

Email address

+
+
Getter:
+

Get the email address of attendee

+
+
Setter:
+

Set the email address of attendee

+
+
Type:
+

str

+
+
+
+ +
+
+property attendee_type
+

Requirement of the attendee

+
+
Getter:
+

Get the requirement of attendee

+
+
Setter:
+

Set the requirement of attendee

+
+
Type:
+

AttendeeType

+
+
+
+ +
+
+property name
+

Name

+
+
Getter:
+

Get the name of attendee

+
+
Setter:
+

Set the name of attendee

+
+
Type:
+

str

+
+
+
+ +
+
+property response_status
+

Response status of the attendee

+
+
Type:
+

ResponseStatus

+
+
+
+ +
+ +
+
+class O365.calendar.AttendeeType(*values)[source]
+

Bases: CaseEnum

+
+
+Optional = 'optional'
+
+ +
+
+Required = 'required'
+
+ +
+
+Resource = 'resource'
+
+ +
+ +
+
+class O365.calendar.Attendees(event, attendees=None)[source]
+

Bases: ApiComponent

+

A Collection of Attendees

+
+
+__init__(event, attendees=None)[source]
+

Create a collection of attendees

+
+
Parameters:
+
    +
  • event (Event) – event for which to assign the attendees

  • +
  • attendees (str or tuple(str, str) or Attendee or list[str] or +list[tuple(str,str)] or list[Attendee]) – list of attendees to add

  • +
+
+
+
+ +
+
+add(attendees)[source]
+

Add attendees to the parent event

+
+
Parameters:
+

attendees (str or tuple(str, str) or Attendee or list[str] or +list[tuple(str,str)] or list[Attendee]) – list of attendees to add

+
+
+
+ +
+
+clear()[source]
+

Clear the attendees list

+
+ +
+
+remove(attendees)[source]
+

Remove the provided attendees from the event

+
+
Parameters:
+

attendees (str or tuple(str, str) or Attendee or list[str] or +list[tuple(str,str)] or list[Attendee]) – list of attendees to add

+
+
+
+ +
+
+to_api_data()[source]
+

Returns a dict to communicate with the server

+
+
Return type:
+

dict

+
+
+
+ +
+ +
+
+class O365.calendar.Calendar(*, parent=None, con=None, **kwargs)[source]
+

Bases: ApiComponent, HandleRecipientsMixin

+
+
+__init__(*, parent=None, con=None, **kwargs)[source]
+

Create a Calendar Representation

+
+
Parameters:
+
    +
  • parent (Schedule) – parent for this operation

  • +
  • con (Connection) – connection to use if no parent specified

  • +
  • protocol (Protocol) – protocol to use if no parent specified +(kwargs)

  • +
  • main_resource (str) – use this resource instead of parent resource +(kwargs)

  • +
+
+
+
+ +
+
+delete()[source]
+

Deletes this calendar

+
+
Returns:
+

Success / Failure

+
+
Return type:
+

bool

+
+
+
+ +
+
+get_event(param)[source]
+

Returns an Event instance by it’s id

+
+
Parameters:
+

param – an event_id or a Query instance

+
+
Returns:
+

event for the specified info

+
+
Return type:
+

Event

+
+
+
+ +
+
+get_events(limit: int = 25, *, query=None, order_by=None, batch=None, download_attachments=False, include_recurring=True, start_recurring=None, end_recurring=None)[source]
+

Get events from this Calendar

+
+
Parameters:
+
    +
  • limit (int) – max no. of events to get. Over 999 uses batch.

  • +
  • query (Query or str) – applies a OData filter to the request

  • +
  • order_by (Query or str) – orders the result set based on this condition

  • +
  • batch (int) – batch size, retrieves items in +batches allowing to retrieve more items than the limit.

  • +
  • download_attachments – downloads event attachments

  • +
  • include_recurring (bool) – whether to include recurring events or not

  • +
  • start_recurring – a string datetime or a Query object with just a start condition

  • +
  • end_recurring – a string datetime or a Query object with just an end condition

  • +
+
+
Returns:
+

list of events in this calendar

+
+
Return type:
+

list[Event] or Pagination

+
+
+
+ +
+
+new_event(subject=None)[source]
+

Returns a new (unsaved) Event object

+
+
Return type:
+

Event

+
+
+
+ +
+
+update()[source]
+

Updates this calendar. Only name and color can be changed.

+
+
Returns:
+

Success / Failure

+
+
Return type:
+

bool

+
+
+
+ +
+
+calendar_id
+

The calendar’s unique identifier.

   Type: str

+
+ +
+
+can_edit
+

true if the user can write to the calendar, false otherwise.

   Type: bool

+
+ +
+
+can_share
+

true if the user has permission to share the calendar, false otherwise.

   Type: bool

+
+ +
+
+can_view_private_items
+

If true, the user can read calendar items that have been marked private, false otherwise.

   Type: bool

+
+ +
+
+color
+

Specifies the color theme to distinguish the calendar from other calendars in a UI.

   Type: calendarColor

+
+ +
+
+hex_color
+

The calendar color, expressed in a hex color code of three hexadecimal values, +each ranging from 00 to FF and representing the red, green, or blue components +of the color in the RGB color space.

   Type: str

+
+ +
+
+name
+

The calendar name.

   Type: str

+
+ +
+
+property owner
+

Owner of the calendar

+
+
Return type:
+

str

+
+
+
+ +
+ +
+
+class O365.calendar.CalendarColor(*values)[source]
+

Bases: CaseEnum

+
+
+Auto = 'auto'
+
+ +
+
+LightBlue = 'light_blue'
+
+ +
+
+LightBrown = 'light_brown'
+
+ +
+
+LightGray = 'light_gray'
+
+ +
+
+LightGreen = 'light_green'
+
+ +
+
+LightOrange = 'light_orange'
+
+ +
+
+LightPink = 'light_pink'
+
+ +
+
+LightRed = 'light_red'
+
+ +
+
+LightTeal = 'light_teal'
+
+ +
+
+LightYellow = 'light_yellow'
+
+ +
+
+MaxColor = 'max_color'
+
+ +
+ +
+
+class O365.calendar.DailyEventFrequency(recurrence_type, interval)[source]
+

Bases: object

+
+
+__init__(recurrence_type, interval)[source]
+
+ +
+ +
+
+class O365.calendar.Event(*, parent=None, con=None, **kwargs)[source]
+

Bases: ApiComponent, AttachableMixin, HandleRecipientsMixin

+

A Calendar event

+
+
+__init__(*, parent=None, con=None, **kwargs)[source]
+

Create a calendar event representation

+
+
Parameters:
+
    +
  • parent (Calendar or Schedule or ApiComponent) – parent for this operation

  • +
  • con (Connection) – connection to use if no parent specified

  • +
  • protocol (Protocol) – protocol to use if no parent specified +(kwargs)

  • +
  • main_resource (str) – use this resource instead of parent resource +(kwargs)

  • +
  • calendar_id (str) – id of the calender to add this event in +(kwargs)

  • +
  • download_attachments (bool) – whether or not to download attachments +(kwargs)

  • +
  • subject (str) – subject of the event (kwargs)

  • +
+
+
+
+ +
+
+accept_event(comment=None, *, send_response=True, tentatively=False)[source]
+

Accept the event

+
+
Parameters:
+
    +
  • comment – comment to add

  • +
  • send_response – whether or not to send response back

  • +
  • tentatively – whether acceptance is tentative

  • +
+
+
Returns:
+

Success / Failure

+
+
Return type:
+

bool

+
+
+
+ +
+
+cancel_event(comment=None, *, send_response=True)[source]
+

Cancel the event

+
+
Parameters:
+
    +
  • comment (str) – comment to add

  • +
  • send_response (bool) – whether or not to send response back

  • +
+
+
Returns:
+

Success / Failure

+
+
Return type:
+

bool

+
+
+
+ +
+
+decline_event(comment=None, *, send_response=True)[source]
+

Decline the event

+
+
Parameters:
+
    +
  • comment (str) – comment to add

  • +
  • send_response (bool) – whether or not to send response back

  • +
+
+
Returns:
+

Success / Failure

+
+
Return type:
+

bool

+
+
+
+ +
+
+delete()[source]
+

Deletes a stored event

+
+
Returns:
+

Success / Failure

+
+
Return type:
+

bool

+
+
+
+ +
+
+get_body_soup()[source]
+

Returns the beautifulsoup4 of the html body

+
+
Returns:
+

Html body

+
+
Return type:
+

BeautifulSoup

+
+
+
+ +
+
+get_body_text()[source]
+

Parse the body html and returns the body text using bs4

+
+
Returns:
+

body text

+
+
Return type:
+

str

+
+
+
+ +
+
+get_occurrences(start, end, *, limit=None, query=None, order_by=None, batch=None)[source]
+

Returns all the occurrences of a seriesMaster event for a specified time range.

+
+
Parameters:
+
    +
  • start (datetime) – the start of the time range

  • +
  • end (datetime) – the end of the time range

  • +
  • limit (int) – ax no. of events to get. Over 999 uses batch.

  • +
  • query (Query or str) – optional. extra filters or ordes to apply to this query

  • +
  • order_by (str) – orders the result set based on this condition

  • +
  • batch (int) – batch size, retrieves items in +batches allowing to retrieve more items than the limit.

  • +
+
+
Returns:
+

a list of events

+
+
Return type:
+

list[Event] or Pagination

+
+
+
+ +
+
+save()[source]
+

Create a new event or update an existing one by checking what +values have changed and update them on the server

+
+
Returns:
+

Success / Failure

+
+
Return type:
+

bool

+
+
+
+ +
+
+to_api_data(restrict_keys=None)[source]
+

Returns a dict to communicate with the server

+
+
Parameters:
+

restrict_keys – a set of keys to restrict the returned data to

+
+
Return type:
+

dict

+
+
+
+ +
+
+property attachments
+

List of attachments

+
+
Return type:
+

EventAttachments

+
+
+
+ +
+
+property attendees
+

List of meeting attendees

+
+
Return type:
+

Attendees

+
+
+
+ +
+
+property body
+

Body of the event

+
+
Getter:
+

Get body text

+
+
Setter:
+

Set body of event

+
+
Type:
+

str

+
+
+
+ +
+
+body_type
+

The type of the content. Possible values are text and html.

   Type: bodyType

+
+ +
+
+calendar_id
+

The calendar’s unique identifier.

   Type: str

+
+ +
+
+property categories
+

Categories of the event

+
+
Getter:
+

get the list of categories

+
+
Setter:
+

set the list of categories

+
+
Type:
+

list[str]

+
+
+
+ +
+
+property created
+

Created time of the event

+
+
Return type:
+

datetime

+
+
+
+ +
+
+property end
+

End Time of event

+
+
Getter:
+

get the end time

+
+
Setter:
+

set the end time

+
+
Type:
+

datetime

+
+
+
+ +
+
+property event_type
+
+ +
+
+has_attachments
+

Set to true if the event has attachments.

   Type: bool

+
+ +
+
+ical_uid
+

A unique identifier for an event across calendars. This ID is different for each occurrence in a recurring series.

   Type: str

+
+ +
+
+property importance
+

Event Priority

+
+
Getter:
+

get importance of event

+
+
Setter:
+

set the importance of event

+
+
Type:
+

ImportanceLevel

+
+
+
+ +
+
+property is_all_day
+

Is the event for whole day

+
+
Getter:
+

get the current status of is_all_day property

+
+
Setter:
+

set if the event is all day or not

+
+
Type:
+

bool

+
+
+
+ +
+
+is_cancelled
+

Set to true if the event has been cancelled.

   Type: bool

+
+ +
+
+property is_online_meeting
+

Status of the online_meeting

+
+
Getter:
+

check is online_meeting enabled or not

+
+
Setter:
+

enable or disable online_meeting option

+
+
Type:
+

bool

+
+
+
+ +
+
+is_organizer
+

Set to true if the calendar owner (specified by the owner property of the calendar) is the organizer of the event +(specified by the organizer property of the event). It also applies if a delegate organized the event on behalf of the owner. +

   Type: bool

+
+ +
+
+property is_reminder_on
+

Status of the Reminder

+
+
Getter:
+

check is reminder enabled or not

+
+
Setter:
+

enable or disable reminder option

+
+
Type:
+

bool

+
+
+
+ +
+
+property location
+

Location of event

+
+
Getter:
+

get current location configured for the event

+
+
Setter:
+

set a location for the event

+
+
Type:
+

str

+
+
+
+ +
+
+locations
+

The locations where the event is held or attended from.

   Type: list

+
+ +
+
+property modified
+

Last modified time of the event

+
+
Return type:
+

datetime

+
+
+
+ +
+
+property no_forwarding
+
+ +
+
+object_id
+

Unique identifier for the event.

   Type: str

+
+ +
+
+online_meeting
+

Details for an attendee to join the meeting online. The default is null.

   Type: OnlineMeetingInfo

+
+ +
+
+property online_meeting_provider
+

online_meeting_provider of event

+
+
Getter:
+

get current online_meeting_provider configured for the event

+
+
Setter:
+

set a online_meeting_provider for the event

+
+
Type:
+

OnlineMeetingProviderType

+
+
+
+ +
+
+online_meeting_url
+

A URL for an online meeting.

   Type: str

+
+ +
+
+property organizer
+

Organizer of the meeting event

+
+
Return type:
+

Recipient

+
+
+
+ +
+
+property recurrence
+

Recurrence information of the event

+
+
Return type:
+

EventRecurrence

+
+
+
+ +
+
+property remind_before_minutes
+

No. of minutes to remind before the meeting

+
+
Getter:
+

get current minutes

+
+
Setter:
+

set to remind before new x minutes

+
+
Type:
+

int

+
+
+
+ +
+
+property response_requested
+

Is response requested or not

+
+
Getter:
+

Is response requested or not

+
+
Setter:
+

set the event to request response or not

+
+
Type:
+

bool

+
+
+
+ +
+
+property response_status
+

Your response

+
+
Return type:
+

ResponseStatus

+
+
+
+ +
+
+property sensitivity
+

Sensitivity of the Event

+
+
Getter:
+

Get the current sensitivity

+
+
Setter:
+

Set a new sensitivity

+
+
Type:
+

EventSensitivity

+
+
+
+ +
+
+series_master_id
+

The ID for the recurring series master item, if this event is part of a recurring series.

   Type: str

+
+ +
+
+property show_as
+

Show as “busy” or any other status during the event

+
+
Getter:
+

Current status during the event

+
+
Setter:
+

update show as status

+
+
Type:
+

EventShowAs

+
+
+
+ +
+
+property start
+

Start Time of event

+
+
Getter:
+

get the start time

+
+
Setter:
+

set the start time

+
+
Type:
+

datetime

+
+
+
+ +
+
+property subject
+

Subject of the event

+
+
Getter:
+

Get subject

+
+
Setter:
+

Set subject of event

+
+
Type:
+

str

+
+
+
+ +
+
+property transaction_id
+

Transaction Id of the event

+
+
Getter:
+

Get transaction_id

+
+
Setter:
+

Set transaction_id of event - can only be set for event creation

+
+
Type:
+

str

+
+
+
+ +
+ +

The URL to open the event in Outlook on the web.

   Type: str

+
+ +
+ +
+
+class O365.calendar.EventAttachment(attachment=None, *, parent=None, **kwargs)[source]
+

Bases: BaseAttachment

+
+ +
+
+class O365.calendar.EventAttachments(parent, attachments=None)[source]
+

Bases: BaseAttachments

+
+ +
+
+class O365.calendar.EventRecurrence(event, recurrence=None)[source]
+

Bases: ApiComponent

+
+
+__init__(event, recurrence=None)[source]
+

A representation of an event recurrence properties

+
+
Parameters:
+
    +
  • event (Event) – event object

  • +
  • recurrence (dict) – recurrence information

  • +
+
+
+
+ +
+
+set_daily(interval, **kwargs)[source]
+

Set to repeat every x no. of days

+
+
Parameters:
+
    +
  • interval (int) – no. of days to repeat at

  • +
  • start (date) – Start date of repetition (kwargs)

  • +
  • end (date) – End date of repetition (kwargs)

  • +
  • occurrences (int) – no of occurrences (kwargs)

  • +
+
+
+
+ +
+
+set_monthly(interval, *, day_of_month=None, days_of_week=None, index=None, **kwargs)[source]
+

Set to repeat every month on specified days for every x no. of days

+
+
Parameters:
+
    +
  • interval (int) – no. of days to repeat at

  • +
  • day_of_month (int) – repeat day of a month

  • +
  • days_of_week (list[str]) – list of days of the week to repeat

  • +
  • index – index

  • +
  • start (date) – Start date of repetition (kwargs)

  • +
  • end (date) – End date of repetition (kwargs)

  • +
  • occurrences (int) – no of occurrences (kwargs)

  • +
+
+
+
+ +
+
+set_range(start=None, end=None, occurrences=None)[source]
+

Set the range of recurrence

+
+
Parameters:
+
    +
  • start (date) – Start date of repetition

  • +
  • end (date) – End date of repetition

  • +
  • occurrences (int) – no of occurrences

  • +
+
+
+
+ +
+
+set_weekly(interval, *, days_of_week, first_day_of_week, **kwargs)[source]
+

Set to repeat every week on specified days for every x no. of days

+
+
Parameters:
+
    +
  • interval (int) – no. of days to repeat at

  • +
  • first_day_of_week (str) – starting day for a week

  • +
  • days_of_week (list[str]) – list of days of the week to repeat

  • +
  • start (date) – Start date of repetition (kwargs)

  • +
  • end (date) – End date of repetition (kwargs)

  • +
  • occurrences (int) – no of occurrences (kwargs)

  • +
+
+
+
+ +
+
+set_yearly(interval, month, *, day_of_month=None, days_of_week=None, index=None, **kwargs)[source]
+

Set to repeat every month on specified days for every x no. of days

+
+
Parameters:
+
    +
  • interval (int) – no. of days to repeat at

  • +
  • month (int) – month to repeat

  • +
  • day_of_month (int) – repeat day of a month

  • +
  • days_of_week (list[str]) – list of days of the week to repeat

  • +
  • index – index

  • +
  • start (date) – Start date of repetition (kwargs)

  • +
  • end (date) – End date of repetition (kwargs)

  • +
  • occurrences (int) – no of occurrences (kwargs)

  • +
+
+
+
+ +
+
+to_api_data()[source]
+

Returns a dict to communicate with the server

+
+
Return type:
+

dict

+
+
+
+ +
+
+property day_of_month
+

Repeat on this day of month

+
+
Getter:
+

Get the repeat day of month

+
+
Setter:
+

Set the repeat day of month

+
+
Type:
+

int

+
+
+
+ +
+
+property days_of_week
+

Days in week to repeat

+
+
Getter:
+

Get the current list of days

+
+
Setter:
+

Set the list of days to repeat

+
+
Type:
+

set(str)

+
+
+
+ +
+
+property end_date
+

End date of repetition

+
+
Getter:
+

get the end date

+
+
Setter:
+

set the end date

+
+
Type:
+

date

+
+
+
+ +
+
+property first_day_of_week
+

Which day to consider start of the week

+
+
Getter:
+

Get the current start of week

+
+
Setter:
+

Set the start day of week

+
+
Type:
+

str

+
+
+
+ +
+
+property index
+

Index

+
+
Getter:
+

Get index

+
+
Setter:
+

Set index

+
+
Type:
+

str

+
+
+
+ +
+
+property interval
+

Repeat interval for the event

+
+
Getter:
+

Get the current interval

+
+
Setter:
+

Update to a new interval

+
+
Type:
+

int

+
+
+
+ +
+
+property month
+

Month of the event

+
+
Getter:
+

Get month

+
+
Setter:
+

Update month

+
+
Type:
+

int

+
+
+
+ +
+
+property occurrences
+

No. of occurrences

+
+
Getter:
+

Get the no. of occurrences

+
+
Setter:
+

Set the no. of occurrences

+
+
Type:
+

int

+
+
+
+ +
+
+property recurrence_time_zone
+

Timezone to consider for repeating

+
+
Getter:
+

Get the timezone

+
+
Setter:
+

Set the timezone

+
+
Type:
+

str

+
+
+
+ +
+
+property recurrence_type
+

Type of the recurrence pattern

+
+
Getter:
+

Get the type

+
+
Type:
+

str

+
+
+
+ +
+
+property start_date
+

Start date of repetition

+
+
Getter:
+

get the start date

+
+
Setter:
+

set the start date

+
+
Type:
+

date

+
+
+
+ +
+ +
+
+class O365.calendar.EventResponse(*values)[source]
+

Bases: CaseEnum

+
+
+Accepted = 'accepted'
+
+ +
+
+Declined = 'declined'
+
+ +
+
+NotResponded = 'not_responded'
+
+ +
+
+Organizer = 'organizer'
+
+ +
+
+TentativelyAccepted = 'tentatively_accepted'
+
+ +
+ +
+
+class O365.calendar.EventSensitivity(*values)[source]
+

Bases: CaseEnum

+
+
+Confidential = 'confidential'
+
+ +
+
+Normal = 'normal'
+
+ +
+
+Personal = 'personal'
+
+ +
+
+Private = 'private'
+
+ +
+ +
+
+class O365.calendar.EventShowAs(*values)[source]
+

Bases: CaseEnum

+
+
+Busy = 'busy'
+
+ +
+
+Free = 'free'
+
+ +
+
+Oof = 'oof'
+
+ +
+
+Tentative = 'tentative'
+
+ +
+
+Unknown = 'unknown'
+
+ +
+
+WorkingElsewhere = 'working_elsewhere'
+
+ +
+ +
+
+class O365.calendar.EventType(*values)[source]
+

Bases: CaseEnum

+
+
+Exception = 'exception'
+
+ +
+
+Occurrence = 'occurrence'
+
+ +
+
+SeriesMaster = 'series_master'
+
+ +
+
+SingleInstance = 'single_instance'
+
+ +
+ +
+
+class O365.calendar.OnlineMeetingProviderType(*values)[source]
+

Bases: CaseEnum

+
+
+SkypeForBusiness = 'skype_for_business'
+
+ +
+
+SkypeForConsumer = 'skype_for_consumer'
+
+ +
+
+TeamsForBusiness = 'teams_for_business'
+
+ +
+
+Unknown = 'unknown'
+
+ +
+ +
+
+class O365.calendar.ResponseStatus(parent, response_status)[source]
+

Bases: ApiComponent

+

An event response status (status, time)

+
+
+__init__(parent, response_status)[source]
+

An event response status (status, time)

+
+
Parameters:
+
    +
  • parent (Attendees or Event) – parent of this

  • +
  • response_status (dict) – status info frm cloud

  • +
+
+
+
+ +
+
+response_time
+

The time the response was received

   Type: datetime

+
+ +
+
+status
+

The status of the response

   Type: str

+
+ +
+ +
+
+class O365.calendar.Schedule(*, parent=None, con=None, **kwargs)[source]
+

Bases: ApiComponent

+
+
+__init__(*, parent=None, con=None, **kwargs)[source]
+

Create a wrapper around calendars and events

+
+
Parameters:
+
    +
  • parent (Account) – parent for this operation

  • +
  • con (Connection) – connection to use if no parent specified

  • +
  • protocol (Protocol) – protocol to use if no parent specified +(kwargs)

  • +
  • main_resource (str) – use this resource instead of parent resource +(kwargs)

  • +
+
+
+
+ +
+
+get_availability(schedules, start, end, interval=60)[source]
+

Returns the free/busy availability for a set of users in a given time frame +:param list schedules: a list of strings (email addresses) +:param datetime start: the start time frame to look for available space +:param datetime end: the end time frame to look for available space +:param int interval: the number of minutes to look for space

+
+ +
+
+get_calendar(calendar_id=None, calendar_name=None, query=None)[source]
+

Returns a calendar by it’s id or name

+
+
Parameters:
+
    +
  • calendar_id (str) – the calendar id to be retrieved.

  • +
  • calendar_name (str) – the calendar name to be retrieved.

  • +
  • query (Query) – applies a OData filter to the request

  • +
+
+
Returns:
+

calendar for the given info

+
+
Return type:
+

Calendar

+
+
+
+ +
+
+get_default_calendar()[source]
+

Returns the default calendar for the current user

+
+
Return type:
+

Calendar

+
+
+
+ +
+
+get_events(limit=25, *, query=None, order_by=None, batch=None, download_attachments=False, include_recurring=True, start_recurring=None, end_recurring=None)[source]
+

Get events from the default Calendar

+
+
Parameters:
+
    +
  • limit (int) – max no. of events to get. Over 999 uses batch.

  • +
  • query (Query or str) – applies a OData filter to the request

  • +
  • order_by (Query or str) – orders the result set based on this condition

  • +
  • batch (int) – batch size, retrieves items in +batches allowing to retrieve more items than the limit.

  • +
  • download_attachments (bool) – downloads event attachments

  • +
  • include_recurring (bool) – whether to include recurring events or not

  • +
  • start_recurring – a string datetime or a Query object with just a start condition

  • +
  • end_recurring – a string datetime or a Query object with just an end condition

  • +
+
+
Returns:
+

list of items in this folder

+
+
Return type:
+

list[Event] or Pagination

+
+
+
+ +
+
+list_calendars(limit=None, *, query=None, order_by=None, batch=None)[source]
+

Gets a list of calendars

+

To use query an order_by check the OData specification here: +https://docs.oasis-open.org/odata/odata/v4.0/errata03/os/odata-v4.0-errata03-os.html

+
+
Parameters:
+
    +
  • limit (int) – max no. of calendars to get. Over 999 uses batch.

  • +
  • query (Query or str) – applies a OData filter to the request

  • +
  • order_by (Query or str) – orders the result set based on this condition

  • +
  • batch (int) – batch size, retrieves items in +batches allowing to retrieve more items than the limit.

  • +
+
+
Returns:
+

list of calendars

+
+
Return type:
+

list[Calendar] or Pagination

+
+
+
+ +
+
+new_calendar(calendar_name)[source]
+

Creates a new calendar

+
+
Parameters:
+

calendar_name (str) – name of the new calendar

+
+
Returns:
+

a new Calendar instance

+
+
Return type:
+

Calendar

+
+
+
+ +
+
+new_event(subject=None)[source]
+

Returns a new (unsaved) Event object in the default calendar

+
+
Parameters:
+

subject (str) – subject text for the new event

+
+
Returns:
+

new event

+
+
Return type:
+

Event

+
+
+
+ +
+ +
+ + +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/docs/latest/api/category.html b/docs/latest/api/category.html new file mode 100644 index 00000000..be60f0a8 --- /dev/null +++ b/docs/latest/api/category.html @@ -0,0 +1,432 @@ + + + + + + + + + Category — O365 documentation + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+ +
+
+
+
+ +
+

Category

+
+
+class O365.category.Categories(*, parent=None, con=None, **kwargs)[source]
+

Bases: ApiComponent

+
+
+__init__(*, parent=None, con=None, **kwargs)[source]
+

Object to retrive categories

+
+
Parameters:
+
    +
  • parent (Account) – parent object

  • +
  • con (Connection) – connection to use if no parent specified

  • +
  • protocol (Protocol) – protocol to use if no parent specified +(kwargs)

  • +
  • main_resource (str) – use this resource instead of parent resource +(kwargs)

  • +
+
+
+
+ +
+
+create_category(name, color='auto')[source]
+

Creates a category. +If the color is not provided it will be choosed from the pool of unused colors.

+
+
Parameters:
+
    +
  • name (str) – The name of this outlook category. Must be unique.

  • +
  • color (str or CategoryColor) – optional color. If not provided will be assigned automatically.

  • +
+
+
Returns:
+

bool

+
+
+
+ +
+
+get_categories()[source]
+

Returns a list of categories

+
+ +
+
+get_category(category_id)[source]
+

Returns a category by id

+
+ +
+ +
+
+class O365.category.Category(*, parent=None, con=None, **kwargs)[source]
+

Bases: ApiComponent

+
+
+__init__(*, parent=None, con=None, **kwargs)[source]
+

Represents a category by which a user can group Outlook items such as messages and events. +It can be used in conjunction with Event, Message, Contact and Post.

+
+
Parameters:
+
    +
  • parent (Account) – parent object

  • +
  • con (Connection) – connection to use if no parent specified

  • +
  • protocol (Protocol) – protocol to use if no parent specified +(kwargs)

  • +
  • main_resource (str) – use this resource instead of parent resource +(kwargs)

  • +
+
+
+
+ +
+
+delete()[source]
+

Deletes this Category

+
+ +
+
+update_color(color)[source]
+

Updates this Category color +:param None or str or CategoryColor color: the category color

+
+ +
+
+color
+

A pre-set color constant that characterizes a category, and that is mapped to one of 25 predefined colors.

   Type: categoryColor

+
+ +
+
+name
+

A unique name that identifies a category in the user’s mailbox.

   Type: str

+
+ +
+
+object_id
+

The unique id of the category.

   Type: str

+
+ +
+ +
+
+class O365.category.CategoryColor(*values)[source]
+

Bases: Enum

+
+
+classmethod get(color)[source]
+

Gets a color by name or value. +Raises ValueError if not found whithin the collection of colors.

+
+ +
+
+BLACK = 'preset14'
+
+ +
+
+BLUE = 'preset7'
+
+ +
+
+BROWN = 'preset2'
+
+ +
+
+CRANBERRY = 'preset9'
+
+ +
+
+DARKBLUE = 'preset22'
+
+ +
+
+DARKBROWN = 'preset17'
+
+ +
+
+DARKCRANBERRY = 'preset24'
+
+ +
+
+DARKGREEN = 'preset19'
+
+ +
+
+DARKGREY = 'preset13'
+
+ +
+
+DARKOLIVE = 'preset21'
+
+ +
+
+DARKORANGE = 'preset16'
+
+ +
+
+DARKPURPLE = 'preset23'
+
+ +
+
+DARKRED = 'preset15'
+
+ +
+
+DARKSTEEL = 'preset11'
+
+ +
+
+DARKTEAL = 'preset20'
+
+ +
+
+DARKYELLOW = 'preset18'
+
+ +
+
+GRAY = 'preset12'
+
+ +
+
+GREEN = 'preset4'
+
+ +
+
+OLIVE = 'preset6'
+
+ +
+
+ORANGE = 'preset1'
+
+ +
+
+PURPLE = 'preset8'
+
+ +
+
+RED = 'preset0'
+
+ +
+
+STEEL = 'preset10'
+
+ +
+
+TEAL = 'preset5'
+
+ +
+
+YELLOW = 'preset3'
+
+ +
+ +
+ + +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/docs/latest/api/connection.html b/docs/latest/api/connection.html new file mode 100644 index 00000000..924ce7dc --- /dev/null +++ b/docs/latest/api/connection.html @@ -0,0 +1,922 @@ + + + + + + + + + Connection — O365 documentation + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+ +
+
+
+
+ +
+

Connection

+
+
+exception O365.connection.TokenExpiredError(*args, **kwargs)[source]
+

Bases: HTTPError

+
+ +
+
+class O365.connection.Connection(credentials: str | tuple[str, str], *, proxy_server: str | None = None, proxy_port: int | None = 8080, proxy_username: str | None = None, proxy_password: str | None = None, proxy_http_only: bool = False, requests_delay: int = 200, raise_http_errors: bool = True, request_retries: int = 3, token_backend: BaseTokenBackend | None = None, tenant_id: str = 'common', auth_flow_type: str = 'authorization', username: str | None = None, password: str | None = None, timeout: int | None = None, json_encoder: JSONEncoder | None = None, verify_ssl: bool = True, default_headers: dict = None, store_token_after_refresh: bool = True, **kwargs)[source]
+

Bases: object

+

Handles all communication (requests) between the app and the server

+
+
+__init__(credentials: str | tuple[str, str], *, proxy_server: str | None = None, proxy_port: int | None = 8080, proxy_username: str | None = None, proxy_password: str | None = None, proxy_http_only: bool = False, requests_delay: int = 200, raise_http_errors: bool = True, request_retries: int = 3, token_backend: BaseTokenBackend | None = None, tenant_id: str = 'common', auth_flow_type: str = 'authorization', username: str | None = None, password: str | None = None, timeout: int | None = None, json_encoder: JSONEncoder | None = None, verify_ssl: bool = True, default_headers: dict = None, store_token_after_refresh: bool = True, **kwargs)[source]
+

Creates an API connection object

+
+
Parameters:
+
    +
  • credentials (tuple) – a tuple of (client_id, client_secret) +Generate client_id and client_secret in https://entra.microsoft.com/

  • +
  • proxy_server (str) – the proxy server

  • +
  • proxy_port (int) – the proxy port, defaults to 8080

  • +
  • proxy_username (str) – the proxy username

  • +
  • proxy_password (str) – the proxy password

  • +
  • requests_delay (int) – number of milliseconds to wait between api +calls. +The Api will respond with 429 Too many requests if more than +17 requests are made per second. Defaults to 200 milliseconds +just in case more than 1 connection is making requests +across multiple processes.

  • +
  • raise_http_errors (bool) – If True Http 4xx and 5xx status codes +will raise as exceptions

  • +
  • request_retries (int) – number of retries done when the server +responds with 5xx error codes.

  • +
  • token_backend (BaseTokenBackend) – the token backend used to get +and store tokens

  • +
  • tenant_id (str) – use this specific tenant id, defaults to common

  • +
  • default_headers (dict) – allow to force headers in api call +(ex: default_headers={“Prefer”: ‘IdType=”ImmutableId”’}) to get constant id for objects.

  • +
  • auth_flow_type (str) –

    the auth method flow style used: Options:

    +
      +
    • ’authorization’: 2-step web style grant flow using an authentication url

    • +
    • +
      ’public’: 2-step web style grant flow using an authentication url for public apps where

      client secret cannot be secured

      +
      +
      +
    • +
    • +
      ’credentials’: also called client credentials grant flow using only the client id and secret.

      The secret can be certificate based authentication

      +
      +
      +
    • +
    • ’password’: using the username and password. Not recommended

    • +
    +

  • +
  • username (str) – The username the credentials will be taken from in the token backend. +If None, the username will be the first one found in the token backend. +The user’s email address to provide in case of auth_flow_type == ‘password’

  • +
  • password (str) – The user’s password to provide in case of auth_flow_type == ‘password’

  • +
  • timeout (float or tuple) – How long to wait for the server to send +data before giving up, as a float, or a tuple (connect timeout, read timeout)

  • +
  • json_encoder (JSONEncoder) – The JSONEncoder to use during the JSON serialization on the request.

  • +
  • verify_ssl (bool) – set the verify flag on the requests library

  • +
  • store_token_after_refresh (bool) – if after a token refresh the token backend should call save_token

  • +
  • kwargs (dict) – any extra params passed to Connection

  • +
+
+
Raises:
+

ValueError – if credentials is not tuple of (client_id, client_secret)

+
+
+
+ +
+
+delete(url: str, **kwargs) Response[source]
+

Shorthand for self.request(url, ‘delete’)

+
+
Parameters:
+
    +
  • url (str) – url to send delete oauth request to

  • +
  • kwargs – extra params to send to request api

  • +
+
+
Returns:
+

Response of the request

+
+
Return type:
+

requests.Response

+
+
+
+ +
+
+get(url: str, params: dict | None = None, **kwargs) Response[source]
+

Shorthand for self.oauth_request(url, ‘get’)

+
+
Parameters:
+
    +
  • url (str) – url to send get oauth request to

  • +
  • params (dict) – request parameter to get the service data

  • +
  • kwargs – extra params to send to request api

  • +
+
+
Returns:
+

Response of the request

+
+
Return type:
+

requests.Response

+
+
+
+ +
+
+get_authorization_url(requested_scopes: List[str], redirect_uri: str | None = None, **kwargs) tuple[str, dict][source]
+

Initializes the oauth authorization flow, getting the +authorization url that the user must approve.

+
+
Parameters:
+
    +
  • requested_scopes (list[str]) – list of scopes to request access for

  • +
  • redirect_uri (str) – redirect url configured in registered app

  • +
  • kwargs – allow to pass unused params in conjunction with Connection

  • +
+
+
Returns:
+

authorization url and the flow dict

+
+
+
+ +
+
+get_naive_session() Session[source]
+

Creates and returns a naive session

+
+ +
+
+get_session(load_token: bool = False) Session[source]
+

Create a requests Session object with the oauth token attached to it

+
+
Parameters:
+

load_token (bool) – load the token from the token backend and load the access token into the session auth

+
+
Returns:
+

A ready to use requests session with authentication header attached

+
+
Return type:
+

requests.Session

+
+
+
+ +
+
+load_token_from_backend() bool[source]
+

Loads the token from the backend and tries to set the self.username if it’s not set

+
+ +
+
+naive_request(url: str, method: str, **kwargs) Response[source]
+

Makes a request to url using an without oauth authorization +session, but through a normal session

+
+
Parameters:
+
    +
  • url (str) – url to send request to

  • +
  • method (str) – type of request (get/put/post/patch/delete)

  • +
  • kwargs – extra params to send to the request api

  • +
+
+
Returns:
+

Response of the request

+
+
Return type:
+

requests.Response

+
+
+
+ +
+
+oauth_request(url: str, method: str, **kwargs) Response[source]
+

Makes a request to url using an oauth session. +Raises RuntimeError if the session does not have an Authorization header

+
+
Parameters:
+
    +
  • url (str) – url to send request to

  • +
  • method (str) – type of request (get/put/post/patch/delete)

  • +
  • kwargs – extra params to send to the request api

  • +
+
+
Returns:
+

Response of the request

+
+
Return type:
+

requests.Response

+
+
+
+ +
+
+patch(url: str, data: dict | None = None, **kwargs) Response[source]
+

Shorthand for self.oauth_request(url, ‘patch’)

+
+
Parameters:
+
    +
  • url (str) – url to send patch oauth request to

  • +
  • data (dict) – patch data to update the service

  • +
  • kwargs – extra params to send to request api

  • +
+
+
Returns:
+

Response of the request

+
+
Return type:
+

requests.Response

+
+
+
+ +
+
+post(url: str, data: dict | None = None, **kwargs) Response[source]
+

Shorthand for self.oauth_request(url, ‘post’)

+
+
Parameters:
+
    +
  • url (str) – url to send post oauth request to

  • +
  • data (dict) – post data to update the service

  • +
  • kwargs – extra params to send to request api

  • +
+
+
Returns:
+

Response of the request

+
+
Return type:
+

requests.Response

+
+
+
+ +
+
+put(url: str, data: dict | None = None, **kwargs) Response[source]
+

Shorthand for self.oauth_request(url, ‘put’)

+
+
Parameters:
+
    +
  • url (str) – url to send put oauth request to

  • +
  • data (dict) – put data to update the service

  • +
  • kwargs – extra params to send to request api

  • +
+
+
Returns:
+

Response of the request

+
+
Return type:
+

requests.Response

+
+
+
+ +
+
+refresh_token() bool[source]
+

Refresh the OAuth authorization token. +This will be called automatically when the access token +expires, however, you can manually call this method to +request a new refresh token.

+
+
Return bool:
+

Success / Failure

+
+
+
+ +
+
+request_token(authorization_url: str | None, *, flow: dict | None = None, requested_scopes: List[str] | None = None, store_token: bool = True, **kwargs) bool[source]
+

Authenticates for the specified url and gets the oauth token data. Saves the +token in the backend if store_token is True. This will replace any other tokens stored +for the same username and scopes requested. +If the token data is successfully requested, then this method will try to set the username if +not previously set.

+
+
Parameters:
+
    +
  • authorization_url (str or None) – url given by the authorization flow or None if it’s client credentials

  • +
  • flow (dict) – dict object holding the data used in get_authorization_url

  • +
  • requested_scopes (list[str]) – list of scopes to request access for

  • +
  • store_token (bool) – True to store the token in the token backend, +so you don’t have to keep opening the auth link and +authenticating every time

  • +
  • kwargs – allow to pass unused params in conjunction with Connection

  • +
+
+
Returns:
+

Success/Failure

+
+
Return type:
+

bool

+
+
+
+ +
+
+set_proxy(proxy_server: str, proxy_port: int, proxy_username: str, proxy_password: str, proxy_http_only: bool) None[source]
+

Sets a proxy on the Session

+
+
Parameters:
+
    +
  • proxy_server (str) – the proxy server

  • +
  • proxy_port (int) – the proxy port, defaults to 8080

  • +
  • proxy_username (str) – the proxy username

  • +
  • proxy_password (str) – the proxy password

  • +
  • proxy_http_only (bool) – if the proxy should only be used for http

  • +
+
+
+
+ +
+
+update_session_auth_header(access_token: str | None = None) None[source]
+

Will update the internal request session auth header with an access token

+
+ +
+
+auth: tuple
+

The credentials for the connection.

   Type: tuple

+
+ +
+
+property auth_flow_type: str
+
+ +
+
+default_headers: Dict
+

The default headers.

   Type: dict

+
+ +
+
+json_encoder: JSONEncoder | None
+

JSONEncoder to use.

   Type: json.JSONEncoder

+
+ +
+
+property msal_client: PublicClientApplication | ConfidentialClientApplication
+

Returns the msal client or creates it if it’s not already done

+
+ +
+
+naive_session: Session | None
+

the naive session.

   Type: Session

+
+ +
+
+oauth_redirect_url: str
+

The oauth redirect url.

   Type: str

+
+ +
+
+password: str | None
+

The password for the connection.

   Type: str

+
+ +
+
+proxy: Dict
+

The proxy to use.

   Type: dict

+
+ +
+
+raise_http_errors: bool
+

Should http errors be raised. Default true.

   Type: bool

+
+ +
+
+request_retries: int
+

Number of time to retry request. Default 3.

   Type: int

+
+ +
+
+requests_delay: int
+

The delay to put in a request. Default 0.

   Type: int

+
+ +
+
+session: Session | None
+

The session to use.

   Type: Session

+
+ +
+
+store_token_after_refresh: bool
+

Store token after refresh. Default true.

   Type: bool

+
+ +
+
+tenant_id: str
+

The tenant id.

   Type: str

+
+ +
+
+timeout: int
+

Timeout for the request. Default None.

   Type: int

+
+ +
+
+token_backend: BaseTokenBackend
+

The token backend in use.

   Type: BaseTokenbackend

+
+ +
+
+property username: str | None
+

Returns the username in use +If username is not set this will try to set the username to the first account found +from the token_backend.

+
+ +
+
+verify_ssl: bool
+

Whether to verify the ssl cert. Default true.

   Type: bool

+
+ +
+ +
+
+class O365.connection.MSBusinessCentral365Protocol(api_version: str = 'v1.0', default_resource: str | None = None, environment: str | None = None, **kwargs)[source]
+

Bases: Protocol

+

A Microsoft Business Central Protocol Implementation +https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/api-reference/v1.0/

+
+
+__init__(api_version: str = 'v1.0', default_resource: str | None = None, environment: str | None = None, **kwargs)[source]
+

Create a new Microsoft Graph protocol object

+

_protocol_url = ‘https://api.businesscentral.dynamics.com/

+

_oauth_scope_prefix = ‘https://api.businesscentral.dynamics.com/

+
+
Parameters:
+
    +
  • api_version (str) – api version to use

  • +
  • default_resource (str) – the default resource to use when there is +nothing explicitly specified during the requests

  • +
+
+
+
+ +
+
+max_top_value: int
+

The max value for ‘top’ (999).

   Type: str

+
+ +
+
+property timezone: ZoneInfo
+
+ +
+ +
+
+class O365.connection.MSGraphProtocol(api_version: str = 'v1.0', default_resource: str | None = None, **kwargs)[source]
+

Bases: Protocol

+

A Microsoft Graph Protocol Implementation +https://docs.microsoft.com/en-us/outlook/rest/compare-graph-outlook

+
+
+__init__(api_version: str = 'v1.0', default_resource: str | None = None, **kwargs)[source]
+

Create a new Microsoft Graph protocol object

+

_protocol_url = ‘https://graph.microsoft.com/

+

_oauth_scope_prefix = ‘https://graph.microsoft.com/

+
+
Parameters:
+
    +
  • api_version (str) – api version to use

  • +
  • default_resource (str) – the default resource to use when there is +nothing explicitly specified during the requests

  • +
+
+
+
+ +
+
+max_top_value: int
+

The max value for ‘top’ (999).

   Type: str

+
+ +
+
+property timezone: ZoneInfo
+
+ +
+ +
+
+class O365.connection.Protocol(*, protocol_url: str | None = None, api_version: str | None = None, default_resource: str | None = None, casing_function: Callable | None = None, protocol_scope_prefix: str | None = None, timezone: str | None | ZoneInfo = None, **kwargs)[source]
+

Bases: object

+

Base class for all protocols

+
+
+static to_api_case(key: str) str[source]
+

Converts key to snake_case

+
+
Parameters:
+

key – key to convert into snake_case

+
+
Returns:
+

key after case conversion

+
+
+
+ +
+
+__init__(*, protocol_url: str | None = None, api_version: str | None = None, default_resource: str | None = None, casing_function: Callable | None = None, protocol_scope_prefix: str | None = None, timezone: str | None | ZoneInfo = None, **kwargs)[source]
+

Create a new protocol object

+
+
Parameters:
+
    +
  • protocol_url – the base url used to communicate with the +server

  • +
  • api_version – the api version

  • +
  • default_resource – the default resource to use when there is +nothing explicitly specified during the requests

  • +
  • casing_function – the casing transform function to be +used on api keywords (camelcase / pascalcase)

  • +
  • protocol_scope_prefix – prefix url for scopes

  • +
  • timezone – preferred timezone, if not provided will default +to the system timezone or fallback to UTC

  • +
+
+
Raises:
+

ValueError – if protocol_url or api_version are not supplied

+
+
+
+ +
+
+convert_case(key: str) str[source]
+

Returns a key converted with this protocol casing method

+

Converts case to send/read from the cloud

+

When using Microsoft Graph API, the keywords of the API use +lowerCamelCase Casing

+

Default case in this API is lowerCamelCase

+
+
Parameters:
+

key – a dictionary key to convert

+
+
Returns:
+

key after case conversion

+
+
+
+ +
+
+get_scopes_for(user_provided_scopes: list | str | tuple | None) list[source]
+

Returns a list of scopes needed for each of the +scope_helpers provided, by adding the prefix to them if required

+
+
Parameters:
+

user_provided_scopes – a list of scopes or scope helpers

+
+
Returns:
+

scopes with url prefix added

+
+
Raises:
+

ValueError – if unexpected datatype of scopes are passed

+
+
+
+ +
+
+get_service_keyword(keyword: str) str | None[source]
+

Returns the data set to the key in the internal data-key dict

+
+
Parameters:
+

keyword – key to get value for

+
+
Returns:
+

value of the keyword

+
+
+
+ +
+
+prefix_scope(scope: str) str[source]
+

Inserts the protocol scope prefix if required

+
+ +
+
+api_version: str
+

The api version being used.

   Type: str

+
+ +
+
+casing_function: Callable
+

The casing function being used.

   Type: callable

+
+ +
+
+default_resource: str
+

The resource being used. Defaults to ‘me’.

   Type: str

+
+ +
+
+keyword_data_store: dict
+

The keyword data store.

   Type: dict

+
+ +
+
+max_top_value: int
+

The max value for ‘top’ (500).

   Type: str

+
+ +
+
+protocol_scope_prefix: str
+

The scope prefix for protcol in use.

   Type: str

+
+ +
+
+protocol_url: str
+

The url for the protcol in use.

   Type: str

+
+ +
+
+service_url: str
+

The full service url.

   Type: str

+
+ +
+
+property timezone: ZoneInfo
+
+ +
+
+use_default_casing: bool
+

Indicates if default casing is being used.

   Type: bool

+
+ +
+ +
+
+O365.connection.oauth_authentication_flow(client_id: str, client_secret: str, scopes: List[str] = None, protocol: Protocol | None = None, **kwargs) bool[source]
+

A helper method to perform the OAuth2 authentication flow. +Authenticate and get the oauth token

+
+
Parameters:
+
    +
  • client_id (str) – the client_id

  • +
  • client_secret (str) – the client_secret

  • +
  • scopes (list[str]) – a list of protocol user scopes to be converted +by the protocol or raw scopes

  • +
  • protocol (Protocol) – the protocol to be used. +Defaults to MSGraphProtocol

  • +
  • kwargs – other configuration to be passed to the Connection instance, +connection.get_authorization_url or connection.request_token

  • +
+
+
Returns:
+

Success or Failure

+
+
Return type:
+

bool

+
+
+
+ +
+ + +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/docs/latest/api/directory.html b/docs/latest/api/directory.html new file mode 100644 index 00000000..04375f5f --- /dev/null +++ b/docs/latest/api/directory.html @@ -0,0 +1,779 @@ + + + + + + + + + Directory — O365 documentation + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+ +
+
+
+
+ +
+

Directory

+
+
+class O365.directory.Directory(*, parent=None, con=None, **kwargs)[source]
+

Bases: ApiComponent

+
+
+__init__(*, parent=None, con=None, **kwargs)[source]
+

Represents the Active Directory

+
+
Parameters:
+
    +
  • parent (Account) – parent object

  • +
  • con (Connection) – connection to use if no parent specified

  • +
  • protocol (Protocol) – protocol to use if no parent specified +(kwargs)

  • +
  • main_resource (str) – use this resource instead of parent resource +(kwargs)

  • +
+
+
+
+ +
+
+get_current_user(query=None)[source]
+

Returns the current logged-in user

+
+ +
+
+get_user(user, query=None)[source]
+

Returns a User by it’s id or user principal name

+
+
Parameters:
+

user (str) – the user id or user principal name

+
+
Returns:
+

User for specified email

+
+
Return type:
+

User

+
+
+
+ +
+
+get_user_direct_reports(user, limit=100, *, query=None, order_by=None, batch=None)[source]
+

Gets a list of direct reports for the user provided from the active directory

+

When querying the Active Directory the Users endpoint will be used.

+

Also using endpoints has some limitations on the querying capabilities.

+

To use query an order_by check the OData specification here: +http://docs.oasis-open.org/odata/odata/v4.0/errata03/os/complete/ +part2-url-conventions/odata-v4.0-errata03-os-part2-url-conventions +-complete.html

+
+
Parameters:
+
    +
  • limit (int or None) – max no. of contacts to get. Over 999 uses batch.

  • +
  • query (Query or str) – applies a OData filter to the request

  • +
  • order_by (Query or str) – orders the result set based on this condition

  • +
  • batch (int) – batch size, retrieves items in +batches allowing to retrieve more items than the limit.

  • +
+
+
Returns:
+

list of users

+
+
Return type:
+

list[User] or Pagination

+
+
+
+ +
+
+get_user_manager(user, query=None)[source]
+

Returns a Users’ manager by the users id, or user principal name

+
+
Parameters:
+

user (str) – the user id or user principal name

+
+
Returns:
+

User for specified email

+
+
Return type:
+

User

+
+
+
+ +
+
+get_users(limit=100, *, query=None, order_by=None, batch=None)[source]
+

Gets a list of users from the active directory

+

When querying the Active Directory the Users endpoint will be used. +Only a limited set of information will be available unless you have +access to scope ‘User.Read.All’ which requires App Administration +Consent.

+

Also using endpoints has some limitations on the querying capabilities.

+

To use query an order_by check the OData specification here: +http://docs.oasis-open.org/odata/odata/v4.0/errata03/os/complete/ +part2-url-conventions/odata-v4.0-errata03-os-part2-url-conventions +-complete.html

+
+
Parameters:
+
    +
  • limit (int or None) – max no. of contacts to get. Over 999 uses batch.

  • +
  • query (Query or str) – applies a OData filter to the request

  • +
  • order_by (Query or str) – orders the result set based on this condition

  • +
  • batch (int) – batch size, retrieves items in +batches allowing to retrieve more items than the limit.

  • +
+
+
Returns:
+

list of users

+
+
Return type:
+

list[User] or Pagination

+
+
+
+ +
+
+invite_user(email: str, redirect_url: str, **kwargs) dict[str][source]
+

Sends a guest invitation to the named user to make them a guest of the tenant. +This user can then be added to groups and teams.

+

The return dict is what the graph call returns. The two key pieces of information +is the invitedUser > id key, and the inviteRedeemKey (which is used to activate +the account).

+
+
Parameters:
+
    +
  • email (str) – the email address of the guest to be added

  • +
  • redirect_url (str) – the URL the user will be redirected to after registering their guest account

  • +
+
+
Return type:
+

dict

+
+
+
+ +
+ +
+
+class O365.directory.User(*, parent=None, con=None, **kwargs)[source]
+

Bases: ApiComponent

+
+
+__init__(*, parent=None, con=None, **kwargs)[source]
+

Represents an Azure AD user account

+
+
Parameters:
+
    +
  • parent (Account) – parent object

  • +
  • con (Connection) – connection to use if no parent specified

  • +
  • protocol (Protocol) – protocol to use if no parent specified +(kwargs)

  • +
  • main_resource (str) – use this resource instead of parent resource +(kwargs)

  • +
+
+
+
+ +
+
+get_profile_photo(size=None)[source]
+

Returns the user profile photo

+
+
Parameters:
+

size (str) – 48x48, 64x64, 96x96, 120x120, 240x240, +360x360, 432x432, 504x504, and 648x648

+
+
+
+ +
+
+new_message(recipient=None, *, recipient_type=RecipientType.TO)[source]
+

This method returns a new draft Message instance with this +user email as a recipient

+
+
Parameters:
+
    +
  • recipient (Recipient) – a Recipient instance where to send this +message. If None the email of this contact will be used

  • +
  • recipient_type (RecipientType) – section to add recipient into

  • +
+
+
Returns:
+

newly created message

+
+
Return type:
+

Message or None

+
+
+
+ +
+
+update_profile_photo(photo)[source]
+

Updates this user profile photo +:param bytes photo: the photo data in bytes

+
+ +
+
+about_me
+

A freeform text entry field for the user to describe themselves.

   Type: str

+
+ +
+
+account_enabled
+

true if the account is enabled; otherwise, false.

   Type: str

+
+ +
+
+age_group
+

The age group of the user.

   Type: ageGroup

+
+ +
+
+assigned_licenses
+

The licenses that are assigned to the user, including inherited (group-based) licenses. +

   Type: list[assignedLicenses]

+
+ +
+
+assigned_plans
+

The plans that are assigned to the user.

   Type: list[assignedPlans]

+
+ +
+
+birthday
+

The birthday of the user.

   Type: datetime

+
+ +
+
+business_phones
+

The telephone numbers for the user.

   Type: list[str]

+
+ +
+
+city
+

The city where the user is located.

   Type: str

+
+ +
+
+company_name
+

The name of the company that the user is associated with.

   Type: str

+
+ +
+
+consent_provided_for_minor
+

Whether consent was obtained for minors.

   Type: consentProvidedForMinor

+
+ +
+
+country
+

The country or region where the user is located; for example, US or UK. +

   Type: str

+
+ +
+
+created
+

The date and time the user was created.

   Type: datetime

+
+ +
+
+department
+

The name of the department in which the user works.

   Type: str

+
+ +
+
+display_name
+

The name displayed in the address book for the user.

   Type: str

+
+ +
+
+employee_id
+

The employee identifier assigned to the user by the organization.

   Type: str

+
+ +
+
+fax_number
+

The fax number of the user.

   Type: str

+
+ +
+
+property full_name
+

Full Name (Name + Surname) +:rtype: str

+
+ +
+
+given_name
+

The given name (first name) of the user.

   Type: str

+
+ +
+
+hire_date
+

The type of the user.

   Type: str

+
+ +
+
+im_addresses
+

The instant message voice-over IP (VOIP) session initiation protocol (SIP) +addresses for the user.

   Type: str

+
+ +
+
+interests
+

A list for the user to describe their interests.

   Type: list[str]

+
+ +
+
+is_resource_account
+

Don’t use – reserved for future use.

   Type: bool

+
+ +
+
+job_title
+

The user’s job title.

   Type: str

+
+ +
+
+last_password_change
+

The time when this Microsoft Entra user last changed their password or +when their password was created, whichever date the latest action was performed. +

   Type: str

+
+ +
+
+legal_age_group_classification
+

Used by enterprise applications to determine the legal age group of the user. +

   Type: legalAgeGroupClassification

+
+ +
+
+license_assignment_states
+

State of license assignments for this user. +Also indicates licenses that are directly assigned or the user inherited through +group memberships.

   Type: list[licenseAssignmentState]

+
+ +
+
+mail
+

The SMTP address for the user, for example, jeff@contoso.com.

   Type: str

+
+ +
+
+mail_nickname
+

The mail alias for the user.

   Type: str

+
+ +
+
+mailbox_settings
+

Settings for the primary mailbox of the signed-in user.

   Type: MailboxSettings

+
+ +
+
+mobile_phone
+

The primary cellular telephone number for the user.

   Type: str

+
+ +
+
+my_site
+

The URL for the user’s site.

   Type: str

+
+ +
+
+object_id
+

The unique identifier for the user.

   Type: str

+
+ +
+
+office_location
+

The office location in the user’s place of business.

   Type: str

+
+ +
+
+on_premises_sam_account_name
+

Contains the on-premises samAccountName synchronized from the on-premises directory. +

   Type: str

+
+ +
+
+other_mails
+

A list of other email addresses for the user; for example: +[”bob@contoso.com”, “Robert@fabrikam.com”].

   Type: list[str]

+
+ +
+
+password_policies
+

Specifies password policies for the user.

   Type: str

+
+ +
+
+password_profile
+

Specifies the password profile for the user.

   Type: passwordProfile

+
+ +
+
+past_projects
+

A list for the user to enumerate their past projects.

   Type: list[str]

+
+ +
+
+postal_code
+

The postal code for the user’s postal address.

   Type: str

+
+ +
+
+preferred_data_location
+

The preferred data location for the user.

   Type: str

+
+ +
+
+preferred_language
+

The preferred language for the user. The preferred language format is based on RFC 4646. +

   Type: str

+
+ +
+
+preferred_name
+

The preferred name for the user. +Not Supported. This attribute returns an empty string. +

   Type: str

+
+ +
+
+provisioned_plans
+

The plans that are provisioned for the user..

   Type: list[provisionedPlan]

+
+ +
+
+proxy_addresses
+

For example: [“SMTP: bob@contoso.com”, “smtp: bob@sales.contoso.com”]. +

   Type: list[str]

+
+ +
+
+responsibilities
+

A list for the user to enumerate their responsibilities.

   Type: list[str]

+
+ +
+
+schools
+

A list for the user to enumerate the schools they attended

   Type: list[str]

+
+ +
+
+show_in_address_list
+

Represents whether the user should be included in the Outlook global address list. +

   Type: bool

+
+ +
+
+sign_in_sessions_valid_from
+

Any refresh tokens or session tokens (session cookies) issued before +this time are invalid.

   Type: datetime

+
+ +
+
+skills
+

A list for the user to enumerate their skills.

   Type: list[str]

+
+ +
+
+state
+

The state or province in the user’s address.

   Type: str

+
+ +
+
+street_address
+

The street address of the user’s place of business.

   Type: str

+
+ +
+
+surname
+

The user’s surname (family name or last name).

   Type: str

+
+ +
+
+type
+

The type of the user.

   Type: str

+
+ +
+
+usage_location
+

A two-letter country code (ISO standard 3166).

   Type: str

+
+ +
+
+user_principal_name
+

The user principal name (UPN) of the user. +The UPN is an Internet-style sign-in name for the user based on the Internet +standard RFC 822.

   Type: str

+
+ +
+
+user_type
+

A string value that can be used to classify user types in your directory. +

   Type: str

+
+ +
+ +
+ + +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/docs/latest/api/drive.html b/docs/latest/api/drive.html new file mode 100644 index 00000000..c7a7b3d1 --- /dev/null +++ b/docs/latest/api/drive.html @@ -0,0 +1,1227 @@ + + + + + + + + + One Drive — O365 documentation + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+ +
+
+
+
+ +
+

One Drive

+
+
+class O365.drive.CopyOperation(*, parent=None, con=None, target=None, **kwargs)[source]
+

Bases: ApiComponent

+

https://github.com/OneDrive/onedrive-api-docs/issues/762

+
+
+__init__(*, parent=None, con=None, target=None, **kwargs)[source]
+
+
Parameters:
+
    +
  • parent (Drive) – parent for this operation i.e. the source of the copied item

  • +
  • con (Connection) – connection to use if no parent specified

  • +
  • target (Drive) – The target drive for the copy operation

  • +
  • protocol (Protocol) – protocol to use if no parent specified +(kwargs)

  • +
  • main_resource (str) – use this resource instead of parent resource +(kwargs)

  • +
  • monitor_url (str)

  • +
  • item_id (str)

  • +
+
+
+
+ +
+
+check_status(delay=0)[source]
+

Checks the api endpoint in a loop

+
+
Parameters:
+

delay – number of seconds to wait between api calls. +Note Connection ‘requests_delay’ also apply.

+
+
Returns:
+

tuple of status and percentage complete

+
+
Return type:
+

tuple(str, float)

+
+
+
+ +
+
+get_item()[source]
+

Returns the item copied

+
+
Returns:
+

Copied Item

+
+
Return type:
+

DriveItem

+
+
+
+ +
+ +
+
+class O365.drive.DownloadableMixin[source]
+

Bases: object

+
+
+download(to_path=None, name=None, chunk_size='auto', convert_to_pdf=False, output=None)[source]
+

Downloads this file to the local drive. Can download the +file in chunks with multiple requests to the server.

+
+
Parameters:
+
    +
  • to_path (str or Path) – a path to store the downloaded file

  • +
  • name (str) – the name you want the stored file to have.

  • +
  • chunk_size (int) – number of bytes to retrieve from +each api call to the server. if auto, files bigger than +SIZE_THERSHOLD will be chunked (into memory, will be +however only 1 request)

  • +
  • convert_to_pdf (bool) – will try to download the converted pdf +if file extension in ALLOWED_PDF_EXTENSIONS

  • +
  • output (RawIOBase) – (optional) an opened io object to write to. +if set, the to_path and name will be ignored

  • +
+
+
Returns:
+

Success / Failure

+
+
Return type:
+

bool

+
+
+
+ +
+ +
+
+class O365.drive.Drive(*, parent=None, con=None, **kwargs)[source]
+

Bases: ApiComponent

+

A Drive representation. +A Drive is a Container of Folders and Files and act as a root item

+
+
+__init__(*, parent=None, con=None, **kwargs)[source]
+

Create a drive representation

+
+
Parameters:
+
    +
  • parent (Drive or Storage) – parent for this operation

  • +
  • con (Connection) – connection to use if no parent specified

  • +
  • protocol (Protocol) – protocol to use if no parent specified +(kwargs)

  • +
  • main_resource (str) – use this resource instead of parent resource +(kwargs)

  • +
+
+
+
+ +
+
+get_child_folders(limit=None, *, query=None, order_by=None, batch=None)[source]
+

Returns all the folders inside this folder

+
+
Parameters:
+
    +
  • limit (int) – max no. of folders to get. Over 999 uses batch.

  • +
  • query (Query or str) – applies a OData filter to the request

  • +
  • order_by (Query or str) – orders the result set based on this condition

  • +
  • batch (int) – batch size, retrieves items in +batches allowing to retrieve more items than the limit.

  • +
+
+
Returns:
+

folder items in this folder

+
+
Return type:
+

generator of DriveItem or Pagination

+
+
+
+ +
+
+get_item(item_id)[source]
+

Returns a DriveItem by it’s Id

+
+
Returns:
+

one item

+
+
Return type:
+

DriveItem

+
+
+
+ +
+
+get_item_by_path(item_path)[source]
+

Returns a DriveItem by it’s absolute path: /path/to/file +:return: one item +:rtype: DriveItem

+
+ +
+
+get_items(limit=None, *, query=None, order_by=None, batch=None)[source]
+

Returns a collection of drive items from the root folder

+
+
Parameters:
+
    +
  • limit (int) – max no. of items to get. Over 999 uses batch.

  • +
  • query (Query or str) – applies a OData filter to the request

  • +
  • order_by (Query or str) – orders the result set based on this condition

  • +
  • batch (int) – batch size, retrieves items in +batches allowing to retrieve more items than the limit.

  • +
+
+
Returns:
+

items in this folder

+
+
Return type:
+

generator of DriveItem or Pagination

+
+
+
+ +
+
+get_recent(limit=None, *, query=None, order_by=None, batch=None)[source]
+

Returns a collection of recently used DriveItems

+
+
Parameters:
+
    +
  • limit (int) – max no. of items to get. Over 999 uses batch.

  • +
  • query (Query or str) – applies a OData filter to the request

  • +
  • order_by (Query or str) – orders the result set based on this condition

  • +
  • batch (int) – batch size, retrieves items in +batches allowing to retrieve more items than the limit.

  • +
+
+
Returns:
+

items in this folder

+
+
Return type:
+

generator of DriveItem or Pagination

+
+
+
+ +
+
+get_root_folder()[source]
+

Returns the Root Folder of this drive

+
+
Returns:
+

Root Folder

+
+
Return type:
+

DriveItem

+
+
+
+ +
+
+get_shared_with_me(limit=None, allow_external=False, *, query=None, order_by=None, batch=None)[source]
+

Returns a collection of DriveItems shared with me

+
+
Parameters:
+
    +
  • limit (int) – max no. of items to get. Over 999 uses batch.

  • +
  • query (Query or str) – applies a OData filter to the request

  • +
  • order_by (Query or str) – orders the result set based on this condition

  • +
  • batch (int) – batch size, retrieves items in +batches allowing to retrieve more items than the limit.

  • +
  • allow_external (bool) – includes items shared from external tenants

  • +
+
+
Returns:
+

items in this folder

+
+
Return type:
+

generator of DriveItem or Pagination

+
+
+
+ +
+
+get_special_folder(name)[source]
+

Returns the specified Special Folder

+
+
Returns:
+

a special Folder

+
+
Return type:
+

drive.Folder

+
+
+
+ +
+
+refresh()[source]
+

Updates this drive with data from the server

+
+
Returns:
+

Success / Failure

+
+
Return type:
+

bool

+
+
+
+ +
+
+search(search_text, limit=None, *, query=None, order_by=None, batch=None)[source]
+

Search for DriveItems under this drive. +Your app can search more broadly to include items shared with the +current user.

+

To broaden the search scope, use this search instead the Folder Search.

+

The search API uses a search service under the covers, which requires +indexing of content.

+

As a result, there will be some time between creation of an +item and when it will appear in search results.

+
+
Parameters:
+
    +
  • search_text (str) – The query text used to search for items. +Values may be matched across several fields including filename, +metadata, and file content.

  • +
  • limit (int) – max no. of items to get. Over 999 uses batch.

  • +
  • query (Query or str) – applies a OData filter to the request

  • +
  • order_by (Query or str) – orders the result set based on this condition

  • +
  • batch (int) – batch size, retrieves items in +batches allowing to retrieve more items than the limit.

  • +
+
+
Returns:
+

items in this folder matching search

+
+
Return type:
+

generator of DriveItem or Pagination

+
+
+
+ +
+ +
+
+class O365.drive.DriveItem(*, parent=None, con=None, **kwargs)[source]
+

Bases: ApiComponent

+

A DriveItem representation. Groups all functionality

+
+
+__init__(*, parent=None, con=None, **kwargs)[source]
+

Create a DriveItem

+
+
Parameters:
+
    +
  • parent (Drive or drive.Folder) – parent for this operation

  • +
  • con (Connection) – connection to use if no parent specified

  • +
  • protocol (Protocol) – protocol to use if no parent specified +(kwargs)

  • +
  • main_resource (str) – use this resource instead of parent resource +(kwargs)

  • +
+
+
+
+ +
+
+copy(target=None, name=None)[source]
+

Asynchronously creates a copy of this DriveItem and all it’s +child elements.

+
+
Parameters:
+
    +
  • target (drive.Folder or Drive) – target location to move to. +If it’s a drive the item will be moved to the root folder. +If it’s None, the target is the parent of the item being copied i.e. item will be copied +into the same location.

  • +
  • name – a new name for the copy.

  • +
+
+
Return type:
+

CopyOperation

+
+
+
+ +
+
+delete()[source]
+

Moves this item to the Recycle Bin

+
+
Returns:
+

Success / Failure

+
+
Return type:
+

bool

+
+
+
+ +
+
+get_drive()[source]
+

Returns this item drive +:return: Drive of this item +:rtype: Drive or None

+
+ +
+
+get_parent()[source]
+

the parent of this DriveItem

+
+
Returns:
+

Parent of this item

+
+
Return type:
+

Drive or drive.Folder

+
+
+
+ +
+
+get_permissions()[source]
+

Returns a list of DriveItemPermissions with the +permissions granted for this DriveItem.

+
+
Returns:
+

List of Permissions

+
+
Return type:
+

list[DriveItemPermission]

+
+
+
+ +
+
+get_thumbnails(size=None)[source]
+

Returns this Item Thumbnails. Thumbnails are not supported on +SharePoint Server 2016.

+
+
Parameters:
+

size – request only the specified size: ej: “small”, +Custom 300x400 px: “c300x400”, Crop: “c300x400_Crop”

+
+
Returns:
+

Thumbnail Data

+
+
Return type:
+

dict

+
+
+
+ +
+
+get_version(version_id)[source]
+

Returns a version for specified id

+
+
Returns:
+

a version object of specified id

+
+
Return type:
+

DriveItemVersion

+
+
+
+ +
+
+get_versions()[source]
+

Returns a list of available versions for this item

+
+
Returns:
+

list of versions

+
+
Return type:
+

list[DriveItemVersion]

+
+
+
+ +
+
+property is_file
+

Returns if this DriveItem is a File

+
+ +
+
+property is_folder
+

Returns if this DriveItem is a Folder

+
+ +
+
+property is_image
+

Returns if this DriveItem is a Image

+
+ +
+
+property is_photo
+

Returns if this DriveItem is a Photo

+
+ +
+
+move(target)[source]
+

Moves this DriveItem to another Folder. +Can’t move between different Drives.

+
+
Parameters:
+

target (drive.Folder or DriveItem or str) – a Folder, Drive item or Item Id string. +If it’s a drive the item will be moved to the root folder.

+
+
Returns:
+

Success / Failure

+
+
Return type:
+

bool

+
+
+
+ +
+
+share_with_invite(recipients, require_sign_in=True, send_email=True, message=None, share_type='view')[source]
+

Sends an invitation to access or edit this DriveItem

+
+
Parameters:
+
    +
  • recipients (list[str] or list[Contact] or str or Contact) – a string or Contact or a list of the former +representing recipients of this invitation

  • +
  • require_sign_in (bool) – if True the recipients +invited will need to log in to view the contents

  • +
  • send_email (bool) – if True an email will be send to the recipients

  • +
  • message (str) – the body text of the message emailed

  • +
  • share_type (str) – ‘view’: will allow to read the contents. +‘edit’ will allow to modify the contents

  • +
+
+
Returns:
+

link to share

+
+
Return type:
+

DriveItemPermission

+
+
+
+ +
+ +

Creates or returns a link you can share with others

+
+
Parameters:
+
    +
  • share_type (str) – ‘view’ to allow only view access, +‘edit’ to allow editions, and +‘embed’ to allow the DriveItem to be embedded

  • +
  • share_scope (str) – ‘anonymous’: anyone with the link can access. +‘organization’ Only organization members can access

  • +
  • share_password (str) – sharing link password that is set by the creator. Optional.

  • +
  • share_expiration_date (str) – format of yyyy-MM-dd (e.g., 2022-02-14) that indicates the expiration date of the permission. Optional.

  • +
+
+
Returns:
+

link to share

+
+
Return type:
+

DriveItemPermission

+
+
+
+ +
+
+update(**kwargs)[source]
+

Updates this item

+
+
Parameters:
+

kwargs – all the properties to be updated. +only name and description are allowed at the moment.

+
+
Returns:
+

Success / Failure

+
+
Return type:
+

bool

+
+
+
+ +
+ +
+
+class O365.drive.DriveItemPermission(*, parent=None, con=None, **kwargs)[source]
+

Bases: ApiComponent

+

A Permission representation for a DriveItem

+
+
+__init__(*, parent=None, con=None, **kwargs)[source]
+

Permissions for DriveItem

+
+
Parameters:
+
    +
  • parent (DriveItem) – parent for this operation

  • +
  • con (Connection) – connection to use if no parent specified

  • +
  • protocol (Protocol) – protocol to use if no parent specified +(kwargs)

  • +
  • main_resource (str) – use this resource instead of parent resource +(kwargs)

  • +
+
+
+
+ +
+
+delete()[source]
+

Deletes this permission. Only permissions that are not +inherited can be deleted.

+
+
Returns:
+

Success / Failure

+
+
Return type:
+

bool

+
+
+
+ +
+
+update_roles(roles='view')[source]
+

Updates the roles of this permission

+
+
Returns:
+

Success / Failure

+
+
Return type:
+

bool

+
+
+
+ +
+ +
+
+class O365.drive.DriveItemVersion(*, parent=None, con=None, **kwargs)[source]
+

Bases: ApiComponent, DownloadableMixin

+

A version of a DriveItem

+
+
+__init__(*, parent=None, con=None, **kwargs)[source]
+

Version of DriveItem

+
+
Parameters:
+
    +
  • parent (DriveItem) – parent for this operation

  • +
  • con (Connection) – connection to use if no parent specified

  • +
  • protocol (Protocol) – protocol to use if no parent specified +(kwargs)

  • +
  • main_resource (str) – use this resource instead of parent resource +(kwargs)

  • +
+
+
+
+ +
+
+download(to_path=None, name=None, chunk_size='auto', convert_to_pdf=False)[source]
+

Downloads this version. +You can not download the current version (last one).

+
+
Returns:
+

Success / Failure

+
+
Return type:
+

bool

+
+
+
+ +
+
+restore()[source]
+

Restores this DriveItem Version. +You can not restore the current version (last one).

+
+
Returns:
+

Success / Failure

+
+
Return type:
+

bool

+
+
+
+ +
+ +
+
+class O365.drive.File(**kwargs)[source]
+

Bases: DriveItem, DownloadableMixin

+

A File

+
+
+__init__(**kwargs)[source]
+

Create a DriveItem

+
+
Parameters:
+
    +
  • parent (Drive or drive.Folder) – parent for this operation

  • +
  • con (Connection) – connection to use if no parent specified

  • +
  • protocol (Protocol) – protocol to use if no parent specified +(kwargs)

  • +
  • main_resource (str) – use this resource instead of parent resource +(kwargs)

  • +
+
+
+
+ +
+
+property extension
+
+ +
+ +
+
+class O365.drive.Folder(*args, **kwargs)[source]
+

Bases: DriveItem

+

A Folder inside a Drive

+
+
+__init__(*args, **kwargs)[source]
+

Create a DriveItem

+
+
Parameters:
+
    +
  • parent (Drive or drive.Folder) – parent for this operation

  • +
  • con (Connection) – connection to use if no parent specified

  • +
  • protocol (Protocol) – protocol to use if no parent specified +(kwargs)

  • +
  • main_resource (str) – use this resource instead of parent resource +(kwargs)

  • +
+
+
+
+ +
+
+create_child_folder(name, description=None)[source]
+

Creates a Child Folder

+
+
Parameters:
+
    +
  • name (str) – the name of the new child folder

  • +
  • description (str) – the description of the new child folder

  • +
+
+
Returns:
+

newly created folder

+
+
Return type:
+

drive.Folder

+
+
+
+ +
+
+download_contents(to_folder=None)[source]
+

This will download each file and folder sequentially. +Caution when downloading big folder structures +:param drive.Folder to_folder: folder where to store the contents

+
+ +
+
+get_child_folders(limit=None, *, query=None, order_by=None, batch=None)[source]
+

Returns all the folders inside this folder

+
+
Parameters:
+
    +
  • limit (int) – max no. of folders to get. Over 999 uses batch.

  • +
  • query (Query or str) – applies a OData filter to the request

  • +
  • order_by (Query or str) – orders the result set based on this condition

  • +
  • batch (int) – batch size, retrieves items in +batches allowing to retrieve more items than the limit.

  • +
+
+
Returns:
+

folder items in this folder

+
+
Return type:
+

generator of DriveItem or Pagination

+
+
+
+ +
+
+get_items(limit=None, *, query=None, order_by=None, batch=None)[source]
+

Returns generator all the items inside this folder

+
+
Parameters:
+
    +
  • limit (int) – max no. of folders to get. Over 999 uses batch.

  • +
  • query (Query or str) – applies a OData filter to the request

  • +
  • order_by (Query or str) – orders the result set based on this condition

  • +
  • batch (int) – batch size, retrieves items in +batches allowing to retrieve more items than the limit.

  • +
+
+
Returns:
+

items in this folder

+
+
Return type:
+

generator of DriveItem or Pagination

+
+
+
+ +
+
+search(search_text, limit=None, *, query=None, order_by=None, batch=None)[source]
+

Search for DriveItems under this folder +The search API uses a search service under the covers, +which requires indexing of content.

+

As a result, there will be some time between creation of an item +and when it will appear in search results.

+
+
Parameters:
+
    +
  • search_text (str) – The query text used to search for items. +Values may be matched across several fields including filename, +metadata, and file content.

  • +
  • limit (int) – max no. of folders to get. Over 999 uses batch.

  • +
  • query (Query or str) – applies a OData filter to the request

  • +
  • order_by (Query or str) – orders the result set based on this condition

  • +
  • batch (int) – batch size, retrieves items in +batches allowing to retrieve more items than the limit.

  • +
+
+
Returns:
+

items in this folder matching search

+
+
Return type:
+

generator of DriveItem or Pagination

+
+
+
+ +
+
+upload_file(item, item_name=None, chunk_size=5242880, upload_in_chunks=False, stream=None, stream_size=None, conflict_handling=None, file_created_date_time: str = None, file_last_modified_date_time: str = None)[source]
+

Uploads a file

+
+
Parameters:
+
    +
  • item (str or Path) – path to the item you want to upload

  • +
  • item_name (str or Path) – name of the item on the server. None to use original name

  • +
  • chunk_size – Only applies if file is bigger than 4MB or upload_in_chunks is True. +Chunk size for uploads. Must be a multiple of 327.680 bytes

  • +
  • upload_in_chunks – force the method to upload the file in chunks

  • +
  • stream (io.BufferedIOBase) – (optional) an opened io object to read into. +if set, the to_path and name will be ignored

  • +
  • stream_size (int) – size of stream, required if using stream

  • +
  • conflict_handling (str) – How to handle conflicts. +NOTE: works for chunk upload only (>4MB or upload_in_chunks is True) +None to use default (overwrite). Options: fail | replace | rename

  • +
  • file_created_date_time – allow to force file created date time while uploading

  • +
  • file_last_modified_date_time – allow to force file last modified date time while uploading

  • +
+
+
Returns:
+

uploaded file

+
+
Return type:
+

DriveItem

+
+
+
+ +
+ +
+
+class O365.drive.Image(**kwargs)[source]
+

Bases: File

+

An Image

+
+
+__init__(**kwargs)[source]
+

Create a DriveItem

+
+
Parameters:
+
    +
  • parent (Drive or drive.Folder) – parent for this operation

  • +
  • con (Connection) – connection to use if no parent specified

  • +
  • protocol (Protocol) – protocol to use if no parent specified +(kwargs)

  • +
  • main_resource (str) – use this resource instead of parent resource +(kwargs)

  • +
+
+
+
+ +
+
+property dimensions
+

Dimension of the Image

+
+
Returns:
+

width x height

+
+
Return type:
+

str

+
+
+
+ +
+ +
+
+class O365.drive.Photo(**kwargs)[source]
+

Bases: Image

+

Photo Object. Inherits from Image but has more attributes

+
+
+__init__(**kwargs)[source]
+

Create a DriveItem

+
+
Parameters:
+
    +
  • parent (Drive or drive.Folder) – parent for this operation

  • +
  • con (Connection) – connection to use if no parent specified

  • +
  • protocol (Protocol) – protocol to use if no parent specified +(kwargs)

  • +
  • main_resource (str) – use this resource instead of parent resource +(kwargs)

  • +
+
+
+
+ +
+ +
+
+class O365.drive.Storage(*, parent=None, con=None, **kwargs)[source]
+

Bases: ApiComponent

+

Parent Class that holds drives

+
+
+__init__(*, parent=None, con=None, **kwargs)[source]
+

Create a storage representation

+
+
Parameters:
+
    +
  • parent (Account) – parent for this operation

  • +
  • con (Connection) – connection to use if no parent specified

  • +
  • protocol (Protocol) – protocol to use if no parent specified +(kwargs)

  • +
  • main_resource (str) – use this resource instead of parent resource +(kwargs)

  • +
+
+
+
+ +
+
+drive_constructor
+

alias of Drive

+
+ +
+
+get_default_drive(request_drive=False)[source]
+

Returns a Drive instance

+
+
Parameters:
+

request_drive – True will make an api call to retrieve the drive +data

+
+
Returns:
+

default One Drive

+
+
Return type:
+

Drive

+
+
+
+ +
+
+get_drive(drive_id)[source]
+

Returns a Drive instance

+
+
Parameters:
+

drive_id – the drive_id to be retrieved

+
+
Returns:
+

Drive for the id

+
+
Return type:
+

Drive

+
+
+
+ +
+
+get_drives()[source]
+

Returns a collection of drives

+
+ +
+ +
+ + +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/docs/latest/api/excel.html b/docs/latest/api/excel.html new file mode 100644 index 00000000..7351cadb --- /dev/null +++ b/docs/latest/api/excel.html @@ -0,0 +1,2137 @@ + + + + + + + + + Excel — O365 documentation + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+ +
+
+
+
+ +
+

Excel

+

2019-04-15 +Note: Support for workbooks stored in OneDrive Consumer platform is still not available. +At this time, only the files stored in business platform is supported by Excel REST APIs.

+
+
+exception O365.excel.FunctionException[source]
+
+ +
+
+class O365.excel.NamedRange(parent=None, session=None, **kwargs)[source]
+

Represents a defined name for a range of cells or value

+
+
+__init__(parent=None, session=None, **kwargs)[source]
+

Object initialization

+
+
Parameters:
+
    +
  • protocol (Protocol) – A protocol class or instance to be used with +this connection

  • +
  • main_resource (str) – main_resource to be used in these API +communications

  • +
+
+
+
+ +
+
+comment
+

The comment associated with this name.

   Type: str

+
+ +
+
+data_type
+

The type of reference is associated with the name. +Possible values are: String, Integer, Double, Boolean, Range.

   Type: str

+
+ +
+
+get_range()[source]
+

Returns the Range instance this named range refers to

+
+ +
+
+name
+

The name of the object.

   Type: str

+
+ +
+
+object_id
+

Id of the named range

   Type: str

+
+ +
+
+scope
+

Indicates whether the name is scoped to the workbook or to a specific worksheet. +

   Type: str

+
+ +
+
+update(*, visible=None, comment=None)[source]
+

Updates this named range +:param bool visible: Specifies whether the object is visible or not +:param str comment: Represents the comment associated with this name +:return: Success or Failure

+
+ +
+
+value
+

The formula that the name is defined to refer to. +For example, =Sheet14!$B$2:$H$12 and =4.75.

   Type: str

+
+ +
+
+visible
+

Indicates whether the object is visible.

   Type: bool

+
+ +
+ +
+
+class O365.excel.Range(parent=None, session=None, **kwargs)[source]
+

An Excel Range

+
+
+__init__(parent=None, session=None, **kwargs)[source]
+

Object initialization

+
+
Parameters:
+
    +
  • protocol (Protocol) – A protocol class or instance to be used with +this connection

  • +
  • main_resource (str) – main_resource to be used in these API +communications

  • +
+
+
+
+ +
+
+address
+

Represents the range reference in A1-style. +Address value contains the Sheet reference +(for example, Sheet1!A1:B4).

   Type: str

+
+ +
+
+address_local
+

Represents range reference for the specified range in the language of the user. +

   Type: str

+
+ +
+
+cell_count
+

Number of cells in the range.

   Type: int

+
+ +
+
+clear(apply_to='all')[source]
+

Clear range values, format, fill, border, etc.

+
+
Parameters:
+

apply_to (str) – Optional. Determines the type of clear action. +The possible values are: all, formats, contents.

+
+
+
+ +
+
+column_count
+

Represents the total number of columns in the range.

   Type: int

+
+ +
+
+property column_hidden
+

Indicates whether all columns of the current range are hidden.

+
+
Getter:
+

get the column_hidden

+
+
Setter:
+

set the column_hidden

+
+
Type:
+

bool

+
+
+
+ +
+
+column_index
+

Represents the column number of the first cell in the range. Zero-indexed. +

   Type: int

+
+ +
+
+delete(shift='up')[source]
+

Deletes the cells associated with the range.

+
+
Parameters:
+

shift (str) – Optional. Specifies which way to shift the cells. +The possible values are: up, left.

+
+
+
+ +
+
+property formulas
+

Represents the formula in A1-style notation.

+
+
Getter:
+

get the formulas

+
+
Setter:
+

set the formulas

+
+
Type:
+

any

+
+
+
+ +
+
+property formulas_local
+

Represents the formula in A1-style notation, in the user’s language +and number-formatting locale. For example, the English “=SUM(A1, 1.5)” +formula would become “=SUMME(A1; 1,5)” in German.

+
+
Getter:
+

get the formulas_local

+
+
Setter:
+

set the formulas_local

+
+
Type:
+

list[list]

+
+
+
+ +
+
+property formulas_r1_c1
+

Represents the formula in R1C1-style notation.

+
+
Getter:
+

get the formulas_r1_c1

+
+
Setter:
+

set the formulas_r1_c1

+
+
Type:
+

list[list]

+
+
+
+ +
+
+get_bounding_rect(address)[source]
+

Gets the smallest range object that encompasses the given ranges. +For example, the GetBoundingRect of “B2:C5” and “D10:E15” is “B2:E16”. +:param str address: another address to retrieve it’s bounding rect

+
+ +
+
+get_cell(row, column)[source]
+

Gets the range object containing the single cell based on row and column numbers. +:param int row: the row number +:param int column: the column number +:return: a Range instance

+
+ +
+
+get_column(index)[source]
+

Returns a column whitin the range +:param int index: the index of the column. zero indexed +:return: a Range

+
+ +
+
+get_columns_after(columns=1)[source]
+

Gets a certain number of columns to the right of the given range. +:param int columns: Optional. The number of columns to include in the resulting range.

+
+ +
+
+get_columns_before(columns=1)[source]
+

Gets a certain number of columns to the left of the given range. +:param int columns: Optional. The number of columns to include in the resulting range.

+
+ +
+
+get_entire_column()[source]
+

Gets a Range that represents the entire column of the range.

+
+ +
+
+get_format()[source]
+

Returns a RangeFormat instance with the format of this range

+
+ +
+
+get_intersection(address)[source]
+

Gets the Range that represents the rectangular intersection of the given ranges.

+
+
Parameters:
+

address – the address range you want ot intersect with.

+
+
Returns:
+

Range

+
+
+
+ +
+
+get_last_cell()[source]
+

Gets the last cell within the range.

+
+ +
+
+get_last_column()[source]
+

Gets the last column within the range.

+
+ +
+
+get_last_row()[source]
+

Gets the last row within the range.

+
+ +
+
+get_offset_range(row_offset, column_offset)[source]
+
+
Gets an object which represents a range that’s offset from the specified range.

The dimension of the returned range will match this range. +If the resulting range is forced outside the bounds of the worksheet grid, +an exception will be thrown.

+
+
+
+
Parameters:
+
    +
  • row_offset (int) – The number of rows (positive, negative, or 0) +by which the range is to be offset.

  • +
  • column_offset (int) – he number of columns (positive, negative, or 0) +by which the range is to be offset.

  • +
+
+
Returns:
+

Range

+
+
+
+ +
+
+get_resized_range(rows, columns)[source]
+

Gets a range object similar to the current range object, +but with its bottom-right corner expanded (or contracted) +by some number of rows and columns.

+
+
Parameters:
+
    +
  • rows (int) – The number of rows by which to expand the +bottom-right corner, relative to the current range.

  • +
  • columns (int) – The number of columns by which to expand the +bottom-right corner, relative to the current range.

  • +
+
+
Returns:
+

Range

+
+
+
+ +
+
+get_row(index)[source]
+

Gets a row contained in the range. +:param int index: Row number of the range to be retrieved. +:return: Range

+
+ +
+
+get_rows_above(rows=1)[source]
+

Gets a certain number of rows above a given range.

+
+
Parameters:
+

rows (int) – Optional. The number of rows to include in the resulting range.

+
+
Returns:
+

Range

+
+
+
+ +
+
+get_rows_below(rows=1)[source]
+

Gets a certain number of rows below a given range.

+
+
Parameters:
+

rows (int) – Optional. The number of rows to include in the resulting range.

+
+
Returns:
+

Range

+
+
+
+ +
+
+get_used_range(only_values=True)[source]
+

Returns the used range of the given range object.

+
+
Parameters:
+

only_values (bool) – Optional. Defaults to True. +Considers only cells with values as used cells (ignores formatting).

+
+
Returns:
+

Range

+
+
+
+ +
+
+get_worksheet()[source]
+

Returns this range worksheet

+
+ +
+
+hidden
+

Represents if all cells of the current range are hidden.

   Type: bool

+
+ +
+
+insert_range(shift)[source]
+

Inserts a cell or a range of cells into the worksheet in place of this range, +and shifts the other cells to make space.

+
+
Parameters:
+

shift (str) – Specifies which way to shift the cells. The possible values are: down, right.

+
+
Returns:
+

new Range instance at the now blank space

+
+
+
+ +
+
+merge(across=False)[source]
+

Merge the range cells into one region in the worksheet.

+
+
Parameters:
+

across (bool) – Optional. Set True to merge cells in each row of the +specified range as separate merged cells.

+
+
+
+ +
+
+property number_format
+

Represents Excel’s number format code for the given cell.

+
+
Getter:
+

get the number_format

+
+
Setter:
+

set the number_fromat

+
+
Type:
+

list[list]

+
+
+
+ +
+
+object_id
+

The id of the range.

   Type: str

+
+ +
+
+row_count
+

Returns the total number of rows in the range.

   Type: int

+
+ +
+
+property row_hidden
+

Indicates whether all rows of the current range are hidden.

+
+
Getter:
+

get the row_hidden

+
+
Setter:
+

set the row_hidden

+
+
Type:
+

bool

+
+
+
+ +
+
+row_index
+

Returns the row number of the first cell in the range. Zero-indexed. +

   Type: int

+
+ +
+
+text
+

Text values of the specified range.

   Type: str

+
+ +
+
+to_api_data(restrict_keys=None)[source]
+

Returns a dict to communicate with the server

+
+
Parameters:
+

restrict_keys – a set of keys to restrict the returned data to

+
+
Return type:
+

dict

+
+
+
+ +
+
+unmerge()[source]
+

Unmerge the range cells into separate cells.

+
+ +
+
+update()[source]
+

Update this range

+
+ +
+
+value_types
+

Represents the type of data of each cell. +The possible values are: Unknown, Empty, String, +Integer, Double, Boolean, Error.

   Type: list[list]

+
+ +
+
+property values
+

Represents the raw values of the specified range. +The data returned can be of type string, number, or a Boolean. +Cell that contains an error returns the error string.

+
+
Getter:
+

get the number_format

+
+
Setter:
+

set the number_fromat

+
+
Type:
+

list[list]

+
+
+
+ +
+ +
+
+class O365.excel.RangeFormat(parent=None, session=None, **kwargs)[source]
+

A format applied to a range

+
+
+__init__(parent=None, session=None, **kwargs)[source]
+

Object initialization

+
+
Parameters:
+
    +
  • protocol (Protocol) – A protocol class or instance to be used with +this connection

  • +
  • main_resource (str) – main_resource to be used in these API +communications

  • +
+
+
+
+ +
+
+auto_fit_columns()[source]
+

Changes the width of the columns of the current range +to achieve the best fit, based on the current data in the columns

+
+ +
+
+auto_fit_rows()[source]
+

Changes the width of the rows of the current range +to achieve the best fit, based on the current data in the rows

+
+ +
+
+property background_color
+

The background color of the range

+
+
Getter:
+

get the background_color

+
+
Setter:
+

set the background_color

+
+
Type:
+

UnsentSentinel

+
+
+
+ +
+
+property column_width
+

The width of all columns within the range

+
+
Getter:
+

get the column_width

+
+
Setter:
+

set the column_width

+
+
Type:
+

float

+
+
+
+ +
+
+property font
+

Returns the font object defined on the overall range selected

+
+
Getter:
+

get the font

+
+
Setter:
+

set the font

+
+
Type:
+

RangeFormatFont

+
+
+
+ +
+
+property horizontal_alignment
+

The horizontal alignment for the specified object. +Possible values are: General, Left, Center, Right, Fill, Justify, +CenterAcrossSelection, Distributed.

+
+
Getter:
+

get the vertical_alignment

+
+
Setter:
+

set the vertical_alignment

+
+
Type:
+

string

+
+
+
+ +
+
+range
+

The range of the range format.

   Type: range

+
+ +
+
+property row_height
+

The height of all rows in the range.

+
+
Getter:
+

get the row_height

+
+
Setter:
+

set the row_height

+
+
Type:
+

float

+
+
+
+ +
+
+session
+

The session for the range format.

   Type: str

+
+ +
+
+set_borders(side_style='')[source]
+

Sets the border of this range

+
+ +
+
+to_api_data(restrict_keys=None)[source]
+

Returns a dict to communicate with the server

+
+
Parameters:
+

restrict_keys – a set of keys to restrict the returned data to

+
+
Return type:
+

dict

+
+
+
+ +
+
+update()[source]
+

Updates this range format

+
+ +
+
+property vertical_alignment
+

The vertical alignment for the specified object. +Possible values are: Top, Center, Bottom, Justify, Distributed.

+
+
Getter:
+

get the vertical_alignment

+
+
Setter:
+

set the vertical_alignment

+
+
Type:
+

string

+
+
+
+ +
+
+property wrap_text
+

Indicates whether Excel wraps the text in the object

+
+
Getter:
+

get the wrap_text

+
+
Setter:
+

set the wrap_text

+
+
Type:
+

bool

+
+
+
+ +
+ +
+
+class O365.excel.RangeFormatFont(parent)[source]
+

A font format applied to a range

+
+
+__init__(parent)[source]
+
+ +
+
+property bold
+
+ +
+
+property color
+

The color of the range format font

+
+
Getter:
+

get the color

+
+
Setter:
+

set the color

+
+
Type:
+

str

+
+
+
+ +
+
+property italic
+

Is range format font in italics

+
+
Getter:
+

get the italic

+
+
Setter:
+

set the italic

+
+
Type:
+

bool

+
+
+
+ +
+
+property name
+

The name of the range format font

+
+
Getter:
+

get the name

+
+
Setter:
+

set the name

+
+
Type:
+

str

+
+
+
+ +
+
+parent
+

The parent of the range format font.

   Type: parent

+
+ +
+
+property size
+

The size of the range format font

+
+
Getter:
+

get the size

+
+
Setter:
+

set the size

+
+
Type:
+

int

+
+
+
+ +
+
+to_api_data(restrict_keys=None)[source]
+

Returns a dict to communicate with the server

+
+
Parameters:
+

restrict_keys – a set of keys to restrict the returned data to

+
+
Return type:
+

dict

+
+
+
+ +
+
+property underline
+

Is range format font underlined

+
+
Getter:
+

get the underline

+
+
Setter:
+

set the underline

+
+
Type:
+

bool

+
+
+
+ +
+ +
+
+class O365.excel.Table(parent=None, session=None, **kwargs)[source]
+

An Excel Table

+
+
+__init__(parent=None, session=None, **kwargs)[source]
+

Object initialization

+
+
Parameters:
+
    +
  • protocol (Protocol) – A protocol class or instance to be used with +this connection

  • +
  • main_resource (str) – main_resource to be used in these API +communications

  • +
+
+
+
+ +
+
+add_column(name, *, index=0, values=None)[source]
+

Adds a column to the table +:param str name: the name of the column +:param int index: the index at which the column should be added. Defaults to 0. +:param list values: a two dimension array of values to add to the column

+
+ +
+
+add_rows(values=None, index=None)[source]
+

Add rows to this table.

+
+

Multiple rows can be added at once. +This request might occasionally receive a 504 HTTP error. +The appropriate response to this error is to repeat the request.

+
+
+
Parameters:
+
    +
  • values (list) – Optional. a 1 or 2 dimensional array of values to add

  • +
  • index (int) – Optional. Specifies the relative position of the new row. +If null, the addition happens at the end.

  • +
+
+
Returns:
+

+
+
+
+ +
+
+clear_filters()[source]
+

Clears all the filters currently applied on the table.

+
+ +
+
+convert_to_range()[source]
+

Converts the table into a normal range of cells. All data is preserved.

+
+ +
+
+delete()[source]
+

Deletes this table

+
+ +
+
+delete_column(id_or_name)[source]
+

Deletes a Column by its id or name +:param id_or_name: the id or name of the column +:return bool: Success or Failure

+
+ +
+
+delete_row(index)[source]
+

Deletes a Row by it’s index +:param int index: the index of the row. zero indexed +:return bool: Success or Failure

+
+ +
+
+get_column(id_or_name)[source]
+

Gets a column from this table by id or name +:param id_or_name: the id or name of the column +:return: WorkBookTableColumn

+
+ +
+
+get_column_at_index(index)[source]
+

Returns a table column by it’s index +:param int index: the zero-indexed position of the column in the table

+
+ +
+
+get_columns(*, top=None, skip=None)[source]
+

Return the columns of this table +:param int top: specify n columns to retrieve +:param int skip: specify n columns to skip

+
+ +
+
+get_data_body_range()[source]
+

Gets the range object associated with the data body of the table

+
+ +
+
+get_header_row_range()[source]
+

Gets the range object associated with the header row of the table

+
+ +
+
+get_range()[source]
+

Gets the range object associated with the entire table

+
+ +
+
+get_row(index)[source]
+

Returns a Row instance at an index

+
+ +
+
+get_row_at_index(index)[source]
+

Returns a table row by it’s index +:param int index: the zero-indexed position of the row in the table

+
+ +
+
+get_rows(*, top=None, skip=None)[source]
+

Return the rows of this table +:param int top: specify n rows to retrieve +:param int skip: specify n rows to skip +:rtype: TableRow

+
+ +
+
+get_total_row_range()[source]
+

Gets the range object associated with the totals row of the table

+
+ +
+
+get_worksheet()[source]
+

Returns this table worksheet

+
+ +
+
+highlight_first_column
+

Indicates whether the first column contains special formatting.

   Type: bool

+
+ +
+
+highlight_last_column
+

Indicates whether the last column contains special formatting.

   Type: bool

+
+ +
+
+legacy_id
+

A legacy identifier used in older Excel clients.

   Type: str

+
+ +
+
+name
+

The name of the table.

   Type: str

+
+ +
+
+object_id
+

The unique identifier for the table in the workbook.

   Type: str

+
+ +
+
+parent
+

Parent of the table.

   Type: parent

+
+ +
+
+reapply_filters()[source]
+

Reapplies all the filters currently on the table.

+
+ +
+
+session
+

Session of the table.

   Type: session

+
+ +
+
+show_banded_columns
+

Indicates whether the columns show banded formatting in which odd columns +are highlighted differently from even ones to make reading the table easier. +

   Type: bool

+
+ +
+
+show_banded_rows
+

The name of the table column.

   Type: str

+
+ +
+
+show_filter_button
+

Indicates whether the rows show banded formatting in which odd rows +are highlighted differently from even ones to make reading the table easier. +

   Type: bool

+
+ +
+
+show_headers
+

Indicates whether the header row is visible or not

   Type: bool

+
+ +
+
+show_totals
+

Indicates whether the total row is visible or not.

   Type: bool

+
+ +
+
+style
+

A constant value that represents the Table style

   Type: str

+
+ +
+
+update(*, name=None, show_headers=None, show_totals=None, style=None)[source]
+

Updates this table +:param str name: the name of the table +:param bool show_headers: whether or not to show the headers +:param bool show_totals: whether or not to show the totals +:param str style: the style of the table +:return: Success or Failure

+
+ +
+ +
+
+class O365.excel.TableColumn(parent=None, session=None, **kwargs)[source]
+

An Excel Table Column

+
+
+__init__(parent=None, session=None, **kwargs)[source]
+

Object initialization

+
+
Parameters:
+
    +
  • protocol (Protocol) – A protocol class or instance to be used with +this connection

  • +
  • main_resource (str) – main_resource to be used in these API +communications

  • +
+
+
+
+ +
+
+apply_filter(criteria)[source]
+

Apply the given filter criteria on the given column.

+
+
Parameters:
+

criteria (str) –

the criteria to apply

+

Example:

+
{
+    "color": "string",
+    "criterion1": "string",
+    "criterion2": "string",
+    "dynamicCriteria": "string",
+    "filterOn": "string",
+    "icon": {"@odata.type": "microsoft.graph.workbookIcon"},
+    "values": {"@odata.type": "microsoft.graph.Json"}
+}
+
+
+

+
+
+
+ +
+
+clear_filter()[source]
+

Clears the filter applied to this column

+
+ +
+
+delete()[source]
+

Deletes this table Column

+
+ +
+
+get_data_body_range()[source]
+

Gets the range object associated with the data body of the column

+
+ +
+
+get_filter()[source]
+

Returns the filter applie to this column

+
+ +
+
+get_header_row_range()[source]
+

Gets the range object associated with the header row of the column

+
+ +
+
+get_range()[source]
+

Gets the range object associated with the entire column

+
+ +
+
+get_total_row_range()[source]
+

Gets the range object associated with the totals row of the column

+
+ +
+
+index
+

TThe index of the column within the columns collection of the table. Zero-indexed. +

   Type: int

+
+ +
+
+name
+

The name of the table column.

   Type: str

+
+ +
+
+object_id
+

Id of the Table Column|br| Type: str

+
+ +
+
+session
+

session of the table column..

   Type: session

+
+ +
+
+table
+

Parent of the table column.

   Type: parent

+
+ +
+
+update(values)[source]
+

Updates this column +:param values: values to update

+
+ +
+
+values
+

Represents the raw values of the specified range. +The data returned could be of type string, number, or a Boolean. +Cell that contain an error will return the error string.

   Type: list[list]

+
+ +
+ +
+
+class O365.excel.TableRow(parent=None, session=None, **kwargs)[source]
+

An Excel Table Row

+
+
+__init__(parent=None, session=None, **kwargs)[source]
+

Object initialization

+
+
Parameters:
+
    +
  • protocol (Protocol) – A protocol class or instance to be used with +this connection

  • +
  • main_resource (str) – main_resource to be used in these API +communications

  • +
+
+
+
+ +
+
+delete()[source]
+

Deletes this row

+
+ +
+
+get_range()[source]
+

Gets the range object associated with the entire row

+
+ +
+
+index
+

The index of the row within the rows collection of the table. Zero-based. +

   Type: int

+
+ +
+
+object_id
+

Id of the Table Row

   Type: str

+
+ +
+
+session
+

Session of table row

   Type: session

+
+ +
+
+table
+

Parent of the table row.

   Type: parent

+
+ +
+
+update(values)[source]
+

Updates this row

+
+ +
+
+values
+

The raw values of the specified range. +The data returned could be of type string, number, or a Boolean. +Any cell that contain an error will return the error string. +

   Type: list[list]

+
+ +
+ +
+
+class O365.excel.WorkBook(file_item, *, use_session=True, persist=True)[source]
+
+
+__init__(file_item, *, use_session=True, persist=True)[source]
+

Create a workbook representation

+
+
Parameters:
+
    +
  • file_item (File) – the Drive File you want to interact with

  • +
  • use_session (Bool) – Whether or not to use a session to be more efficient

  • +
  • persist (Bool) – Whether or not to persist this info

  • +
+
+
+
+ +
+
+add_named_range(name, reference, comment='', is_formula=False)[source]
+

Adds a new name to the collection of the given scope using the user’s locale for the formula +:param str name: the name of this range +:param str reference: the reference for this range or formula +:param str comment: a comment to describe this named range +:param bool is_formula: True if the reference is a formula +:return: NamedRange instance

+
+ +
+
+add_worksheet(name=None)[source]
+

Adds a new worksheet

+
+ +
+
+delete_worksheet(worksheet_id)[source]
+

Deletes a worksheet by it’s id

+
+ +
+
+get_named_range(name)[source]
+

Retrieves a Named range by it’s name

+
+ +
+
+get_named_ranges()[source]
+

Returns the list of named ranges for this Workbook

+
+ +
+
+get_table(id_or_name)[source]
+

Retrieves a Table by id or name +:param str id_or_name: The id or name of the column +:return: a Table instance

+
+ +
+
+get_tables()[source]
+

Returns a collection of this workbook tables

+
+ +
+
+get_workbookapplication()[source]
+
+ +
+
+get_worksheet(id_or_name)[source]
+

Gets a specific worksheet by id or name

+
+ +
+
+get_worksheets()[source]
+

Returns a collection of this workbook worksheets

+
+ +
+
+invoke_function(function_name, **function_params)[source]
+

Invokes an Excel Function

+
+ +
+
+name
+

The name of the workbook.

   Type:**str

+
+ +
+
+object_id
+

The id of the workbook.

   Type: str**

+
+ +
+
+session
+

The session for the workbook.

   Type: WorkbookSession

+
+ +
+ +
+
+class O365.excel.WorkSheet(parent=None, session=None, **kwargs)[source]
+

An Excel WorkSheet

+
+
+__init__(parent=None, session=None, **kwargs)[source]
+

Object initialization

+
+
Parameters:
+
    +
  • protocol (Protocol) – A protocol class or instance to be used with +this connection

  • +
  • main_resource (str) – main_resource to be used in these API +communications

  • +
+
+
+
+ +
+
+add_named_range(name, reference, comment='', is_formula=False)[source]
+

Adds a new name to the collection of the given scope using the user’s locale for the formula +:param str name: the name of this range +:param str reference: the reference for this range or formula +:param str comment: a comment to describe this named range +:param bool is_formula: True if the reference is a formula +:return: NamedRange instance

+
+ +
+
+add_table(address, has_headers)[source]
+

Adds a table to this worksheet +:param str address: a range address eg: ‘A1:D4’ +:param bool has_headers: if the range address includes headers or not +:return: a Table instance

+
+ +
+
+append_rows(rows)[source]
+

Appends rows to the end of a worksheet. There is no direct Graph API to do this operation without a Table +instance. Instead, this method identifies the last row in the worksheet and requests a range after that row +and updates that range.

+

Beware! If you open your workbook from sharepoint and delete all of the rows in one go and attempt to append +new rows, you will get undefined behavior from the Microsoft Graph API. I don’t know if I did not give enough +time for the backend to synchronize from the moment of deletion on my browser and the moment I triggered my +script, but this is something I have observed. Sometimes insertion fails and sometimes it inserts where the new +row would have been if data had not been deleted from the browser side. Maybe it is an API cache issue. However, +after the first row is inserted successfully, this undefined behavior goes away on repeat calls to my scripts. +Documenting this behavior for future consumers of this API.

+
+
Parameters:
+

rows (list[list[str]]) – list of rows to push to this range. If updating a single cell, pass a list +containing a single row (list) containing a single cell worth of data.

+
+
+
+ +
+
+delete()[source]
+

Deletes this worksheet

+
+ +
+
+get_cell(row, column)[source]
+

Gets the range object containing the single cell based on row and column numbers.

+
+ +
+
+get_named_range(name)[source]
+

Retrieves a Named range by it’s name

+
+ +
+
+get_range(address=None)[source]
+

Returns a Range instance from whitin this worksheet +:param str address: Optional, the range address you want +:return: a Range instance

+
+ +
+
+get_table(id_or_name)[source]
+

Retrieves a Table by id or name +:param str id_or_name: The id or name of the column +:return: a Table instance

+
+ +
+
+get_tables()[source]
+

Returns a collection of this worksheet tables

+
+ +
+
+get_used_range(only_values=True)[source]
+

Returns the smallest range that encompasses any cells that +have a value or formatting assigned to them.

+
+
Parameters:
+

only_values (bool) – Optional. Defaults to True. +Considers only cells with values as used cells (ignores formatting).

+
+
Returns:
+

Range

+
+
+
+ +
+
+name
+

The display name of the worksheet.

   Type: str

+
+ +
+
+object_id
+

The unique identifier for the worksheet in the workbook.

   Type: str

+
+ +
+
+position
+

The zero-based position of the worksheet within the workbook.

   Type: int

+
+ +
+
+static remove_sheet_name_from_address(address)[source]
+

Removes the sheet name from a given address

+
+ +
+
+session
+

Thesession of the worksheet.

   Type: session

+
+ +
+
+update(*, name=None, position=None, visibility=None)[source]
+

Changes the name, position or visibility of this worksheet

+
+ +
+
+update_cells(address, rows)[source]
+

Updates the cells at a given range in this worksheet. This is a convenience method since there is no +direct endpoint API for tableless row updates. +:param str|Range address: the address to resolve to a range which can be used for updating cells. +:param list[list[str]] rows: list of rows to push to this range. If updating a single cell, pass a list +containing a single row (list) containing a single cell worth of data.

+
+ +
+
+visibility
+

The visibility of the worksheet. +The possible values are: Visible, Hidden, VeryHidden.

   Type: str

+
+ +
+
+workbook
+

The parent of the worksheet.

   Type: parent

+
+ +
+ +
+
+class O365.excel.WorkbookApplication(workbook)[source]
+
+
+__init__(workbook)[source]
+

Create A WorkbookApplication representation

+
+
Parameters:
+

workbook – A workbook object, of the workboook that you want to interact with

+
+
+
+ +
+
+get_details()[source]
+

Gets workbookApplication

+
+ +
+
+parent
+

The application parent.

   Type: Workbook

+
+ +
+
+run_calculations(calculation_type)[source]
+

Recalculate all currently opened workbooks in Excel.

+
+ +
+ +
+
+class O365.excel.WorkbookSession(*, parent=None, con=None, persist=True, **kwargs)[source]
+

See https://docs.microsoft.com/en-us/graph/api/resources/excel?view=graph-rest-1.0#sessions-and-persistence

+
+
+__init__(*, parent=None, con=None, persist=True, **kwargs)[source]
+

Create a workbook session object.

+
+
Parameters:
+
    +
  • parent – parent for this operation

  • +
  • con (Connection) – connection to use if no parent specified

  • +
  • persist (Bool) – Whether or not to persist the session changes

  • +
+
+
+
+ +
+
+close_session()[source]
+

Close the current session

+
+ +
+
+create_session()[source]
+

Request a new session id

+
+ +
+
+delete(*args, **kwargs)[source]
+
+ +
+
+get(*args, **kwargs)[source]
+
+ +
+
+inactivity_limit
+

The inactivity limit.

   Type: timedelta

+
+ +
+
+last_activity
+

The time of last activity.

   Type: datetime

+
+ +
+
+patch(*args, **kwargs)[source]
+
+ +
+
+persist
+

Whether or not the session changes are persisted.

   Type: bool

+
+ +
+
+post(*args, **kwargs)[source]
+
+ +
+
+prepare_request(kwargs)[source]
+

If session is in use, prepares the request headers and +checks if the session is expired.

+
+ +
+
+put(*args, **kwargs)[source]
+
+ +
+
+refresh_session()[source]
+

Refresh the current session id

+
+ +
+
+session_id
+

The session id.

   Type: str

+
+ +
+ +
+ + +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/docs/latest/api/global.html b/docs/latest/api/global.html new file mode 100644 index 00000000..7a845bfd --- /dev/null +++ b/docs/latest/api/global.html @@ -0,0 +1,106 @@ + + + + + + + + + <no title> — O365 documentation + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+ +
+
+
+
+ + + +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/docs/latest/api/group.html b/docs/latest/api/group.html new file mode 100644 index 00000000..dc748d4e --- /dev/null +++ b/docs/latest/api/group.html @@ -0,0 +1,331 @@ + + + + + + + + + Group — O365 documentation + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+ +
+
+
+
+ +
+

Group

+
+
+class O365.groups.Group(*, parent=None, con=None, **kwargs)[source]
+

Bases: ApiComponent

+

A Microsoft 365 group

+
+
+__init__(*, parent=None, con=None, **kwargs)[source]
+

A Microsoft 365 group

+
+
Parameters:
+
    +
  • parent (Teams) – parent object

  • +
  • con (Connection) – connection to use if no parent specified

  • +
  • protocol (Protocol) – protocol to use if no parent specified +(kwargs)

  • +
  • main_resource (str) – use this resource instead of parent resource +(kwargs)

  • +
+
+
+
+ +
+
+get_group_members(recursive=False)[source]
+

Returns members of given group +:param bool recursive: drill down to users if group has other group as a member +:rtype: list[User]

+
+ +
+
+get_group_owners()[source]
+

Returns owners of given group

+
+
Return type:
+

list[User]

+
+
+
+ +
+
+description
+

An optional description for the group.

   Type: str

+
+ +
+
+display_name
+

The display name for the group.

   Type: str

+
+ +
+
+mail
+

The SMTP address for the group, for example, “serviceadmins@contoso.com”.

   Type: str

+
+ +
+
+mail_nickname
+

The mail alias for the group, unique for Microsoft 365 groups in the organization.

   Type: str

+
+ +
+
+object_id
+

The unique identifier for the group.

   Type: str

+
+ +
+
+type
+

The group type.

   Type: str

+
+ +
+
+visibility
+

Specifies the group join policy and group content visibility for groups.

   Type: str

+
+ +
+ +
+
+class O365.groups.Groups(*, parent=None, con=None, **kwargs)[source]
+

Bases: ApiComponent

+

A microsoft groups class +In order to use the API following permissions are required. +Delegated (work or school account) - Group.Read.All, Group.ReadWrite.All

+
+
+__init__(*, parent=None, con=None, **kwargs)[source]
+

A Teams object

+
+
Parameters:
+
    +
  • parent (Account) – parent object

  • +
  • con (Connection) – connection to use if no parent specified

  • +
  • protocol (Protocol) – protocol to use if no parent specified +(kwargs)

  • +
  • main_resource (str) – use this resource instead of parent resource +(kwargs)

  • +
+
+
+
+ +
+
+get_group_by_id(group_id=None)[source]
+

Returns Microsoft 365/AD group with given id

+
+
Parameters:
+

group_id – group id of group

+
+
Return type:
+

Group

+
+
+
+ +
+
+get_group_by_mail(group_mail=None)[source]
+

Returns Microsoft 365/AD group by mail field

+
+
Parameters:
+

group_name – mail of group

+
+
Return type:
+

Group

+
+
+
+ +
+
+get_user_groups(user_id=None, limit=None, batch=None)[source]
+

Returns list of groups that given user has membership

+
+
Parameters:
+
    +
  • user_id – user_id

  • +
  • limit (int) – max no. of groups to get. Over 999 uses batch.

  • +
  • batch (int) – batch size, retrieves items in +batches allowing to retrieve more items than the limit.

  • +
+
+
Return type:
+

list[Group] or Pagination

+
+
+
+ +
+
+list_groups()[source]
+

Returns list of groups

+
+
Return type:
+

list[Group]

+
+
+
+ +
+ +
+ + +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/docs/latest/api/mailbox.html b/docs/latest/api/mailbox.html new file mode 100644 index 00000000..af3bd7af --- /dev/null +++ b/docs/latest/api/mailbox.html @@ -0,0 +1,1020 @@ + + + + + + + + + Mailbox — O365 documentation + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+ +
+
+
+
+ +
+

Mailbox

+
+
+class O365.mailbox.AutoReplyStatus(*values)[source]
+

Bases: Enum

+

Valid values for status.

+
+
+ALWAYSENABLED = 'alwaysEnabled'
+
+ +
+
+DISABLED = 'disabled'
+
+ +
+
+SCHEDULED = 'scheduled'
+
+ +
+ +
+
+class O365.mailbox.AutomaticRepliesSettings(*, parent=None, con=None, **kwargs)[source]
+

Bases: ApiComponent

+

The AutomaticRepliesSettingss.

+
+
+__init__(*, parent=None, con=None, **kwargs)[source]
+

Representation of the AutomaticRepliesSettings.

+
+
Parameters:
+
    +
  • parent (Mailbox) – parent object

  • +
  • con (Connection) – connection to use if no parent specified

  • +
  • protocol (Protocol) – protocol to use if no parent specified +(kwargs)

  • +
  • main_resource (str) – use this resource instead of parent resource +(kwargs)

  • +
+
+
+
+ +
+
+property external_audience: ExternalAudience
+

External Audience of auto reply.

+
+
Getter:
+

get the external audience of auto reply

+
+
Setter:
+

set the external audience of auto reply

+
+
Type:
+

autoreplystatus

+
+
+
+ +
+
+external_reply_message
+

The automatic reply to send to the specified external audience, +if Status is AlwaysEnabled or Scheduled.

   Type: str

+
+ +
+
+internal_reply_message
+

The automatic reply to send to the audience internal to the signed-in user’s +organization, if Status is AlwaysEnabled or Scheduled.

   Type: str

+
+ +
+
+property scheduled_enddatetime
+

Scheduled End Time of auto reply.

+
+
Getter:
+

get the scheduled_enddatetime time

+
+
Setter:
+

set the reminder time

+
+
Type:
+

datetime

+
+
+
+ +
+
+property scheduled_startdatetime
+

Scheduled Start Time of auto reply.

+
+
Getter:
+

get the scheduled_startdatetime time

+
+
Setter:
+

set the scheduled_startdatetime time

+
+
Type:
+

datetime

+
+
+
+ +
+
+property status: AutoReplyStatus
+

Status of auto reply.

+
+
Getter:
+

get the status of auto reply

+
+
Setter:
+

set the status of auto reply

+
+
Type:
+

autoreplystatus

+
+
+
+ +
+ +
+
+class O365.mailbox.ExternalAudience(*values)[source]
+

Bases: Enum

+

Valid values for externalAudience.

+
+
+ALL = 'all'
+
+ +
+
+CONTACTSONLY = 'contactsOnly'
+
+ +
+
+NONE = 'none'
+
+ +
+ +
+
+class O365.mailbox.Folder(*, parent=None, con=None, **kwargs)[source]
+

Bases: ApiComponent

+

A Mail Folder representation.

+
+
+__init__(*, parent=None, con=None, **kwargs)[source]
+

Create an instance to represent the specified folder in given +parent folder

+
+
Parameters:
+
    +
  • parent (mailbox.Folder or Account) – parent folder/account for this folder

  • +
  • con (Connection) – connection to use if no parent specified

  • +
  • protocol (Protocol) – protocol to use if no parent specified +(kwargs)

  • +
  • main_resource (str) – use this resource instead of parent resource +(kwargs)

  • +
  • name (str) – name of the folder to get under the parent (kwargs)

  • +
  • folder_id (str) – id of the folder to get under the parent (kwargs)

  • +
+
+
+
+ +
+
+copy_folder(to_folder)[source]
+

Copy this folder and it’s contents to into another folder

+
+
Parameters:
+

to_folder (mailbox.Folder or str) – the destination Folder/folder_id to copy into

+
+
Returns:
+

The new folder after copying

+
+
Return type:
+

mailbox.Folder or None

+
+
+
+ +
+
+create_child_folder(folder_name)[source]
+

Creates a new child folder under this folder

+
+
Parameters:
+

folder_name (str) – name of the folder to add

+
+
Returns:
+

newly created folder

+
+
Return type:
+

mailbox.Folder or None

+
+
+
+ +
+
+delete()[source]
+

Deletes this folder

+
+
Returns:
+

Deleted or Not

+
+
Return type:
+

bool

+
+
+
+ +
+
+delete_message(message)[source]
+

Deletes a stored message

+
+
Parameters:
+

message (Message or str) – message/message_id to delete

+
+
Returns:
+

Success / Failure

+
+
Return type:
+

bool

+
+
+
+ +
+
+get_folder(*, folder_id=None, folder_name=None)[source]
+

Get a folder by it’s id or name

+
+
Parameters:
+
    +
  • folder_id (str) – the folder_id to be retrieved. +Can be any folder Id (child or not)

  • +
  • folder_name (str) – the folder name to be retrieved. +Must be a child of this folder.

  • +
+
+
Returns:
+

a single folder

+
+
Return type:
+

mailbox.Folder or None

+
+
+
+ +
+
+get_folders(limit=None, *, query=None, order_by=None, batch=None)[source]
+

Return a list of child folders matching the query.

+
+
Parameters:
+
    +
  • limit (int) – max no. of folders to get. Over 999 uses batch.

  • +
  • query (Query or str) – applies a filter to the request such as +“displayName eq ‘HelloFolder’”

  • +
  • order_by (Query or str) – orders the result set based on this condition

  • +
  • batch (int) – batch size, retrieves items in +batches allowing to retrieve more items than the limit.

  • +
+
+
Returns:
+

list of folders

+
+
Return type:
+

list[mailbox.Folder] or Pagination

+
+
+
+ +
+
+get_message(object_id=None, query=None, *, download_attachments=False)[source]
+

Get one message from the query result. +A shortcut to get_messages with limit=1

+
+
Parameters:
+
    +
  • object_id – the message id to be retrieved.

  • +
  • query (Query or str) – applies a filter to the request such as +“displayName eq ‘HelloFolder’”

  • +
  • download_attachments (bool) – whether or not to download attachments

  • +
+
+
Returns:
+

one Message

+
+
Return type:
+

Message or None

+
+
+
+ +
+
+get_messages(limit=25, *, query=None, order_by=None, batch=None, download_attachments=False)[source]
+

Downloads messages from this folder

+
+
Parameters:
+
    +
  • limit (int) – limits the result set. Over 999 uses batch.

  • +
  • query (Query or str) – applies a filter to the request such as +“displayName eq ‘HelloFolder’”

  • +
  • order_by (Query or str) – orders the result set based on this condition

  • +
  • batch (int) – batch size, retrieves items in +batches allowing to retrieve more items than the limit.

  • +
  • download_attachments (bool) – whether or not to download attachments

  • +
+
+
Returns:
+

list of messages

+
+
Return type:
+

list[Message] or Pagination

+
+
+
+ +
+
+get_parent_folder()[source]
+

Get the parent folder from attribute self.parent or +getting it from the cloud

+
+
Returns:
+

Parent Folder

+
+
Return type:
+

mailbox.Folder or None

+
+
+
+ +
+
+move_folder(to_folder, *, update_parent_if_changed=True)[source]
+

Move this folder to another folder

+
+
Parameters:
+
    +
  • to_folder (mailbox.Folder or str) – the destination Folder/folder_id to move into

  • +
  • update_parent_if_changed (bool) – updates self.parent with the +new parent Folder if changed

  • +
+
+
Returns:
+

The new folder after copying

+
+
Return type:
+

mailbox.Folder or None

+
+
+
+ +
+
+new_message()[source]
+

Creates a new draft message under this folder

+
+
Returns:
+

new Message

+
+
Return type:
+

Message

+
+
+
+ +
+
+refresh_folder(update_parent_if_changed=False)[source]
+

Re-download folder data +Inbox Folder will be unable to download its own data (no folder_id)

+
+
Parameters:
+

update_parent_if_changed (bool) – updates self.parent with new +parent Folder if changed

+
+
Returns:
+

Refreshed or Not

+
+
Return type:
+

bool

+
+
+
+ +
+
+update_folder_name(name, update_folder_data=True)[source]
+

Change this folder name

+
+
Parameters:
+
    +
  • name (str) – new name to change to

  • +
  • update_folder_data (bool) – whether or not to re-fetch the data

  • +
+
+
Returns:
+

Updated or Not

+
+
Return type:
+

bool

+
+
+
+ +
+
+child_folders_count
+

The number of immediate child mailFolders in the current mailFolder. +

   Type: int

+
+ +
+
+folder_id
+

The mailFolder’s unique identifier.

   Type: str

+
+ +
+
+name
+

The mailFolder’s display name.

   Type: str

+
+ +
+
+parent
+

The parent of the folder.

   Type: str

+
+ +
+
+parent_id
+

The unique identifier for the mailFolder’s parent mailFolder.

   Type: str

+
+ +
+
+root
+

Root folder.

   Type: bool

+
+ +
+
+total_items_count
+

The number of items in the mailFolder.

   Type: int

+
+ +
+
+unread_items_count
+

The number of items in the mailFolder marked as unread.

   Type: int

+
+ +
+
+updated_at
+

Last time data updated

   Type: datetime

+
+ +
+ +
+
+class O365.mailbox.MailBox(*, parent=None, con=None, **kwargs)[source]
+

Bases: Folder

+

The mailbox folder.

+
+
+__init__(*, parent=None, con=None, **kwargs)[source]
+

Create an instance to represent the specified folder in given +parent folder

+
+
Parameters:
+
    +
  • parent (mailbox.Folder or Account) – parent folder/account for this folder

  • +
  • con (Connection) – connection to use if no parent specified

  • +
  • protocol (Protocol) – protocol to use if no parent specified +(kwargs)

  • +
  • main_resource (str) – use this resource instead of parent resource +(kwargs)

  • +
  • name (str) – name of the folder to get under the parent (kwargs)

  • +
  • folder_id (str) – id of the folder to get under the parent (kwargs)

  • +
+
+
+
+ +
+
+archive_folder()[source]
+

Shortcut to get Archive Folder instance

+
+
Return type:
+

mailbox.Folder

+
+
+
+ +
+
+clutter_folder()[source]
+
+
Shortcut to get Clutter Folder instance

The clutter folder low-priority messages are moved to when using the Clutter feature.

+
+
+
+
Return type:
+

mailbox.Folder

+
+
+
+ +
+
+conflicts_folder()[source]
+
+
Shortcut to get Conflicts Folder instance

The folder that contains conflicting items in the mailbox.

+
+
+
+
Return type:
+

mailbox.Folder

+
+
+
+ +
+
+conversationhistory_folder()[source]
+
+
Shortcut to get Conversation History Folder instance

The folder where Skype saves IM conversations (if Skype is configured to do so).

+
+
+
+
Return type:
+

mailbox.Folder

+
+
+
+ +
+
+deleted_folder()[source]
+

Shortcut to get DeletedItems Folder instance

+
+
Return type:
+

mailbox.Folder

+
+
+
+ +
+
+drafts_folder()[source]
+

Shortcut to get Drafts Folder instance

+
+
Return type:
+

mailbox.Folder

+
+
+
+ +
+
+get_settings()[source]
+

Return the MailboxSettings.

+
+
Return type:
+

mailboxsettings

+
+
+
+ +
+
+inbox_folder()[source]
+

Shortcut to get Inbox Folder instance

+
+
Return type:
+

mailbox.Folder

+
+
+
+ +
+
+junk_folder()[source]
+

Shortcut to get Junk Folder instance

+
+
Return type:
+

mailbox.Folder

+
+
+
+ +
+
+localfailures_folder()[source]
+

Shortcut to get Local Failure Folder instance +The folder that contains items that exist on the local client but could not be uploaded to the server.

+
+
Return type:
+

mailbox.Folder

+
+
+
+ +
+
+outbox_folder()[source]
+

Shortcut to get Outbox Folder instance

+
+
Return type:
+

mailbox.Folder

+
+
+
+ +
+
+recoverableitemsdeletions_folder()[source]
+

Shortcut to get Recoverable Items Deletions (Purges) Folder instance +The folder that contains soft-deleted items: deleted either from the Deleted Items folder, or by pressing shift+delete in Outlook. +This folder is not visible in any Outlook email client, +but end users can interact with it through the Recover Deleted Items from Server feature in Outlook or Outlook on the web.

+
+
Return type:
+

mailbox.Folder

+
+
+
+ +
+
+scheduled_folder()[source]
+

Shortcut to get Scheduled Folder instance +The folder that contains messages that are scheduled to reappear in the inbox using the Schedule feature in Outlook for iOS.

+
+
Return type:
+

mailbox.Folder

+
+
+
+ +
+
+searchfolders_folder()[source]
+

Shortcut to get Search Folders Folder instance +The parent folder for all search folders defined in the user’s mailbox.

+
+
Return type:
+

mailbox.Folder

+
+
+
+ +
+
+sent_folder()[source]
+

Shortcut to get SentItems Folder instance

+
+
Return type:
+

mailbox.Folder

+
+
+
+ +
+
+serverfailures_folder()[source]
+

Shortcut to get Server Failures Folder instance +The folder that contains items that exist on the server but could not be synchronized to the local client.

+
+
Return type:
+

mailbox.Folder

+
+
+
+ +
+
+set_automatic_reply(internal_text: str, external_text: str, scheduled_start_date_time: datetime = None, scheduled_end_date_time: datetime = None, externalAudience: ExternalAudience = ExternalAudience.ALL)[source]
+

Set an automatic reply for the mailbox.

+
+
Returns:
+

Success / Failure

+
+
Return type:
+

bool

+
+
+
+ +
+
+set_disable_reply()[source]
+

Disable the automatic reply for the mailbox.

+
+
Returns:
+

Success / Failure

+
+
Return type:
+

bool

+
+
+
+ +
+
+syncissues_folder()[source]
+

Shortcut to get Sync Issues Folder instance +The folder that contains synchronization logs created by Outlook.

+
+
Return type:
+

mailbox.Folder

+
+
+
+ +
+ +
+
+class O365.mailbox.MailboxSettings(*, parent=None, con=None, **kwargs)[source]
+

Bases: ApiComponent

+

The MailboxSettings.

+
+
+__init__(*, parent=None, con=None, **kwargs)[source]
+

Representation of the MailboxSettings.

+
+
Parameters:
+
    +
  • parent (Mailbox) – parent object

  • +
  • con (Connection) – connection to use if no parent specified

  • +
  • protocol (Protocol) – protocol to use if no parent specified +(kwargs)

  • +
  • main_resource (str) – use this resource instead of parent resource +(kwargs)

  • +
+
+
+
+ +
+
+save()[source]
+

Save the MailboxSettings.

+
+
Returns:
+

Success / Failure

+
+
Return type:
+

bool

+
+
+
+ +
+
+automaticrepliessettings
+

Configuration settings to automatically notify the sender of +an incoming email with a message from the signed-in user. +

   Type: AutomaticRepliesSettings

+
+ +
+
+timezone
+

The default time zone for the user’s mailbox.

   Type: str

+
+ +
+
+workinghours
+

The days of the week and hours in a specific time zone +that the user works.

   Type: workingHours

+
+ +
+ +
+ + +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/docs/latest/api/message.html b/docs/latest/api/message.html new file mode 100644 index 00000000..0be272a5 --- /dev/null +++ b/docs/latest/api/message.html @@ -0,0 +1,1065 @@ + + + + + + + + + Message — O365 documentation + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+ +
+
+
+
+ +
+

Message

+
+
+class O365.message.Flag(*values)[source]
+

Bases: CaseEnum

+
+
+Complete = 'complete'
+
+ +
+
+Flagged = 'flagged'
+
+ +
+
+NotFlagged = 'not_flagged'
+
+ +
+ +
+
+class O365.message.MeetingMessageType(*values)[source]
+

Bases: CaseEnum

+
+
+MeetingAccepted = 'meeting_accepted'
+
+ +
+
+MeetingCancelled = 'meeting_cancelled'
+
+ +
+
+MeetingDeclined = 'meeting_declined'
+
+ +
+
+MeetingRequest = 'meeting_request'
+
+ +
+
+MeetingTentativelyAccepted = 'meeting_tentatively_accepted'
+
+ +
+ +
+
+class O365.message.Message(*, parent=None, con=None, **kwargs)[source]
+

Bases: ApiComponent, AttachableMixin, HandleRecipientsMixin

+

Management of the process of sending, receiving, reading, and +editing emails.

+
+
+__init__(*, parent=None, con=None, **kwargs)[source]
+

Makes a new message wrapper for sending and receiving messages.

+
+
Parameters:
+
    +
  • parent (mailbox.Folder or Account) – parent folder/account to create the message in

  • +
  • con (Connection) – connection to use if no parent specified

  • +
  • protocol (Protocol) – protocol to use if no parent specified +(kwargs)

  • +
  • main_resource (str) – use this resource instead of parent resource +(kwargs)

  • +
  • download_attachments (bool) – whether or not to +download attachments (kwargs)

  • +
+
+
+
+ +
+
+add_category(category)[source]
+

Adds a category to this message current categories list

+
+ +
+
+add_message_header(name, value)[source]
+
+ +
+
+copy(folder)[source]
+

Copy the message to a given folder

+
+
Parameters:
+

folder (str or mailbox.Folder) – Folder object or Folder id or Well-known name to +copy this message to

+
+
Returns:
+

the copied message

+
+
Return type:
+

Message

+
+
+
+ +
+
+delay_delivery(delay_seconds_or_absolute_datetime)[source]
+
+ +
+
+delete()[source]
+

Deletes a stored message

+
+
Returns:
+

Success / Failure

+
+
Return type:
+

bool

+
+
+
+ +
+
+forward()[source]
+

Creates a new message that is a forward this message

+
+
Returns:
+

new message

+
+
Return type:
+

Message

+
+
+
+ +
+
+get_body_soup()[source]
+

Returns the beautifulsoup4 of the html body

+
+
Returns:
+

BeautifulSoup object of body

+
+
Return type:
+

BeautifulSoup

+
+
+
+ +
+
+get_body_text()[source]
+

Parse the body html and returns the body text using bs4

+
+
Returns:
+

body as text

+
+
Return type:
+

str

+
+
+
+ +
+
+get_event()[source]
+

If this is a EventMessage it should return the related Event

+
+ +
+
+get_mime_content()[source]
+

Returns the MIME contents of this message

+
+ +
+
+mark_as_read()[source]
+

Marks this message as read in the cloud

+
+
Returns:
+

Success / Failure

+
+
Return type:
+

bool

+
+
+
+ +
+
+mark_as_unread()[source]
+

Marks this message as unread in the cloud

+
+
Returns:
+

Success / Failure

+
+
Return type:
+

bool

+
+
+
+ +
+
+move(folder)[source]
+

Move the message to a given folder

+
+
Parameters:
+

folder (str or mailbox.Folder) – Folder object or Folder id or Well-known name to +move this message to

+
+
Returns:
+

Success / Failure

+
+
Return type:
+

bool

+
+
+
+ +
+
+reply(to_all=True)[source]
+

Creates a new message that is a reply to this message

+
+
Parameters:
+

to_all (bool) – whether or not to replies to all the recipients +instead to just the sender

+
+
Returns:
+

new message

+
+
Return type:
+

Message

+
+
+
+ +
+
+save_as_eml(to_path=None)[source]
+

Saves this message as and EML to the file system +:param Path or str to_path: the path where to store this file

+
+ +
+
+save_draft(target_folder=OutlookWellKnowFolderNames.DRAFTS)[source]
+

Save this message as a draft on the cloud

+
+
Parameters:
+

target_folder – name of the drafts folder

+
+
Returns:
+

Success / Failure

+
+
Return type:
+

bool

+
+
+
+ +
+
+save_message()[source]
+

Saves changes to a message. +If the message is a new or saved draft it will call ‘save_draft’ otherwise +this will save only properties of a message that are draft-independent such as:

+
+
    +
  • is_read

  • +
  • category

  • +
  • flag

  • +
+
+
+
Returns:
+

Success / Failure

+
+
Return type:
+

bool

+
+
+
+ +
+
+send(save_to_sent_folder=True)[source]
+

Sends this message

+
+
Parameters:
+

save_to_sent_folder (bool) – whether or not to save it to +sent folder

+
+
Returns:
+

Success / Failure

+
+
Return type:
+

bool

+
+
+
+ +
+
+to_api_data(restrict_keys=None)[source]
+

Returns a dict representation of this message prepared to be sent +to the cloud

+
+
Parameters:
+

restrict_keys (dict or set) – a set of keys to restrict the returned +data to

+
+
Returns:
+

converted to cloud based keys

+
+
Return type:
+

dict

+
+
+
+ +
+
+property attachments
+

List of attachments

+
+ +
+
+property bcc
+

‘BCC’ list of recipients

+
+ +
+
+property body
+

Body of the email message

+
+
Getter:
+

Get body text of current message

+
+
Setter:
+

set html body of the message

+
+
Type:
+

str

+
+
+
+ +
+
+property body_preview
+

Returns the body preview

+
+ +
+
+body_type
+

The body type of the message.

   Type: bodyType

+
+ +
+
+property categories
+

Categories of this message

+
+
Getter:
+

Current list of categories

+
+
Setter:
+

Set new categories for the message

+
+
Type:
+

list[str] or str

+
+
+
+ +
+
+property cc
+

‘CC’ list of recipients

+
+ +
+
+conversation_id
+

The ID of the conversation the email belongs to.

   Type: str

+
+ +
+
+conversation_index
+

Indicates the position of the message within the conversation.

   Type: any

+
+ +
+
+property created
+

Created time of the message

+
+ +
+
+property flag
+

The Message Flag instance

+
+ +
+
+folder_id
+

The unique identifier for the message’s parent mailFolder.

   Type: str

+
+ +
+
+property has_attachments
+

Check if the message contains attachments

+
+
Type:
+

bool

+
+
+
+ +
+
+property importance
+

Importance of the message

+
+
Getter:
+

Get the current priority of the message

+
+
Setter:
+

Set a different importance level

+
+
Type:
+

str or ImportanceLevel

+
+
+
+ +
+
+property inference_classification
+

Message is focused or not

+
+ +
+
+internet_message_id
+

The message ID in the format specified by RFC2822.

   Type: str

+
+ +
+
+property is_delivery_receipt_requested
+

if the delivery receipt is requested for this message

+
+
Getter:
+

Current state of isDeliveryReceiptRequested

+
+
Setter:
+

Set isDeliveryReceiptRequested for the message

+
+
Type:
+

bool

+
+
+
+ +
+
+property is_draft
+

Check if the message is marked as draft

+
+
Type:
+

bool

+
+
+
+ +
+
+property is_event_message
+

Returns if this message is of type EventMessage +and therefore can return the related event.

+
+ +
+
+property is_read
+

Check if the message is read or not

+
+
Getter:
+

Get the status of message read

+
+
Setter:
+

Mark the message as read

+
+
Type:
+

bool

+
+
+
+ +
+
+property is_read_receipt_requested
+

if the read receipt is requested for this message

+
+
Getter:
+

Current state of isReadReceiptRequested

+
+
Setter:
+

Set isReadReceiptRequested for the message

+
+
Type:
+

bool

+
+
+
+ +
+
+property meeting_message_type
+

If this message is a EventMessage, returns the +meeting type: meetingRequest, meetingCancelled, meetingAccepted, +meetingTentativelyAccepted, meetingDeclined

+
+ +
+
+property message_headers
+

Custom message headers

+
+
+
+
Type:
+

list[dict[str, str]]

+
+
+
+ +
+
+property modified
+

Message last modified time

+
+ +
+
+object_id
+

Unique identifier for the message.

   Type: str

+
+ +
+
+property received
+

Message received time

+
+ +
+
+property reply_to
+

Reply to address

+
+ +
+
+property sender
+

Sender of the message

+
+
Getter:
+

Get the current sender

+
+
Setter:
+

Update the from address with new value

+
+
Type:
+

str or Recipient

+
+
+
+ +
+
+property sent
+

Message sent time

+
+ +
+
+property single_value_extended_properties
+

singleValueExtendedProperties

+
+ +
+
+property subject
+

Subject of the email message

+
+
Getter:
+

Get the current subject

+
+
Setter:
+

Assign a new subject

+
+
Type:
+

str

+
+
+
+ +
+
+property to
+

‘TO’ list of recipients

+
+ +
+
+property unique_body
+

The unique body of this message

+
+

Requires a select to retrieve it.

+
+
+
Return type:
+

str

+
+
+
+ +
+ +

The URL to open the message in Outlook on the web.

   Type: str

+
+ +
+ +
+
+class O365.message.MessageAttachment(attachment=None, *, parent=None, **kwargs)[source]
+

Bases: BaseAttachment

+
+ +
+
+class O365.message.MessageAttachments(parent, attachments=None)[source]
+

Bases: BaseAttachments

+
+
+get_eml_as_object(attachment: MessageAttachment)[source]
+

Returns a Message object out an eml attached message

+
+ +
+
+get_mime_content(attachment: MessageAttachment)[source]
+

Returns the MIME contents of this attachment

+
+ +
+
+save_as_eml(attachment, to_path=None)[source]
+

Saves this message as and EML to the file system +:param MessageAttachment attachment: the MessageAttachment to store as eml. +:param Path or str to_path: the path where to store this file

+
+ +
+ +
+
+class O365.message.MessageFlag(parent, flag_data)[source]
+

Bases: ApiComponent

+

A flag on a message

+
+
+__init__(parent, flag_data)[source]
+

An flag on a message +Not available on Outlook Rest Api v2 (only in beta)

+
+
Parameters:
+
    +
  • parent (Message) – parent of this

  • +
  • flag_data (dict) – flag data from cloud

  • +
+
+
+
+ +
+
+delete_flag()[source]
+

Sets this message as un flagged

+
+ +
+
+set_completed(*, completition_date=None)[source]
+

Sets this message flag as completed +:param completition_date: the datetime this followUp was completed

+
+ +
+
+set_flagged(*, start_date=None, due_date=None)[source]
+

Sets this message as flagged +:param start_date: the start datetime of the followUp +:param due_date: the due datetime of the followUp

+
+ +
+
+to_api_data()[source]
+

Returns this data as a dict to be sent to the server

+
+ +
+
+property completition_date
+

The completion date of the message flag.

+
+
Getter:
+

get the completion_date

+
+
Type:
+

datetime

+
+
+
+ +
+
+property due_date
+

The due date of the message flag.

+
+
Getter:
+

get the due_date

+
+
Type:
+

datetime

+
+
+
+ +
+
+property is_completed
+

Is the flag completed.

+
+
Getter:
+

get the is_completed status

+
+
Type:
+

bool

+
+
+
+ +
+
+property is_flagged
+

Is item flagged.

+
+
Getter:
+

get the is_flagged status

+
+
Type:
+

bool

+
+
+
+ +
+
+property start_date
+

The start date of the message flag.

+
+
Getter:
+

get the start_date

+
+
Type:
+

datetime

+
+
+
+ +
+
+property status
+
+ +
+ +
+
+class O365.message.RecipientType(*values)[source]
+

Bases: Enum

+
+
+BCC = 'bcc'
+
+ +
+
+CC = 'cc'
+
+ +
+
+TO = 'to'
+
+ +
+ +
+ + +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/docs/latest/api/onedrive.html b/docs/latest/api/onedrive.html new file mode 100644 index 00000000..c46064bd --- /dev/null +++ b/docs/latest/api/onedrive.html @@ -0,0 +1,1629 @@ + + + + + + + + + One Drive — O365 documentation + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+ +
+
+
+
+ +
+

One Drive

+
+
+class O365.drive.CopyOperation(*, parent=None, con=None, target=None, **kwargs)[source]
+

Bases: ApiComponent

+

https://github.com/OneDrive/onedrive-api-docs/issues/762

+
+
+__init__(*, parent=None, con=None, target=None, **kwargs)[source]
+
+
Parameters:
+
    +
  • parent (Drive) – parent for this operation i.e. the source of the copied item

  • +
  • con (Connection) – connection to use if no parent specified

  • +
  • target (Drive) – The target drive for the copy operation

  • +
  • protocol (Protocol) – protocol to use if no parent specified +(kwargs)

  • +
  • main_resource (str) – use this resource instead of parent resource +(kwargs)

  • +
  • monitor_url (str)

  • +
  • item_id (str)

  • +
+
+
+
+ +
+
+check_status(delay=0)[source]
+

Checks the api endpoint in a loop

+
+
Parameters:
+

delay – number of seconds to wait between api calls. +Note Connection ‘requests_delay’ also apply.

+
+
Returns:
+

tuple of status and percentage complete

+
+
Return type:
+

tuple(str, float)

+
+
+
+ +
+
+get_item()[source]
+

Returns the item copied

+
+
Returns:
+

Copied Item

+
+
Return type:
+

DriveItem

+
+
+
+ +
+
+completion_percentage
+

Percentage complete of the copy operation.

   Type: float

+
+ +
+
+item_id
+

item_id of the copy operation.

   Type: str

+
+ +
+
+monitor_url
+

Monitor url of the copy operation.

   Type: str

+
+ +
+
+parent
+

Parent drive of the copy operation.

   Type: Drive

+
+ +
+
+status
+

Status of the copy operation.

   Type: str

+
+ +
+
+target
+

Target drive of the copy operation.

   Type: Drive

+
+ +
+ +
+
+class O365.drive.DownloadableMixin[source]
+

Bases: object

+
+
+download(to_path: None | str | Path = None, name: str = None, chunk_size: str | int = 'auto', convert_to_pdf: bool = False, output: BytesIO | None = None)[source]
+

Downloads this file to the local drive. Can download the +file in chunks with multiple requests to the server.

+
+
Parameters:
+
    +
  • to_path (str or Path) – a path to store the downloaded file

  • +
  • name (str) – the name you want the stored file to have.

  • +
  • chunk_size (int) – number of bytes to retrieve from +each api call to the server. if auto, files bigger than +SIZE_THERSHOLD will be chunked (into memory, will be +however only 1 request)

  • +
  • convert_to_pdf (bool) – will try to download the converted pdf +if file extension in ALLOWED_PDF_EXTENSIONS

  • +
  • output (BytesIO) – (optional) an opened io object to write to. +if set, the to_path and name will be ignored

  • +
+
+
Returns:
+

Success / Failure

+
+
Return type:
+

bool

+
+
+
+ +
+ +
+
+class O365.drive.Drive(*, parent=None, con=None, **kwargs)[source]
+

Bases: ApiComponent

+

A Drive representation. +A Drive is a Container of Folders and Files and act as a root item

+
+
+__init__(*, parent=None, con=None, **kwargs)[source]
+

Create a drive representation

+
+
Parameters:
+
    +
  • parent (Drive or Storage) – parent for this operation

  • +
  • con (Connection) – connection to use if no parent specified

  • +
  • protocol (Protocol) – protocol to use if no parent specified +(kwargs)

  • +
  • main_resource (str) – use this resource instead of parent resource +(kwargs)

  • +
+
+
+
+ +
+
+get_child_folders(limit=None, *, query=None, order_by=None, batch=None)[source]
+

Returns all the folders inside this folder

+
+
Parameters:
+
    +
  • limit (int) – max no. of folders to get. Over 999 uses batch.

  • +
  • query (Query or str) – applies a OData filter to the request

  • +
  • order_by (Query or str) – orders the result set based on this condition

  • +
  • batch (int) – batch size, retrieves items in +batches allowing to retrieve more items than the limit.

  • +
+
+
Returns:
+

folder items in this folder

+
+
Return type:
+

generator of DriveItem or Pagination

+
+
+
+ +
+
+get_item(item_id)[source]
+

Returns a DriveItem by it’s Id

+
+
Returns:
+

one item

+
+
Return type:
+

DriveItem

+
+
+
+ +
+
+get_item_by_path(item_path)[source]
+

Returns a DriveItem by it’s absolute path: /path/to/file +:return: one item +:rtype: DriveItem

+
+ +
+
+get_items(limit=None, *, query=None, order_by=None, batch=None)[source]
+

Returns a collection of drive items from the root folder

+
+
Parameters:
+
    +
  • limit (int) – max no. of items to get. Over 999 uses batch.

  • +
  • query (Query or str) – applies a OData filter to the request

  • +
  • order_by (Query or str) – orders the result set based on this condition

  • +
  • batch (int) – batch size, retrieves items in +batches allowing to retrieve more items than the limit.

  • +
+
+
Returns:
+

items in this folder

+
+
Return type:
+

generator of DriveItem or Pagination

+
+
+
+ +
+
+get_recent(limit=None, *, query=None, order_by=None, batch=None)[source]
+

Returns a collection of recently used DriveItems

+
+
Parameters:
+
    +
  • limit (int) – max no. of items to get. Over 999 uses batch.

  • +
  • query (Query or str) – applies a OData filter to the request

  • +
  • order_by (Query or str) – orders the result set based on this condition

  • +
  • batch (int) – batch size, retrieves items in +batches allowing to retrieve more items than the limit.

  • +
+
+
Returns:
+

items in this folder

+
+
Return type:
+

generator of DriveItem or Pagination

+
+
+
+ +
+
+get_root_folder()[source]
+

Returns the Root Folder of this drive

+
+
Returns:
+

Root Folder

+
+
Return type:
+

DriveItem

+
+
+
+ +
+
+get_shared_with_me(limit=None, allow_external=False, *, query=None, order_by=None, batch=None)[source]
+

Returns a collection of DriveItems shared with me

+
+
Parameters:
+
    +
  • limit (int) – max no. of items to get. Over 999 uses batch.

  • +
  • query (Query or str) – applies a OData filter to the request

  • +
  • order_by (Query or str) – orders the result set based on this condition

  • +
  • batch (int) – batch size, retrieves items in +batches allowing to retrieve more items than the limit.

  • +
  • allow_external (bool) – includes items shared from external tenants

  • +
+
+
Returns:
+

items in this folder

+
+
Return type:
+

generator of DriveItem or Pagination

+
+
+
+ +
+
+get_special_folder(name)[source]
+

Returns the specified Special Folder

+
+
Returns:
+

a special Folder

+
+
Return type:
+

drive.Folder

+
+
+
+ +
+
+refresh()[source]
+

Updates this drive with data from the server

+
+
Returns:
+

Success / Failure

+
+
Return type:
+

bool

+
+
+
+ +
+
+search(search_text, limit=None, *, query=None, order_by=None, batch=None)[source]
+

Search for DriveItems under this drive. +Your app can search more broadly to include items shared with the +current user.

+

To broaden the search scope, use this search instead the Folder Search.

+

The search API uses a search service under the covers, which requires +indexing of content.

+

As a result, there will be some time between creation of an +item and when it will appear in search results.

+
+
Parameters:
+
    +
  • search_text (str) – The query text used to search for items. +Values may be matched across several fields including filename, +metadata, and file content.

  • +
  • limit (int) – max no. of items to get. Over 999 uses batch.

  • +
  • query (Query or str) – applies a OData filter to the request

  • +
  • order_by (Query or str) – orders the result set based on this condition

  • +
  • batch (int) – batch size, retrieves items in +batches allowing to retrieve more items than the limit.

  • +
+
+
Returns:
+

items in this folder matching search

+
+
Return type:
+

generator of DriveItem or Pagination

+
+
+
+ +
+
+parent
+

The parent of the Drive.

   Type: Drive

+
+ +
+ +
+
+class O365.drive.DriveItem(*, parent=None, con=None, **kwargs)[source]
+

Bases: ApiComponent

+

A DriveItem representation. Groups all functionality

+
+
+__init__(*, parent=None, con=None, **kwargs)[source]
+

Create a DriveItem

+
+
Parameters:
+
    +
  • parent (Drive or drive.Folder) – parent for this operation

  • +
  • con (Connection) – connection to use if no parent specified

  • +
  • protocol (Protocol) – protocol to use if no parent specified +(kwargs)

  • +
  • main_resource (str) – use this resource instead of parent resource +(kwargs)

  • +
+
+
+
+ +
+
+copy(target=None, name=None)[source]
+

Asynchronously creates a copy of this DriveItem and all it’s +child elements.

+
+
Parameters:
+
    +
  • target (drive.Folder or Drive) – target location to move to. +If it’s a drive the item will be moved to the root folder. +If it’s None, the target is the parent of the item being copied i.e. item will be copied +into the same location.

  • +
  • name – a new name for the copy.

  • +
+
+
Return type:
+

CopyOperation

+
+
+
+ +
+
+delete()[source]
+

Moves this item to the Recycle Bin

+
+
Returns:
+

Success / Failure

+
+
Return type:
+

bool

+
+
+
+ +
+
+get_drive()[source]
+

Returns this item drive +:return: Drive of this item +:rtype: Drive or None

+
+ +
+
+get_parent()[source]
+

the parent of this DriveItem

+
+
Returns:
+

Parent of this item

+
+
Return type:
+

Drive or drive.Folder

+
+
+
+ +
+
+get_permissions()[source]
+

Returns a list of DriveItemPermissions with the +permissions granted for this DriveItem.

+
+
Returns:
+

List of Permissions

+
+
Return type:
+

list[DriveItemPermission]

+
+
+
+ +
+
+get_thumbnails(size=None)[source]
+

Returns this Item Thumbnails. Thumbnails are not supported on +SharePoint Server 2016.

+
+
Parameters:
+

size – request only the specified size: ej: “small”, +Custom 300x400 px: “c300x400”, Crop: “c300x400_Crop”

+
+
Returns:
+

Thumbnail Data

+
+
Return type:
+

dict

+
+
+
+ +
+
+get_version(version_id)[source]
+

Returns a version for specified id

+
+
Returns:
+

a version object of specified id

+
+
Return type:
+

DriveItemVersion

+
+
+
+ +
+
+get_versions()[source]
+

Returns a list of available versions for this item

+
+
Returns:
+

list of versions

+
+
Return type:
+

list[DriveItemVersion]

+
+
+
+ +
+
+move(target)[source]
+

Moves this DriveItem to another Folder. +Can’t move between different Drives.

+
+
Parameters:
+

target (drive.Folder or DriveItem or str) – a Folder, Drive item or Item Id string. +If it’s a drive the item will be moved to the root folder.

+
+
Returns:
+

Success / Failure

+
+
Return type:
+

bool

+
+
+
+ +
+
+share_with_invite(recipients, require_sign_in=True, send_email=True, message=None, share_type='view')[source]
+

Sends an invitation to access or edit this DriveItem

+
+
Parameters:
+
    +
  • recipients (list[str] or list[Contact] or str or Contact) – a string or Contact or a list of the former +representing recipients of this invitation

  • +
  • require_sign_in (bool) – if True the recipients +invited will need to log in to view the contents

  • +
  • send_email (bool) – if True an email will be send to the recipients

  • +
  • message (str) – the body text of the message emailed

  • +
  • share_type (str) – ‘view’: will allow to read the contents. +‘edit’ will allow to modify the contents

  • +
+
+
Returns:
+

link to share

+
+
Return type:
+

DriveItemPermission

+
+
+
+ +
+ +

Creates or returns a link you can share with others

+
+
Parameters:
+
    +
  • share_type (str) – ‘view’ to allow only view access, +‘edit’ to allow editions, and +‘embed’ to allow the DriveItem to be embedded

  • +
  • share_scope (str) – ‘anonymous’: anyone with the link can access. +‘organization’ Only organization members can access

  • +
  • share_password (str) – sharing link password that is set by the creator. Optional.

  • +
  • share_expiration_date (str) – format of yyyy-MM-dd (e.g., 2022-02-14) that indicates the expiration date of the permission. Optional.

  • +
+
+
Returns:
+

link to share

+
+
Return type:
+

DriveItemPermission

+
+
+
+ +
+
+update(**kwargs)[source]
+

Updates this item

+
+
Parameters:
+

kwargs – all the properties to be updated. +only name and description are allowed at the moment.

+
+
Returns:
+

Success / Failure

+
+
Return type:
+

bool

+
+
+
+ +
+
+created
+

Date and time of item creation.

   Type: datetime

+
+ +
+
+created_by
+

Identity of the user, device, and application which created the item.

   Type: Contact

+
+ +
+
+description
+

Provides a user-visible description of the item.

   Type: str

+
+ +
+
+drive
+

The drive

   Type: Drive

+
+ +
+
+drive_id
+

Identifier of the drive instance that contains the item.

   Type: str

+
+ +
+
+property is_file
+

Returns if this DriveItem is a File

+
+ +
+
+property is_folder
+

Returns if this DriveItem is a Folder

+
+ +
+
+property is_image
+

Returns if this DriveItem is a Image

+
+ +
+
+property is_photo
+

Returns if this DriveItem is a Photo

+
+ +
+
+modified
+

Date and time the item was last modified.

   Type: datetime

+
+ +
+
+modified_by
+

Identity of the user, device, and application which last modified the item +

   Type: Contact

+
+ +
+
+name
+

The name of the item (filename and extension).

   Type: str

+
+ +
+
+object_id
+

The unique identifier of the item within the Drive.

   Type: str

+
+ +
+
+parent_id
+

The id of the parent.

   Type: str

+
+ +
+
+parent_path
+

Path that can be used to navigate to the item.

   Type: str

+
+ +
+
+remote_item
+

Remote item data, if the item is shared from a drive other than the one being accessed. +

   Type: remoteItem

+
+ +
+
+shared
+

Indicates that the item has been shared with others and +provides information about the shared state of the item.

   Type: str

+
+ +
+
+size
+

Size of the item in bytes.

   Type: int

+
+ +
+
+thumbnails
+

The thumbnails.

   Type: any

+
+ +
+
+web_url
+

URL that displays the resource in the browser.

   Type: str

+
+ +
+ +
+
+class O365.drive.DriveItemPermission(*, parent=None, con=None, **kwargs)[source]
+

Bases: ApiComponent

+

A Permission representation for a DriveItem

+
+
+__init__(*, parent=None, con=None, **kwargs)[source]
+

Permissions for DriveItem

+
+
Parameters:
+
    +
  • parent (DriveItem) – parent for this operation

  • +
  • con (Connection) – connection to use if no parent specified

  • +
  • protocol (Protocol) – protocol to use if no parent specified +(kwargs)

  • +
  • main_resource (str) – use this resource instead of parent resource +(kwargs)

  • +
+
+
+
+ +
+
+delete()[source]
+

Deletes this permission. Only permissions that are not +inherited can be deleted.

+
+
Returns:
+

Success / Failure

+
+
Return type:
+

bool

+
+
+
+ +
+
+update_roles(roles='view')[source]
+

Updates the roles of this permission

+
+
Returns:
+

Success / Failure

+
+
Return type:
+

bool

+
+
+
+ +
+
+driveitem_id
+

The unique identifier of the item within the Drive.

   Type: str

+
+ +
+
+granted_to
+

For user type permissions, the details of the users and applications +for this permission.

   Type: IdentitySet

+
+ +
+
+inherited_from
+

Provides a reference to the ancestor of the current permission, +if it’s inherited from an ancestor.

   Type: ItemReference

+
+ +
+
+invited_by
+

The invited by user.

   Type: str

+
+ +
+
+object_id
+

The unique identifier of the permission among all permissions on the item.

   Type: str

+
+ +
+
+permission_type
+

The permission type.

   Type: str

+
+ +
+
+require_sign_in
+

Is sign in required.

   Type: bool

+
+ +
+
+roles
+

The type of permission, for example, read.

   Type: list[str]

+
+ +
+
+share_email
+

The share email.

   Type: str

+
+ +
+
+share_id
+

A unique token that can be used to access this shared item via the shares API +

   Type: str

+
+ +
+ +

The share link.

   Type: str

+
+ +
+
+share_scope
+

The share scope.

   Type: str

+
+ +
+
+share_type
+

The share type.

   Type: str

+
+ +
+ +
+
+class O365.drive.DriveItemVersion(*, parent=None, con=None, **kwargs)[source]
+

Bases: ApiComponent, DownloadableMixin

+

A version of a DriveItem

+
+
+__init__(*, parent=None, con=None, **kwargs)[source]
+

Version of DriveItem

+
+
Parameters:
+
    +
  • parent (DriveItem) – parent for this operation

  • +
  • con (Connection) – connection to use if no parent specified

  • +
  • protocol (Protocol) – protocol to use if no parent specified +(kwargs)

  • +
  • main_resource (str) – use this resource instead of parent resource +(kwargs)

  • +
+
+
+
+ +
+
+download(to_path: None | str | Path = None, name: str = None, chunk_size: str | int = 'auto', convert_to_pdf: bool = False, output: BytesIO | None = None)[source]
+

Downloads this version. +You can not download the current version (last one).

+
+
Returns:
+

Success / Failure

+
+
Return type:
+

bool

+
+
+
+ +
+
+restore()[source]
+

Restores this DriveItem Version. +You can not restore the current version (last one).

+
+
Returns:
+

Success / Failure

+
+
Return type:
+

bool

+
+
+
+ +
+
+driveitem_id
+

The unique identifier of the item within the Drive.

   Type: str

+
+ +
+
+modified
+

Date and time the version was last modified.

   Type: datetime

+
+ +
+
+modified_by
+

Identity of the user which last modified the version.

   Type: Contact

+
+ +
+
+name
+

The name (ID) of the version.

   Type: str

+
+ +
+
+object_id
+

The ID of the version.

   Type: str

+
+ +
+
+size
+

Indicates the size of the content stream for this version of the item. +

   Type: int

+
+ +
+ +
+
+class O365.drive.File(**kwargs)[source]
+

Bases: DriveItem, DownloadableMixin

+

A File

+
+
+__init__(**kwargs)[source]
+

Create a DriveItem

+
+
Parameters:
+
    +
  • parent (Drive or drive.Folder) – parent for this operation

  • +
  • con (Connection) – connection to use if no parent specified

  • +
  • protocol (Protocol) – protocol to use if no parent specified +(kwargs)

  • +
  • main_resource (str) – use this resource instead of parent resource +(kwargs)

  • +
+
+
+
+ +
+
+property extension
+

The suffix of the file name.

+
+
Getter:
+

get the suffix

+
+
Type:
+

str

+
+
+
+ +
+
+hashes
+

Hashes of the file’s binary content, if available.

   Type: Hashes

+
+ +
+
+mime_type
+

The MIME type for the file.

   Type: str

+
+ +
+ +
+
+class O365.drive.Folder(*args, **kwargs)[source]
+

Bases: DriveItem

+

A Folder inside a Drive

+
+
+__init__(*args, **kwargs)[source]
+

Create a DriveItem

+
+
Parameters:
+
    +
  • parent (Drive or drive.Folder) – parent for this operation

  • +
  • con (Connection) – connection to use if no parent specified

  • +
  • protocol (Protocol) – protocol to use if no parent specified +(kwargs)

  • +
  • main_resource (str) – use this resource instead of parent resource +(kwargs)

  • +
+
+
+
+ +
+
+create_child_folder(name, description=None)[source]
+

Creates a Child Folder

+
+
Parameters:
+
    +
  • name (str) – the name of the new child folder

  • +
  • description (str) – the description of the new child folder

  • +
+
+
Returns:
+

newly created folder

+
+
Return type:
+

drive.Folder

+
+
+
+ +
+
+download_contents(to_folder=None)[source]
+

This will download each file and folder sequentially. +Caution when downloading big folder structures +:param drive.Folder to_folder: folder where to store the contents

+
+ +
+
+get_child_folders(limit=None, *, query=None, order_by=None, batch=None)[source]
+

Returns all the folders inside this folder

+
+
Parameters:
+
    +
  • limit (int) – max no. of folders to get. Over 999 uses batch.

  • +
  • query (Query or str) – applies a OData filter to the request

  • +
  • order_by (Query or str) – orders the result set based on this condition

  • +
  • batch (int) – batch size, retrieves items in +batches allowing to retrieve more items than the limit.

  • +
+
+
Returns:
+

folder items in this folder

+
+
Return type:
+

generator of DriveItem or Pagination

+
+
+
+ +
+
+get_items(limit=None, *, query=None, order_by=None, batch=None)[source]
+

Returns generator all the items inside this folder

+
+
Parameters:
+
    +
  • limit (int) – max no. of folders to get. Over 999 uses batch.

  • +
  • query (Query or str) – applies a OData filter to the request

  • +
  • order_by (Query or str) – orders the result set based on this condition

  • +
  • batch (int) – batch size, retrieves items in +batches allowing to retrieve more items than the limit.

  • +
+
+
Returns:
+

items in this folder

+
+
Return type:
+

generator of DriveItem or Pagination

+
+
+
+ +
+
+search(search_text, limit=None, *, query=None, order_by=None, batch=None)[source]
+

Search for DriveItems under this folder +The search API uses a search service under the covers, +which requires indexing of content.

+

As a result, there will be some time between creation of an item +and when it will appear in search results.

+
+
Parameters:
+
    +
  • search_text (str) – The query text used to search for items. +Values may be matched across several fields including filename, +metadata, and file content.

  • +
  • limit (int) – max no. of folders to get. Over 999 uses batch.

  • +
  • query (Query or str) – applies a OData filter to the request

  • +
  • order_by (Query or str) – orders the result set based on this condition

  • +
  • batch (int) – batch size, retrieves items in +batches allowing to retrieve more items than the limit.

  • +
+
+
Returns:
+

items in this folder matching search

+
+
Return type:
+

generator of DriveItem or Pagination

+
+
+
+ +
+
+upload_file(item, item_name=None, chunk_size=5242880, upload_in_chunks=False, stream=None, stream_size=None, conflict_handling=None, file_created_date_time: str = None, file_last_modified_date_time: str = None)[source]
+

Uploads a file

+
+
Parameters:
+
    +
  • item (str or Path) – path to the item you want to upload

  • +
  • item_name (str or Path) – name of the item on the server. None to use original name

  • +
  • chunk_size – Only applies if file is bigger than 4MB or upload_in_chunks is True. +Chunk size for uploads. Must be a multiple of 327.680 bytes

  • +
  • upload_in_chunks – force the method to upload the file in chunks

  • +
  • stream (io.BufferedIOBase) – (optional) an opened io object to read into. +if set, the to_path and name will be ignored

  • +
  • stream_size (int) – size of stream, required if using stream

  • +
  • conflict_handling (str) – How to handle conflicts. +NOTE: works for chunk upload only (>4MB or upload_in_chunks is True) +None to use default (overwrite). Options: fail | replace | rename

  • +
  • file_created_date_time – allow to force file created date time while uploading

  • +
  • file_last_modified_date_time – allow to force file last modified date time while uploading

  • +
+
+
Returns:
+

uploaded file

+
+
Return type:
+

DriveItem

+
+
+
+ +
+
+child_count
+

Number of children contained immediately within this container.

   Type: int

+
+ +
+
+special_folder
+

The unique identifier for this item in the /drive/special collection.

   Type: str

+
+ +
+ +
+
+class O365.drive.Image(**kwargs)[source]
+

Bases: File

+

An Image

+
+
+__init__(**kwargs)[source]
+

Create a DriveItem

+
+
Parameters:
+
    +
  • parent (Drive or drive.Folder) – parent for this operation

  • +
  • con (Connection) – connection to use if no parent specified

  • +
  • protocol (Protocol) – protocol to use if no parent specified +(kwargs)

  • +
  • main_resource (str) – use this resource instead of parent resource +(kwargs)

  • +
+
+
+
+ +
+
+property dimensions
+

Dimension of the Image

+
+
Returns:
+

width x height

+
+
Return type:
+

str

+
+
+
+ +
+
+height
+

Height of the image, in pixels.

   Type: int

+
+ +
+
+width
+

Width of the image, in pixels.

   Type: int

+
+ +
+ +
+
+class O365.drive.Photo(**kwargs)[source]
+

Bases: Image

+

Photo Object. Inherits from Image but has more attributes

+
+
+__init__(**kwargs)[source]
+

Create a DriveItem

+
+
Parameters:
+
    +
  • parent (Drive or drive.Folder) – parent for this operation

  • +
  • con (Connection) – connection to use if no parent specified

  • +
  • protocol (Protocol) – protocol to use if no parent specified +(kwargs)

  • +
  • main_resource (str) – use this resource instead of parent resource +(kwargs)

  • +
+
+
+
+ +
+
+camera_make
+

Camera manufacturer.

   Type: str

+
+ +
+
+camera_model
+

Camera model.

   Type: str

+
+ +
+
+exposure_denominator
+

The denominator for the exposure time fraction from the camera.

   Type: float

+
+ +
+
+exposure_numerator
+

The numerator for the exposure time fraction from the camera.

   Type: float

+
+ +
+
+fnumber
+

The F-stop value from the camera

   Type: float

+
+ +
+
+focal_length
+

The focal length from the camera.

   Type: float

+
+ +
+
+iso
+

The ISO value from the camera.

   Type: int

+
+ +
+
+taken_datetime
+

Represents the date and time the photo was taken.

   Type: datetime

+
+ +
+ +
+
+class O365.drive.Storage(*, parent=None, con=None, **kwargs)[source]
+

Bases: ApiComponent

+

Parent Class that holds drives

+
+
+__init__(*, parent=None, con=None, **kwargs)[source]
+

Create a storage representation

+
+
Parameters:
+
    +
  • parent (Account) – parent for this operation

  • +
  • con (Connection) – connection to use if no parent specified

  • +
  • protocol (Protocol) – protocol to use if no parent specified +(kwargs)

  • +
  • main_resource (str) – use this resource instead of parent resource +(kwargs)

  • +
+
+
+
+ +
+
+get_default_drive(request_drive=False)[source]
+

Returns a Drive instance

+
+
Parameters:
+

request_drive – True will make an api call to retrieve the drive +data

+
+
Returns:
+

default One Drive

+
+
Return type:
+

Drive

+
+
+
+ +
+
+get_drive(drive_id)[source]
+

Returns a Drive instance

+
+
Parameters:
+

drive_id – the drive_id to be retrieved

+
+
Returns:
+

Drive for the id

+
+
Return type:
+

Drive

+
+
+
+ +
+
+get_drives()[source]
+

Returns a collection of drives

+
+ +
+ +
+ + +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/docs/latest/api/planner.html b/docs/latest/api/planner.html new file mode 100644 index 00000000..1ddf8d26 --- /dev/null +++ b/docs/latest/api/planner.html @@ -0,0 +1,970 @@ + + + + + + + + + Planner — O365 documentation + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+ +
+
+
+
+ +
+

Planner

+
+
+class O365.planner.Bucket(*, parent=None, con=None, **kwargs)[source]
+

Bases: ApiComponent

+
+
+__init__(*, parent=None, con=None, **kwargs)[source]
+

A Microsoft 365 bucket

+
+
Parameters:
+
    +
  • parent (Planner or Plan) – parent object

  • +
  • con (Connection) – connection to use if no parent specified

  • +
  • protocol (Protocol) – protocol to use if no parent specified +(kwargs)

  • +
  • main_resource (str) – use this resource instead of parent resource +(kwargs)

  • +
+
+
+
+ +
+
+create_task(title, assignments=None, **kwargs)[source]
+

Creates a Task

+
+
Parameters:
+
    +
  • title (str) – the title of the task

  • +
  • assignments (dict) – the dict of users to which tasks are to be assigned.

  • +
+
+
+
e.g. assignments = {
+      "ca2a1df2-e36b-4987-9f6b-0ea462f4eb47": null,
+      "4e98f8f1-bb03-4015-b8e0-19bb370949d8": {
+          "@odata.type": "microsoft.graph.plannerAssignment",
+          "orderHint": "String"
+        }
+    }
+if "user_id": null -> task is unassigned to user.
+if "user_id": dict -> task is assigned to user
+
+
+
+
Parameters:
+
    +
  • kwargs (dict) – optional extra parameters to include in the task

  • +
  • priority (int) –

    priority of the task. The valid range of values is between 0 and 10.

    +

    1 -> “urgent”, 3 -> “important”, 5 -> “medium”, 9 -> “low” (kwargs)

    +

  • +
  • order_hint (str) – the order of the bucket. Default is on top (kwargs)

  • +
  • start_date_time (datetime or str) – the starting date of the task. If str format should be: “%Y-%m-%dT%H:%M:%SZ” (kwargs)

  • +
  • due_date_time (datetime or str) – the due date of the task. If str format should be: “%Y-%m-%dT%H:%M:%SZ” (kwargs)

  • +
  • conversation_thread_id (str) –

    thread ID of the conversation on the task.

    +

    This is the ID of the conversation thread object created in the group (kwargs)

    +

  • +
  • assignee_priority (str) – hint used to order items of this type in a list view (kwargs)

  • +
  • percent_complete (int) – percentage of task completion. When set to 100, the task is considered completed (kwargs)

  • +
  • applied_categories (dict) –

    The categories (labels) to which the task has been applied.

    +

    Format should be e.g. {“category1”: true, “category3”: true, “category5”: true } should (kwargs)

    +

  • +
+
+
Returns:
+

newly created task

+
+
Return type:
+

Task

+
+
+
+ +
+
+delete()[source]
+

Deletes this bucket

+
+
Returns:
+

Success / Failure

+
+
Return type:
+

bool

+
+
+
+ +
+
+list_tasks()[source]
+

Returns list of tasks that given plan has +:rtype: list[Task]

+
+ +
+
+update(**kwargs)[source]
+

Updates this bucket

+
+
Parameters:
+

kwargs – all the properties to be updated.

+
+
Returns:
+

Success / Failure

+
+
Return type:
+

bool

+
+
+
+ +
+
+name
+

Name of the bucket.

   Type: str

+
+ +
+
+object_id
+

ID of the bucket.

   Type: str

+
+ +
+
+order_hint
+

Hint used to order items of this type in a list view.

   Type: str

+
+ +
+
+plan_id
+

Plan ID to which the bucket belongs.

   Type: str

+
+ +
+ +
+
+class O365.planner.Plan(*, parent=None, con=None, **kwargs)[source]
+

Bases: ApiComponent

+
+
+__init__(*, parent=None, con=None, **kwargs)[source]
+

A Microsoft 365 plan

+
+
Parameters:
+
    +
  • parent (Planner) – parent object

  • +
  • con (Connection) – connection to use if no parent specified

  • +
  • protocol (Protocol) – protocol to use if no parent specified +(kwargs)

  • +
  • main_resource (str) – use this resource instead of parent resource +(kwargs)

  • +
+
+
+
+ +
+
+create_bucket(name, order_hint=' !')[source]
+

Creates a Bucket

+
+
Parameters:
+
+
+
Returns:
+

newly created bucket

+
+
Return type:
+

Bucket

+
+
+
+ +
+
+delete()[source]
+

Deletes this plan

+
+
Returns:
+

Success / Failure

+
+
Return type:
+

bool

+
+
+
+ +
+
+get_details()[source]
+

Returns Microsoft 365/AD plan with given id

+
+
Return type:
+

PlanDetails

+
+
+
+ +
+
+list_buckets()[source]
+

Returns list of buckets that given plan has +:rtype: list[Bucket]

+
+ +
+
+list_tasks()[source]
+

Returns list of tasks that given plan has +:rtype: list[Task] or Pagination of Task

+
+ +
+
+update(**kwargs)[source]
+

Updates this plan

+
+
Parameters:
+

kwargs – all the properties to be updated.

+
+
Returns:
+

Success / Failure

+
+
Return type:
+

bool

+
+
+
+ +
+
+created_date_time
+

Date and time at which the plan is created.

   Type: datetime

+
+ +
+
+group_id
+

The identifier of the resource that contains the plan.

   Type: str

+
+ +
+
+object_id
+

ID of the plan.

   Type: str

+
+ +
+
+title
+

Title of the plan.

   Type: str

+
+ +
+ +
+
+class O365.planner.PlanDetails(*, parent=None, con=None, **kwargs)[source]
+

Bases: ApiComponent

+
+
+__init__(*, parent=None, con=None, **kwargs)[source]
+

A Microsoft 365 plan details

+
+
Parameters:
+
    +
  • parent (Plan) – parent object

  • +
  • con (Connection) – connection to use if no parent specified

  • +
  • protocol (Protocol) – protocol to use if no parent specified +(kwargs)

  • +
  • main_resource (str) – use this resource instead of parent resource +(kwargs)

  • +
+
+
+
+ +
+
+update(**kwargs)[source]
+

Updates this plan detail

+
+
Parameters:
+
    +
  • kwargs – all the properties to be updated.

  • +
  • shared_with (dict) – dict where keys are user_ids and values are boolean (kwargs)

  • +
  • category_descriptions (dict) – dict where keys are category1, category2, …, category25 and values are the label associated with (kwargs)

  • +
+
+
Returns:
+

Success / Failure

+
+
Return type:
+

bool

+
+
+
+ +
+
+category_descriptions
+

An object that specifies the descriptions of the 25 categories +that can be associated with tasks in the plan.

   Type: any

+
+ +
+
+object_id
+

The unique identifier for the plan details.

   Type: str

+
+ +
+
+shared_with
+

Set of user IDs that this plan is shared with.

   Type: any

+
+ +
+ +
+
+class O365.planner.Planner(*, parent=None, con=None, **kwargs)[source]
+

Bases: ApiComponent

+

A microsoft planner class

+

In order to use the API following permissions are required. +Delegated (work or school account) - Group.Read.All, Group.ReadWrite.All

+
+
+__init__(*, parent=None, con=None, **kwargs)[source]
+

A Planner object

+
+
Parameters:
+
    +
  • parent (Account) – parent object

  • +
  • con (Connection) – connection to use if no parent specified

  • +
  • protocol (Protocol) – protocol to use if no parent specified +(kwargs)

  • +
  • main_resource (str) – use this resource instead of parent resource +(kwargs)

  • +
+
+
+
+ +
+
+create_plan(owner, title='Tasks')[source]
+

Creates a Plan

+
+
Parameters:
+
    +
  • owner (str) – the id of the group that will own the plan

  • +
  • title (str) – the title of the new plan. Default set to “Tasks”

  • +
+
+
Returns:
+

newly created plan

+
+
Return type:
+

Plan

+
+
+
+ +
+
+get_bucket_by_id(bucket_id=None)[source]
+

Returns Microsoft 365/AD plan with given id

+
+
Parameters:
+

bucket_id – bucket id of buckets

+
+
Return type:
+

Bucket

+
+
+
+ +
+
+get_my_tasks(*args)[source]
+

Returns a list of open planner tasks assigned to me

+
+
Return type:
+

tasks

+
+
+
+ +
+
+get_plan_by_id(plan_id=None)[source]
+

Returns Microsoft 365/AD plan with given id

+
+
Parameters:
+

plan_id – plan id of plan

+
+
Return type:
+

Plan

+
+
+
+ +
+
+get_task_by_id(task_id=None)[source]
+

Returns Microsoft 365/AD plan with given id

+
+
Parameters:
+

task_id – task id of tasks

+
+
Return type:
+

Task

+
+
+
+ +
+
+list_group_plans(group_id=None)[source]
+

Returns list of plans that given group has +:param group_id: group id +:rtype: list[Plan]

+
+ +
+
+list_user_tasks(user_id=None)[source]
+

Returns Microsoft 365/AD plan with given id

+
+
Parameters:
+

user_id – user id

+
+
Return type:
+

list[Task]

+
+
+
+ +
+ +
+
+class O365.planner.Task(*, parent=None, con=None, **kwargs)[source]
+

Bases: ApiComponent

+

A Microsoft Planner task

+
+
+__init__(*, parent=None, con=None, **kwargs)[source]
+

A Microsoft planner task

+
+
Parameters:
+
    +
  • parent (Planner or Plan or Bucket) – parent object

  • +
  • con (Connection) – connection to use if no parent specified

  • +
  • protocol (Protocol) – protocol to use if no parent specified +(kwargs)

  • +
  • main_resource (str) – use this resource instead of parent resource +(kwargs)

  • +
+
+
+
+ +
+
+delete()[source]
+

Deletes this task

+
+
Returns:
+

Success / Failure

+
+
Return type:
+

bool

+
+
+
+ +
+
+get_details()[source]
+

Returns Microsoft 365/AD plan with given id

+
+
Return type:
+

PlanDetails

+
+
+
+ +
+
+update(**kwargs)[source]
+

Updates this task

+
+
Parameters:
+

kwargs – all the properties to be updated.

+
+
Returns:
+

Success / Failure

+
+
Return type:
+

bool

+
+
+
+ +
+
+active_checklist_item_count
+

Number of checklist items with value set to false, representing incomplete items. +

   Type: int

+
+ +
+
+applied_categories
+

The categories to which the task has been applied.

   Type: plannerAppliedCategories

+
+ +
+
+assignee_priority
+

Hint used to order items of this type in a list view.

   Type: str

+
+ +
+
+assignments
+

The set of assignees the task is assigned to.

   Type: plannerAssignments

+
+ +
+
+bucket_id
+

Bucket ID to which the task belongs.

   Type: str

+
+ +
+
+checklist_item_count
+

Number of checklist items that are present on the task.

   Type: int

+
+ +
+
+completed_date
+

Date and time at which the ‘percentComplete’ of the task is set to ‘100’. +

   Type: datetime

+
+ +
+
+conversation_thread_id
+

Thread ID of the conversation on the task.

   Type: str

+
+ +
+
+created_date
+

Date and time at which the task is created.

   Type: datetime

+
+ +
+
+due_date_time
+

Date and time at which the task is due.

   Type: datetime

+
+ +
+
+has_description
+

Value is true if the details object of the task has a +nonempty description and false otherwise.

   Type: bool

+
+ +
+
+object_id
+

ID of the task.

   Type: str

+
+ +
+
+order_hint
+

Hint used to order items of this type in a list view.

   Type: str

+
+ +
+
+percent_complete
+

Percentage of task completion.

   Type: int

+
+ +
+
+plan_id
+

Plan ID to which the task belongs.

   Type: str

+
+ +
+
+preview_type
+

his sets the type of preview that shows up on the task. +The possible values are: automatic, noPreview, checklist, description, reference. +

   Type: str

+
+ +
+
+priority
+

Priority of the task.

   Type: int

+
+ +
+
+reference_count
+

Number of external references that exist on the task.

   Type: int

+
+ +
+
+start_date_time
+

Date and time at which the task starts.

   Type: datetime

+
+ +
+
+title
+

Title of the task.

   Type: str

+
+ +
+ +
+
+class O365.planner.TaskDetails(*, parent=None, con=None, **kwargs)[source]
+

Bases: ApiComponent

+
+
+__init__(*, parent=None, con=None, **kwargs)[source]
+

A Microsoft 365 plan details

+
+
Parameters:
+
    +
  • parent (Task) – parent object

  • +
  • con (Connection) – connection to use if no parent specified

  • +
  • protocol (Protocol) – protocol to use if no parent specified +(kwargs)

  • +
  • main_resource (str) – use this resource instead of parent resource +(kwargs)

  • +
+
+
+
+ +
+
+update(**kwargs)[source]
+

Updates this task detail

+
+
Parameters:
+
    +
  • kwargs – all the properties to be updated.

  • +
  • checklist (dict) – the collection of checklist items on the task.

  • +
+
+
+
e.g. checklist = {
+  "string GUID": {
+    "isChecked": bool,
+    "orderHint": string,
+    "title": string
+  }
+} (kwargs)
+
+
+
+
Parameters:
+
    +
  • description (str) – description of the task

  • +
  • preview_type (str) –

    this sets the type of preview that shows up on the task.

    +

    The possible values are: automatic, noPreview, checklist, description, reference.

    +

  • +
  • references (dict) – the collection of references on the task.

  • +
+
+
+
e.g. references = {
+  "URL of the resource" : {
+    "alias": string,
+    "previewPriority": string, #same as orderHint
+    "type": string, #e.g. PowerPoint, Excel, Word, Pdf...
+  }
+}
+
+
+
+
Returns:
+

Success / Failure

+
+
Return type:
+

bool

+
+
+
+ +
+
+checklist
+

The collection of checklist items on the task.

   Type: any

+
+ +
+
+description
+

Description of the task.

   Type: str

+
+ +
+
+object_id
+

ID of the task details.

   Type: str

+
+ +
+
+preview_type
+

This sets the type of preview that shows up on the task. +The possible values are: automatic, noPreview, checklist, description, reference. +When set to automatic the displayed preview is chosen by the app viewing the task. +

   Type: str

+
+ +
+
+references
+

The collection of references on the task.

   Type: any

+
+ +
+ +
+ + +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/docs/latest/api/sharepoint.html b/docs/latest/api/sharepoint.html new file mode 100644 index 00000000..9ab9ea5a --- /dev/null +++ b/docs/latest/api/sharepoint.html @@ -0,0 +1,820 @@ + + + + + + + + + Sharepoint — O365 documentation + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+ +
+
+
+
+ +
+

Sharepoint

+
+
+class O365.sharepoint.Sharepoint(*, parent=None, con=None, **kwargs)[source]
+

Bases: ApiComponent

+

A Sharepoint parent class to group functionality

+
+
+__init__(*, parent=None, con=None, **kwargs)[source]
+

A Sharepoint site List

+
+
Parameters:
+
    +
  • parent (Account) – parent object

  • +
  • con (Connection) – connection to use if no parent specified

  • +
  • protocol (Protocol) – protocol to use if no parent specified +(kwargs)

  • +
  • main_resource (str) – use this resource instead of parent resource +(kwargs)

  • +
+
+
+
+ +
+
+get_root_site()[source]
+

Returns the root site

+
+
Return type:
+

Site

+
+
+
+ +
+
+get_site(*args)[source]
+

Returns a sharepoint site

+
+
Parameters:
+

args

It accepts multiple ways of retrieving a site:

+

get_site(host_name): the host_name: host_name ej. +‘contoso.sharepoint.com’ or ‘root’

+

get_site(site_id): the site_id: a comma separated string of +(host_name, site_collection_id, site_id)

+

get_site(host_name, path_to_site): host_name ej. ‘contoso. +sharepoint.com’, path_to_site: a url path (with a leading slash)

+

get_site(host_name, site_collection_id, site_id): +host_name ej. ‘contoso.sharepoint.com’

+

+
+
Return type:
+

Site

+
+
+
+ +
+
+search_site(keyword)[source]
+

Search a sharepoint host for sites with the provided keyword

+
+
Parameters:
+

keyword – a keyword to search sites

+
+
Return type:
+

list[Site]

+
+
+
+ +
+ +
+
+class O365.sharepoint.SharepointList(*, parent=None, con=None, **kwargs)[source]
+

Bases: ApiComponent

+
+
+__init__(*, parent=None, con=None, **kwargs)[source]
+

A Sharepoint site List

+
+
Parameters:
+
    +
  • parent (Site) – parent object

  • +
  • con (Connection) – connection to use if no parent specified

  • +
  • protocol (Protocol) – protocol to use if no parent specified +(kwargs)

  • +
  • main_resource (str) – use this resource instead of parent resource +(kwargs)

  • +
+
+
+
+ +
+
+build_field_filter(expand_fields)[source]
+
+ +
+
+create_list_item(new_data)[source]
+

Create new list item

+
+
Parameters:
+

new_data – dictionary of {‘col_name’: col_value}

+
+
Return type:
+

SharepointListItem

+
+
+
+ +
+
+delete_list_item(item_id)[source]
+

Delete an existing list item

+
+
Parameters:
+

item_id – Id of the item to be delted

+
+
+
+ +
+
+get_item_by_id(item_id, expand_fields=None)[source]
+

Returns a sharepoint list item based on id

+
+
Parameters:
+
    +
  • item_id (int) – item id to search for

  • +
  • expand_fields (list or bool) – specify user-defined fields to return, +True will return all fields

  • +
+
+
Returns:
+

Sharepoint Item

+
+
Return type:
+

SharepointListItem

+
+
+
+ +
+
+get_items(limit=None, *, query=None, order_by=None, batch=None, expand_fields=None)[source]
+

Returns a collection of Sharepoint Items

+
+
Parameters:
+
    +
  • limit (int) – max no. of items to get. Over 999 uses batch.

  • +
  • query (Query or str) – applies a filter to the request.

  • +
  • order_by (Query or str) – orders the result set based on this condition

  • +
  • batch (int) – batch size, retrieves items in +batches allowing to retrieve more items than the limit.

  • +
  • expand_fields (list or bool) – specify user-defined fields to return, +True will return all fields

  • +
+
+
Returns:
+

list of Sharepoint Items

+
+
Return type:
+

list[SharepointListItem] or Pagination

+
+
+
+ +
+
+get_list_columns()[source]
+

Returns the sharepoint list columns

+
+ +
+
+column_name_cw
+

Column names

   Type: dict

+
+ +
+
+content_types_enabled
+

If true, indicates that content types are enabled for this list.

   Type: bool

+
+ +
+
+created
+

The date and time when the item was created.

   Type: datetime

+
+ +
+
+created_by
+

Identity of the creator of this item.

   Type: Contact

+
+ +
+
+description
+

The descriptive text for the item.

   Type: str

+
+ +
+
+display_name
+

The displayable title of the list.

   Type: str

+
+ +
+
+hidden
+

If true, indicates that the list isn’t normally visible in the SharePoint +user experience. +

   Type: bool

+
+ +
+
+modified
+

The date and time when the item was last modified.

   Type: datetime

+
+ +
+
+modified_by
+

Identity of the last modifier of this item.

   Type: Contact

+
+ +
+
+name
+

The name of the item.

   Type: str

+
+ +
+
+object_id
+

The ID of the content type.

   Type: str

+
+ +
+
+template
+

An enumerated value that represents the base list template used in creating +the list. Possible values include documentLibrary, genericList, task, +survey, announcements, contacts, and more. +

   Type: str

+
+ +
+
+web_url
+

URL that displays the item in the browser.

   Type: str

+
+ +
+ +
+
+class O365.sharepoint.SharepointListColumn(*, parent=None, con=None, **kwargs)[source]
+

Bases: ApiComponent

+

A Sharepoint List column within a SharepointList

+
+
+__init__(*, parent=None, con=None, **kwargs)[source]
+

Object initialization

+
+
Parameters:
+
    +
  • protocol (Protocol) – A protocol class or instance to be used with +this connection

  • +
  • main_resource (str) – main_resource to be used in these API +communications

  • +
+
+
+
+ +
+
+column_group
+

For site columns, the name of the group this column belongs to.

   Type: str

+
+ +
+
+description
+

The user-facing description of the column.

   Type: str

+
+ +
+
+display_name
+

he user-facing name of the column.

   Type: str

+
+ +
+
+enforce_unique_values
+

If true, no two list items may have the same value for this column.

   Type: bool

+
+ +
+
+field_type
+

Field type of the column.

   Type: str

+
+ +
+
+hidden
+

Specifies whether the column is displayed in the user interface.

   Type: bool

+
+ +
+
+indexed
+

Specifies whether the column values can be used for sorting and searching. +

   Type: bool

+
+ +
+
+internal_name
+

The API-facing name of the column as it appears in the fields on a listItem. +

   Type: str

+
+ +
+
+object_id
+

The unique identifier for the column.

   Type: str

+
+ +
+
+read_only
+

Specifies whether the column values can be modified.

   Type: bool

+
+ +
+
+required
+

Specifies whether the column value isn’t optional.

   Type: bool

+
+ +
+ +
+
+class O365.sharepoint.SharepointListItem(*, parent=None, con=None, **kwargs)[source]
+

Bases: ApiComponent

+
+
+__init__(*, parent=None, con=None, **kwargs)[source]
+

A Sharepoint ListItem within a SharepointList

+
+
Parameters:
+
    +
  • parent (SharepointList) – parent object

  • +
  • con (Connection) – connection to use if no parent specified

  • +
  • protocol (Protocol) – protocol to use if no parent specified +(kwargs)

  • +
  • main_resource (str) – use this resource instead of parent resource +(kwargs)

  • +
+
+
+
+ +
+
+delete()[source]
+
+ +
+
+save_updates()[source]
+

Save the updated fields to the cloud

+
+ +
+
+update_fields(updates)[source]
+

Update the value for a field(s) in the listitem

+
+
Parameters:
+

update – A dict of {‘field name’: newvalue}

+
+
+
+ +
+
+content_type_id
+

The ID of the content type.

   Type: str

+
+ +
+
+created
+

The date and time the item was created.

   Type: datetime

+
+ +
+
+created_by
+

Identity of the creator of this item.

   Type: contact

+
+ +
+
+fields
+

The fields of the item.

   Type: any

+
+ +
+
+modified
+

The date and time the item was last modified.

   Type: datetime

+
+ +
+
+modified_by
+

Identity of the last modifier of this item.

   Type: Contact

+
+ +
+
+object_id
+

The unique identifier of the item.

   Type: str

+
+ +
+
+web_url
+

URL that displays the item in the browser.

   Type: str

+
+ +
+ +
+
+class O365.sharepoint.Site(*, parent=None, con=None, **kwargs)[source]
+

Bases: ApiComponent

+

A Sharepoint Site

+
+
+__init__(*, parent=None, con=None, **kwargs)[source]
+

A Sharepoint site List

+
+
Parameters:
+
    +
  • parent (Sharepoint) – parent object

  • +
  • con (Connection) – connection to use if no parent specified

  • +
  • protocol (Protocol) – protocol to use if no parent specified +(kwargs)

  • +
  • main_resource (str) – use this resource instead of parent resource +(kwargs)

  • +
+
+
+
+ +
+
+create_list(list_data)[source]
+

Creates a SharePoint list. +:param list_data: Dict representation of list. +:type list_data: Dict +:rtype: list[SharepointList]

+
+ +
+
+get_default_document_library(request_drive=False)[source]
+

Returns the default document library of this site (Drive instance)

+
+
Parameters:
+

request_drive – True will make an api call to retrieve +the drive data

+
+
Return type:
+

Drive

+
+
+
+ +
+
+get_document_library(drive_id)[source]
+

Returns a Document Library (a Drive instance)

+
+
Parameters:
+

drive_id – the drive_id to be retrieved.

+
+
Return type:
+

Drive

+
+
+
+ +
+
+get_list_by_name(display_name)[source]
+

Returns a sharepoint list based on the display name of the list

+
+ +
+
+get_lists()[source]
+

Returns a collection of lists within this site

+
+
Return type:
+

list[SharepointList]

+
+
+
+ +
+
+get_subsites()[source]
+

Returns a list of subsites defined for this site

+
+
Return type:
+

list[Site]

+
+
+
+ +
+
+list_document_libraries()[source]
+

Returns a collection of document libraries for this site +(a collection of Drive instances) +:return: list of items in this folder +:rtype: list[Drive] or Pagination

+
+ +
+
+created
+

The date and time the item was created.

   Type: datetime

+
+ +
+
+description
+

The descriptive text for the site.

   Type: str

+
+ +
+
+display_name
+

The full title for the site.

   Type: str

+
+ +
+
+modified
+

The date and time the item was last modified.

   Type: datttime

+
+ +
+
+name
+

The name/title of the item.

   Type: str

+
+ +
+
+object_id
+

The unique identifier of the item.

   Type: str

+
+ +
+
+root
+

Indicates if this is the root site.

   Type: bool

+
+ +
+
+site_storage
+

The storage for the site.

   Type: Storage

+
+ +
+
+web_url
+

URL that displays the item in the browser.

   Type: str

+
+ +
+ +
+ + +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/docs/latest/api/subscriptions.html b/docs/latest/api/subscriptions.html new file mode 100644 index 00000000..e6523a3e --- /dev/null +++ b/docs/latest/api/subscriptions.html @@ -0,0 +1,209 @@ + + + + + + + + + Subscriptions — O365 documentation + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+ +
+
+
+
+ +
+

Subscriptions

+
+
+class O365.subscriptions.Subscriptions(*, parent=None, con=None, **kwargs)[source]
+

Bases: ApiComponent

+

Subscription operations for Microsoft Graph webhooks.

+
+
+__init__(*, parent=None, con=None, **kwargs)[source]
+

Object initialization

+
+
Parameters:
+
    +
  • protocol (Protocol) – A protocol class or instance to be used with +this connection

  • +
  • main_resource (str) – main_resource to be used in these API +communications

  • +
+
+
+
+ +
+
+create_subscription(notification_url: str, resource: str | None = None, change_type: str | Iterable[str] = 'created', *, expiration_datetime: datetime | None = None, expiration_minutes: int | None = None, client_state: str | None = None, include_resource_data: bool | None = None, encryption_certificate: str | None = None, encryption_certificate_id: str | None = None, lifecycle_notification_url: str | None = None, latest_supported_tls_version: str | None = None, additional_data: Mapping[str, object] | None = None, **request_kwargs) dict | None[source]
+

Create a Microsoft Graph webhook subscription.

+

See subscriptions usage documentation for webhook setup requirements.

+
+ +
+
+delete_subscription(subscription_id: str, **request_kwargs) bool[source]
+

Delete an existing webhook subscription.

+
+ +
+
+get_subscription(subscription_id: str, *, params: Mapping[str, object] | None = None, **request_kwargs) dict | None[source]
+

Retrieve a single webhook subscription by id.

+
+ +
+
+list_subscriptions(*, limit: int | None = None, **request_kwargs) Iterable[dict] | Pagination[source]
+

List webhook subscriptions visible to the current app/context.

+
+ +
+
+renew_subscription(subscription_id: str, *, expiration_datetime: datetime | None = None, expiration_minutes: int | None = None, **request_kwargs) dict | None[source]
+

Renew an existing webhook subscription.

+
+ +
+
+update_subscription(subscription_id: str, *, notification_url: str | None = None, expiration_datetime: datetime | None = None, expiration_minutes: int | None = None, **request_kwargs) dict | None[source]
+

Update subscription fields (expiration and/or notification URL).

+
+ +
+ +
+ + +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/docs/latest/api/tasks.html b/docs/latest/api/tasks.html new file mode 100644 index 00000000..f2a70a34 --- /dev/null +++ b/docs/latest/api/tasks.html @@ -0,0 +1,957 @@ + + + + + + + + + Tasks — O365 documentation + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+ +
+
+
+
+ +
+

Tasks

+

Methods for accessing MS Tasks/Todos via the MS Graph api.

+
+
+class O365.tasks.ChecklistItem(*, parent=None, con=None, **kwargs)[source]
+

Bases: ApiComponent

+

A Microsoft To-Do task CheckList Item.

+
+
+__init__(*, parent=None, con=None, **kwargs)[source]
+

Representation of a Microsoft To-Do task CheckList Item.

+
+
Parameters:
+
    +
  • parent (Task) – parent object

  • +
  • con (Connection) – connection to use if no parent specified

  • +
  • protocol (Protocol) – protocol to use if no parent specified +(kwargs)

  • +
  • main_resource (str) – use this resource instead of parent resource +(kwargs)

  • +
  • task_id (str) – id of the task to add this item in +(kwargs)

  • +
  • displayName (str) – display name of the item (kwargs)

  • +
+
+
+
+ +
+
+delete()[source]
+

Delete a stored checklist item.

+
+
Returns:
+

Success / Failure

+
+
Return type:
+

bool

+
+
+
+ +
+
+mark_checked()[source]
+

Mark the checklist item as checked.

+
+ +
+
+mark_unchecked()[source]
+

Mark the checklist item as unchecked.

+
+ +
+
+save()[source]
+

Create a new checklist item or update an existing one.

+

Does update by checking what values have changed and update them on the server +:return: Success / Failure +:rtype: bool

+
+ +
+
+to_api_data(restrict_keys=None)[source]
+

Return a dict to communicate with the server.

+
+
Parameters:
+

restrict_keys – a set of keys to restrict the returned data to

+
+
Return type:
+

dict

+
+
+
+ +
+
+property checked
+

Return Checked time of the task.

+
+
Type:
+

datetime

+
+
+
+ +
+
+property created
+

Return Created time of the task.

+
+
Type:
+

datetime

+
+
+
+ +
+
+property displayname
+

Return Display Name of the task.

+
+
Type:
+

str

+
+
+
+ +
+
+folder_id
+

Identifier of the folder of the containing task.

   Type: str

+
+ +
+
+property is_checked
+

Is the item checked.

+
+
Type:
+

bool

+
+
+
+ +
+
+item_id
+

Unique identifier for the item.

   Type: str

+
+ +
+
+task_id
+

Identifier of the containing task.

   Type: str

+
+ +
+ +
+
+class O365.tasks.Folder(*, parent=None, con=None, **kwargs)[source]
+

Bases: ApiComponent

+

A Microsoft To-Do folder.

+
+
+__init__(*, parent=None, con=None, **kwargs)[source]
+

Representation of a Microsoft To-Do Folder.

+
+
Parameters:
+
    +
  • parent (ToDo) – parent object

  • +
  • con (Connection) – connection to use if no parent specified

  • +
  • protocol (Protocol) – protocol to use if no parent specified +(kwargs)

  • +
  • main_resource (str) – use this resource instead of parent resource +(kwargs)

  • +
+
+
+
+ +
+
+delete()[source]
+

Delete this folder.

+
+
Returns:
+

Success / Failure

+
+
Return type:
+

bool

+
+
+
+ +
+
+get_task(param)[source]
+

Return a Task instance by it’s id.

+
+
Parameters:
+

param – an task_id or a Query instance

+
+
Returns:
+

task for the specified info

+
+
Return type:
+

Task

+
+
+
+ +
+
+get_tasks(query=None, batch=None, order_by=None)[source]
+

Return list of tasks of a specified folder.

+
+
Parameters:
+
    +
  • query – the query string or object to query tasks

  • +
  • batch – the batch on to retrieve tasks.

  • +
  • order_by – the order clause to apply to returned tasks.

  • +
+
+
Return type:
+

tasks

+
+
+
+ +
+
+new_task(subject=None)[source]
+

Create a task within a specified folder.

+
+ +
+
+update()[source]
+

Update this folder. Only name can be changed.

+
+
Returns:
+

Success / Failure

+
+
Return type:
+

bool

+
+
+
+ +
+
+folder_id
+

The identifier of the task list, unique in the user’s mailbox.

   Type: str

+
+ +
+
+is_default
+

Is the defaultList.

   Type: bool

+
+ +
+
+name
+

The name of the task list.

   Type: str

+
+ +
+ +
+
+class O365.tasks.Task(*, parent=None, con=None, **kwargs)[source]
+

Bases: ApiComponent

+

A Microsoft To-Do task.

+
+
+__init__(*, parent=None, con=None, **kwargs)[source]
+

Representation of a Microsoft To-Do task.

+
+
Parameters:
+
    +
  • parent (Folder) – parent object

  • +
  • con (Connection) – connection to use if no parent specified

  • +
  • protocol (Protocol) – protocol to use if no parent specified +(kwargs)

  • +
  • main_resource (str) – use this resource instead of parent resource +(kwargs)

  • +
  • folder_id (str) – id of the calender to add this task in +(kwargs)

  • +
  • subject (str) – subject of the task (kwargs)

  • +
+
+
+
+ +
+
+delete()[source]
+

Delete a stored task.

+
+
Returns:
+

Success / Failure

+
+
Return type:
+

bool

+
+
+
+ +
+
+get_body_soup()[source]
+

Return the beautifulsoup4 of the html body.

+
+
Returns:
+

Html body

+
+
Return type:
+

BeautifulSoup

+
+
+
+ +
+
+get_body_text()[source]
+

Parse the body html and returns the body text using bs4.

+
+
Returns:
+

body text

+
+
Return type:
+

str

+
+
+
+ +
+
+get_checklist_item(param)[source]
+

Return a Checklist Item instance by it’s id.

+
+
Parameters:
+

param – an item_id or a Query instance

+
+
Returns:
+

Checklist Item for the specified info

+
+
Return type:
+

ChecklistItem

+
+
+
+ +
+
+get_checklist_items(query=None, batch=None, order_by=None)[source]
+

Return list of checklist items of a specified task.

+
+
Parameters:
+
    +
  • query – the query string or object to query items

  • +
  • batch – the batch on to retrieve items.

  • +
  • order_by – the order clause to apply to returned items.

  • +
+
+
Return type:
+

checklistItems

+
+
+
+ +
+
+mark_completed()[source]
+

Mark the task as completed.

+
+ +
+
+mark_uncompleted()[source]
+

Mark the task as uncompleted.

+
+ +
+
+new_checklist_item(displayname=None)[source]
+

Create a checklist item within a specified task.

+
+ +
+
+save()[source]
+

Create a new task or update an existing one.

+

Does update by checking what values have changed and update them on the server +:return: Success / Failure +:rtype: bool

+
+ +
+
+to_api_data(restrict_keys=None)[source]
+

Return a dict to communicate with the server.

+
+
Parameters:
+

restrict_keys – a set of keys to restrict the returned data to

+
+
Return type:
+

dict

+
+
+
+ +
+
+property body
+

Return Body of the task.

+
+
Getter:
+

Get body text

+
+
Setter:
+

Set body of task

+
+
Type:
+

str

+
+
+
+ +
+
+body_type
+

The type of the content. Possible values are text and html.

   Type: str

+
+ +
+
+property checklist_items
+

Checklist items for the task.

+
+
Getter:
+

Get checklistItems

+
+
Type:
+

list[ChecklistItem]

+
+
+
+ +
+
+property completed
+

Completed Time of task.

+
+
Getter:
+

Get the completed time

+
+
Setter:
+

Set the completed time

+
+
Type:
+

datetime

+
+
+
+ +
+
+property created
+

Return Created time of the task.

+
+
Type:
+

datetime

+
+
+
+ +
+
+property due
+

Due Time of task.

+
+
Getter:
+

Get the due time

+
+
Setter:
+

Set the due time

+
+
Type:
+

datetime

+
+
+
+ +
+
+folder_id
+

Identifier of the containing folder.

   Type: str

+
+ +
+
+property importance
+

Return Task importance.

+
+
Getter:
+

Get importance level (Low, Normal, High)

+
+
Type:
+

str

+
+
+
+ +
+
+property is_completed
+

Is task completed or not.

+
+
Getter:
+

Is completed

+
+
Setter:
+

Set the task to completed

+
+
Type:
+

bool

+
+
+
+ +
+
+property is_reminder_on
+

Return isReminderOn of the task.

+
+
Getter:
+

Get isReminderOn

+
+
Type:
+

bool

+
+
+
+ +
+
+property is_starred
+

Is the task starred (high importance).

+
+
Getter:
+

Check if importance is high

+
+
Type:
+

bool

+
+
+
+ +
+
+property modified
+

Return Last modified time of the task.

+
+
Type:
+

datetime

+
+
+
+ +
+
+property reminder
+

Reminder Time of task.

+
+
Getter:
+

Get the reminder time

+
+
Setter:
+

Set the reminder time

+
+
Type:
+

datetime

+
+
+
+ +
+
+property status
+

Status of task

+
+
Getter:
+

Get status

+
+
Type:
+

str

+
+
+
+ +
+
+property subject
+

Subject of the task.

+
+
Getter:
+

Get subject

+
+
Setter:
+

Set subject of task

+
+
Type:
+

str

+
+
+
+ +
+
+task_id
+

Unique identifier for the task.

   Type: str

+
+ +
+ +
+
+class O365.tasks.ToDo(*, parent=None, con=None, **kwargs)[source]
+

Bases: ApiComponent

+

A Microsoft To-Do class for MS Graph API.

+

In order to use the API following permissions are required. +Delegated (work or school account) - Tasks.Read, Tasks.ReadWrite

+
+
+__init__(*, parent=None, con=None, **kwargs)[source]
+

Initialise the ToDo object.

+
+
Parameters:
+
    +
  • parent (Account) – parent object

  • +
  • con (Connection) – connection to use if no parent specified

  • +
  • protocol (Protocol) – protocol to use if no parent specified +(kwargs)

  • +
  • main_resource (str) – use this resource instead of parent resource +(kwargs)

  • +
+
+
+
+ +
+
+get_default_folder()[source]
+

Return the default folder for the current user.

+
+
Return type:
+

Folder

+
+
+
+ +
+
+get_folder(folder_id=None, folder_name=None)[source]
+

Return a folder by it’s id or name.

+
+
Parameters:
+
    +
  • folder_id (str) – the folder id to be retrieved.

  • +
  • folder_name (str) – the folder name to be retrieved.

  • +
+
+
Returns:
+

folder for the given info

+
+
Return type:
+

Folder

+
+
+
+ +
+
+get_tasks(batch=None, order_by=None)[source]
+

Get tasks from the default Folder.

+
+
Parameters:
+
    +
  • order_by – orders the result set based on this condition

  • +
  • batch (int) – batch size, retrieves items in +batches allowing to retrieve more items than the limit.

  • +
+
+
Returns:
+

list of items in this folder

+
+
Return type:
+

list[Task] or Pagination

+
+
+
+ +
+
+list_folders(query=None, limit=None)[source]
+

Return a list of folders.

+

To use query an order_by check the OData specification here: +https://docs.oasis-open.org/odata/odata/v4.0/errata03/os/complete/ +part2-url-conventions/odata-v4.0-errata03-os-part2-url-conventions +-complete.html +:param query: the query string or object to list folders +:param int limit: max no. of folders to get. Over 999 uses batch. +:rtype: list[Folder]

+
+ +
+
+list_folders_delta(query=None, limit=None)[source]
+

Return a list of folders using the delta endpoint.

+

To use query an order_by check the OData specification here: +https://docs.oasis-open.org/odata/odata/v4.0/errata03/os/complete/ +part2-url-conventions/odata-v4.0-errata03-os-part2-url-conventions +-complete.html +:param query: the query string or object to list folders +:param int limit: max no. of folders to get. Over 999 uses batch. +:rtype: list[Folder]

+
+ +
+
+new_folder(folder_name)[source]
+

Create a new folder.

+
+
Parameters:
+

folder_name (str) – name of the new folder

+
+
Returns:
+

a new folder instance

+
+
Return type:
+

Folder

+
+
+
+ +
+
+new_task(subject=None)[source]
+

Return a new (unsaved) Task object in the default folder.

+
+
Parameters:
+

subject (str) – subject text for the new task

+
+
Returns:
+

new task

+
+
Return type:
+

Task

+
+
+
+ +
+ +
+ + +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/docs/latest/api/teams.html b/docs/latest/api/teams.html new file mode 100644 index 00000000..8a43ae48 --- /dev/null +++ b/docs/latest/api/teams.html @@ -0,0 +1,1130 @@ + + + + + + + + + Teams — O365 documentation + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+ +
+
+
+
+ +
+

Teams

+
+
+class O365.teams.Activity(*values)[source]
+

Bases: Enum

+

Valid values for Activity.

+
+
+AVAILABLE = 'Available'
+
+ +
+
+AWAY = 'Away'
+
+ +
+
+INACALL = 'InACall'
+
+ +
+
+INACONFERENCECALL = 'InAConferenceCall'
+
+ +
+
+PRESENTING = 'Presenting'
+
+ +
+ +
+
+class O365.teams.App(*, parent=None, con=None, **kwargs)[source]
+

Bases: ApiComponent

+

A Microsoft Teams app

+
+
+__init__(*, parent=None, con=None, **kwargs)[source]
+

A Microsoft Teams app

+
+
Parameters:
+
    +
  • parent (Teams) – parent object

  • +
  • con (Connection) – connection to use if no parent specified

  • +
  • protocol (Protocol) – protocol to use if no parent specified +(kwargs)

  • +
  • main_resource (str) – use this resource instead of parent resource +(kwargs)

  • +
+
+
+
+ +
+
+app_definition
+

The details for each version of the app.

   Type: list[teamsAppDefinition]

+
+ +
+
+object_id
+

The app ID generated for the catalog is different from the developer-provided +ID found within the Microsoft Teams zip app package. The externalId value is +empty for apps with a distributionMethod type of store. When apps are +published to the global store, the id of the app matches the id in the app manifest. +

   Type: str

+
+ +
+ +
+
+class O365.teams.Availability(*values)[source]
+

Bases: Enum

+

Valid values for Availability.

+
+
+AVAILABLE = 'Available'
+
+ +
+
+AWAY = 'Away'
+
+ +
+
+BUSY = 'Busy'
+
+ +
+
+DONOTDISTURB = 'DoNotDisturb'
+
+ +
+ +
+
+class O365.teams.Channel(*, parent=None, con=None, **kwargs)[source]
+

Bases: ApiComponent

+

A Microsoft Teams channel

+
+
+__init__(*, parent=None, con=None, **kwargs)[source]
+

A Microsoft Teams channel

+
+
Parameters:
+
    +
  • parent (Teams or Team) – parent object

  • +
  • con (Connection) – connection to use if no parent specified

  • +
  • protocol (Protocol) – protocol to use if no parent specified +(kwargs)

  • +
  • main_resource (str) – use this resource instead of parent resource +(kwargs)

  • +
+
+
+
+ +
+
+get_message(message_id)[source]
+

Returns a specified channel chat messages +:param message_id: number of messages to retrieve +:type message_id: int or str +:rtype: ChannelMessage

+
+ +
+
+get_messages(limit=None, batch=None)[source]
+

Returns a list of channel chat messages +:param int limit: number of messages to retrieve +:param int batch: number of messages to be in each data set +:rtype: list[ChannelMessage] or Pagination of ChannelMessage

+
+ +
+
+send_message(content=None, content_type='text')[source]
+

Sends a message to the channel +:param content: str of text, str of html, or dict representation of json body +:type content: str or dict +:param str content_type: ‘text’ to render the content as text or ‘html’ to render the content as html +:rtype: ChannelMessage

+
+ +
+
+description
+

Optional textual description for the channel.

   Type: str

+
+ +
+
+display_name
+

Channel name as it will appear to the user in Microsoft Teams. +

   Type: str

+
+ +
+
+email
+

The email address for sending messages to the channel.

   Type: str

+
+ +
+
+object_id
+

The channel’s unique identifier.

   Type: str

+
+ +
+ +
+
+class O365.teams.ChannelMessage(**kwargs)[source]
+

Bases: ChatMessage

+

A Microsoft Teams chat message that is the start of a channel thread

+
+
+__init__(**kwargs)[source]
+

A Microsoft Teams chat message that is the start of a channel thread

+
+ +
+
+get_replies(limit=None, batch=None)[source]
+

Returns a list of replies to the channel chat message +:param int limit: number of replies to retrieve +:param int batch: number of replies to be in each data set +:rtype: list or Pagination

+
+ +
+
+get_reply(message_id)[source]
+

Returns a specified reply to the channel chat message +:param message_id: the message_id of the reply to retrieve +:type message_id: str or int +:rtype: ChatMessage

+
+ +
+
+send_reply(content=None, content_type='text')[source]
+

Sends a reply to the channel chat message +:param content: str of text, str of html, or dict representation of json body +:type content: str or dict +:param str content_type: ‘text’ to render the content as text or ‘html’ to render the content as html

+
+ +
+
+channel_id
+

The identity of the team in which the message was posted.

   Type: str

+
+ +
+
+team_id
+

The identity of the channel in which the message was posted.

   Type: str

+
+ +
+ +
+
+class O365.teams.Chat(*, parent=None, con=None, **kwargs)[source]
+

Bases: ApiComponent

+

A Microsoft Teams chat

+
+
+__init__(*, parent=None, con=None, **kwargs)[source]
+

A Microsoft Teams chat +:param parent: parent object +:type parent: Teams +:param Connection con: connection to use if no parent specified +:param Protocol protocol: protocol to use if no parent specified (kwargs) +:param str main_resource: use this resource instead of parent resource (kwargs)

+
+ +
+
+get_member(membership_id)[source]
+

Returns a specified conversation member +:param str membership_id: membership_id of member to retrieve +:rtype: ConversationMember

+
+ +
+
+get_members()[source]
+

Returns a list of conversation members +:rtype: list[ConversationMember]

+
+ +
+
+get_message(message_id)[source]
+

Returns a specified message from the chat +:param message_id: the message_id of the message to receive +:type message_id: str or int +:rtype: ChatMessage

+
+ +
+
+get_messages(limit=None, batch=None)[source]
+

Returns a list of chat messages from the chat +:param int limit: number of replies to retrieve +:param int batch: number of replies to be in each data set +:rtype: list[ChatMessage] or Pagination of ChatMessage

+
+ +
+
+send_message(content=None, content_type='text')[source]
+

Sends a message to the chat +:param content: str of text, str of html, or dict representation of json body +:type content: str or dict +:param str content_type: ‘text’ to render the content as text or ‘html’ to render the content as html +:rtype: ChatMessage

+
+ +
+
+chat_type
+

Specifies the type of chat. +Possible values are: group, oneOnOne, meeting, unknownFutureValue. +

   Type: chatType

+
+ +
+
+created_date
+

Date and time at which the chat was created.

   Type: datetime

+
+ +
+
+last_update_date
+

Date and time at which the chat was renamed or +the list of members was last changed.

   Type: datetime

+
+ +
+
+object_id
+

The chat’s unique identifier.

   Type: str

+
+ +
+
+topic
+

Subject or topic for the chat. Only available for group chats. +

   Type: str

+
+ +
+
+web_url
+

The URL for the chat in Microsoft Teams.

   Type: str

+
+ +
+ +
+
+class O365.teams.ChatMessage(*, parent=None, con=None, **kwargs)[source]
+

Bases: ApiComponent

+

A Microsoft Teams chat message

+
+
+__init__(*, parent=None, con=None, **kwargs)[source]
+

A Microsoft Teams chat message +:param parent: parent object +:type parent: Channel, Chat, or ChannelMessage +:param Connection con: connection to use if no parent specified +:param Protocol protocol: protocol to use if no parent specified (kwargs) +:param str main_resource: use this resource instead of parent resource (kwargs)

+
+ +
+
+channel_identity
+

If the message was sent in a channel, represents identity of the channel. +

   Type: channelIdentity

+
+ +
+
+chat_id
+

If the message was sent in a chat, represents the identity of the chat. +

   Type: str

+
+ +
+
+content
+

The content of the item.

   Type: str

+
+ +
+
+content_type
+

The type of the content. Possible values are text and html. +

   Type: bodyType

+
+ +
+
+created_date
+

Timestamp of when the chat message was created.

   Type: datetime

+
+ +
+
+deleted_date
+

Timestamp at which the chat message was deleted, or null if not deleted. +

   Type: datetime

+
+ +
+
+from_display_name
+

Name of the user or application message was sent from. +

   Type: str

+
+ +
+
+from_id
+

Id of the user or application message was sent from. +

   Type: str

+
+ +
+
+from_type
+

Type of the user or application message was sent from. +

   Type: any

+
+ +
+
+importance
+

The importance of the chat message.

   Type: str

+
+ +
+
+last_edited_date
+

Timestamp when edits to the chat message were made. +Triggers an “Edited” flag in the Teams UI.

   Type: datetime

+
+ +
+
+last_modified_date
+

Timestamp when the chat message is created (initial setting) +or modified, including when a reaction is added or removed. +

   Type: datetime

+
+ +
+
+message_type
+

The type of chat message.

   Type: chatMessageType

+
+ +
+
+object_id
+

Unique ID of the message.

   Type: str

+
+ +
+
+reply_to_id
+

ID of the parent chat message or root chat message of the thread. +

   Type: str

+
+ +
+
+subject
+

The subject of the chat message, in plaintext.

   Type: str

+
+ +
+
+summary
+

Summary text of the chat message that could be used for +push notifications and summary views or fall back views.

   Type: str

+
+ +
+
+web_url
+

Link to the message in Microsoft Teams.

   Type: str

+
+ +
+ +
+
+class O365.teams.ConversationMember(*, parent=None, con=None, **kwargs)[source]
+

Bases: ApiComponent

+

A Microsoft Teams conversation member

+
+
+__init__(*, parent=None, con=None, **kwargs)[source]
+

A Microsoft Teams conversation member +:param parent: parent object +:type parent: Chat +:param Connection con: connection to use if no parent specified +:param Protocol protocol: protocol to use if no parent specified (kwargs) +:param str main_resource: use this resource instead of parent resource (kwargs)

+
+ +
+ +
+
+class O365.teams.PreferredActivity(*values)[source]
+

Bases: Enum

+

Valid values for Activity.

+
+
+AVAILABLE = 'Available'
+
+ +
+
+AWAY = 'Away'
+
+ +
+
+BERIGHTBACK = 'BeRightBack'
+
+ +
+
+BUSY = 'Busy'
+
+ +
+
+DONOTDISTURB = 'DoNotDisturb'
+
+ +
+
+OFFWORK = 'OffWork'
+
+ +
+ +
+
+class O365.teams.PreferredAvailability(*values)[source]
+

Bases: Enum

+

Valid values for Availability.

+
+
+AVAILABLE = 'Available'
+
+ +
+
+AWAY = 'Away'
+
+ +
+
+BERIGHTBACK = 'BeRightBack'
+
+ +
+
+BUSY = 'Busy'
+
+ +
+
+DONOTDISTURB = 'DoNotDisturb'
+
+ +
+
+OFFLINE = 'Offline'
+
+ +
+ +
+
+class O365.teams.Presence(*, parent=None, con=None, **kwargs)[source]
+

Bases: ApiComponent

+

Microsoft Teams Presence

+
+
+__init__(*, parent=None, con=None, **kwargs)[source]
+

Microsoft Teams Presence

+
+
Parameters:
+
    +
  • parent (Teams) – parent object

  • +
  • con (Connection) – connection to use if no parent specified

  • +
  • protocol (Protocol) – protocol to use if no parent specified +(kwargs)

  • +
  • main_resource (str) – use this resource instead of parent resource +(kwargs)

  • +
+
+
+
+ +
+
+activity
+

The supplemental information to a user’s availability. +Possible values are Available, Away, BeRightBack, Busy, DoNotDisturb, +InACall, InAConferenceCall, Inactive, InAMeeting, Offline, OffWork, +OutOfOffice, PresenceUnknown, Presenting, UrgentInterruptionsOnly. +

   Type: list[str]

+
+ +
+
+availability
+

The base presence information for a user. +Possible values are Available, AvailableIdle, Away, BeRightBack, +Busy, BusyIdle, DoNotDisturb, Offline, PresenceUnknown +

   Type: list[str]

+
+ +
+
+object_id
+

The unique identifier for the user.

   Type: str

+
+ +
+ +
+
+class O365.teams.Team(*, parent=None, con=None, **kwargs)[source]
+

Bases: ApiComponent

+

A Microsoft Teams team

+
+
+__init__(*, parent=None, con=None, **kwargs)[source]
+

A Microsoft Teams team

+
+
Parameters:
+
    +
  • parent (Teams) – parent object

  • +
  • con (Connection) – connection to use if no parent specified

  • +
  • protocol (Protocol) – protocol to use if no parent specified +(kwargs)

  • +
  • main_resource (str) – use this resource instead of parent resource +(kwargs)

  • +
+
+
+
+ +
+
+get_channel(channel_id)[source]
+

Returns a channel of the team

+
+
Parameters:
+

channel_id – the team_id of the channel to be retrieved.

+
+
Return type:
+

Channel

+
+
+
+ +
+
+get_channels()[source]
+

Returns a list of channels the team

+
+
Return type:
+

list[Channel]

+
+
+
+ +
+
+description
+

An optional description for the team.

   Type: str

+
+ +
+
+display_name
+

The name of the team.

   Type: str

+
+ +
+
+is_archived
+

Whether this team is in read-only mode.

   Type: bool

+
+ +
+
+object_id
+

The unique identifier of the team.

   Type: str

+
+ +
+
+web_url
+

A hyperlink that goes to the team in the Microsoft Teams client. +

   Type: str

+
+ +
+ +
+
+class O365.teams.Teams(*, parent=None, con=None, **kwargs)[source]
+

Bases: ApiComponent

+

A Microsoft Teams class

+
+
+__init__(*, parent=None, con=None, **kwargs)[source]
+

A Teams object

+
+
Parameters:
+
    +
  • parent (Account) – parent object

  • +
  • con (Connection) – connection to use if no parent specified

  • +
  • protocol (Protocol) – protocol to use if no parent specified +(kwargs)

  • +
  • main_resource (str) – use this resource instead of parent resource +(kwargs)

  • +
+
+
+
+ +
+
+create_channel(team_id, display_name, description=None)[source]
+

Creates a channel within a specified team

+
+
Parameters:
+
    +
  • team_id – the team_id where the channel is created.

  • +
  • display_name – the channel display name.

  • +
  • description – the channel description.

  • +
+
+
Return type:
+

Channel

+
+
+
+ +
+
+get_apps_in_team(team_id)[source]
+

Returns a list of apps of a specified team

+
+
Parameters:
+

team_id – the team_id of the team to get the apps of.

+
+
Return type:
+

list[App]

+
+
+
+ +
+
+get_channel(team_id, channel_id)[source]
+

Returns the channel info for a given channel

+
+
Parameters:
+
    +
  • team_id – the team_id of the channel.

  • +
  • channel_id – the channel_id of the channel.

  • +
+
+
Return type:
+

list[Channel]

+
+
+
+ +
+
+get_channels(team_id)[source]
+

Returns a list of channels of a specified team

+
+
Parameters:
+

team_id – the team_id of the channel to be retrieved.

+
+
Return type:
+

list[Channel]

+
+
+
+ +
+
+get_my_chats(limit=None, batch=None)[source]
+

Returns a list of chats that I am in +:param int limit: number of chats to retrieve +:param int batch: number of chats to be in each data set +:rtype: list[ChatMessage] or Pagination of Chat

+
+ +
+
+get_my_presence()[source]
+

Returns my availability and activity

+
+
Return type:
+

Presence

+
+
+
+ +
+
+get_my_teams()[source]
+

Returns a list of teams that I am in

+
+
Return type:
+

list[Team]

+
+
+
+ +
+
+get_user_presence(user_id=None, email=None)[source]
+

Returns specific user availability and activity

+
+
Return type:
+

Presence

+
+
+
+ +
+
+set_my_presence(session_id, availability: Availability, activity: Activity, expiration_duration)[source]
+

Sets my presence status

+
+
Parameters:
+
    +
  • session_id – the session/capplication id.

  • +
  • availability – the availability.

  • +
  • activity – the activity.

  • +
  • activity – the expiration_duration when status will be unset.

  • +
+
+
Return type:
+

Presence

+
+
+
+ +
+
+set_my_user_preferred_presence(availability: PreferredAvailability, activity: PreferredActivity, expiration_duration)[source]
+

Sets my user preferred presence status

+
+
Parameters:
+
    +
  • availability – the availability.

  • +
  • activity – the activity.

  • +
  • activity – the expiration_duration when status will be unset.

  • +
+
+
Return type:
+

Presence

+
+
+
+ +
+ +
+ + +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/docs/latest/api/utils.html b/docs/latest/api/utils.html new file mode 100644 index 00000000..a5d0fb3e --- /dev/null +++ b/docs/latest/api/utils.html @@ -0,0 +1,203 @@ + + + + + + + + + Utils — O365 documentation + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/latest/api/utils/attachment.html b/docs/latest/api/utils/attachment.html new file mode 100644 index 00000000..81c8ad3e --- /dev/null +++ b/docs/latest/api/utils/attachment.html @@ -0,0 +1,438 @@ + + + + + + + + + Attachment — O365 documentation + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+ +
+
+
+
+ +
+

Attachment

+
+
+class O365.utils.attachment.AttachableMixin(attachment_name_property=None, attachment_type=None)[source]
+

Bases: object

+
+
+__init__(attachment_name_property=None, attachment_type=None)[source]
+

Defines the functionality for an object to be attachable. +Any object that inherits from this class will be attachable +(if the underlying api allows that)

+
+ +
+
+to_api_data()[source]
+

Returns a dict to communicate with the server

+
+
Return type:
+

dict

+
+
+
+ +
+
+property attachment_name
+

Name of the attachment

+
+
Getter:
+

get attachment name

+
+
Setter:
+

set new name for the attachment

+
+
Type:
+

str

+
+
+
+ +
+
+property attachment_type
+

Type of attachment

+
+
Return type:
+

str

+
+
+
+ +
+ +
+
+class O365.utils.attachment.BaseAttachment(attachment=None, *, parent=None, **kwargs)[source]
+

Bases: ApiComponent

+

BaseAttachment class is the base object for dealing with attachments

+
+
+__init__(attachment=None, *, parent=None, **kwargs)[source]
+

Creates a new attachment, optionally from existing cloud data

+
+
Parameters:
+
    +
  • attachment (dict or str or Path or list[str] or AttachableMixin) – attachment data (dict = cloud data, +other = user data)

  • +
  • parent (BaseAttachments) – the parent Attachments

  • +
  • protocol (Protocol) – protocol to use if no parent specified +(kwargs)

  • +
  • main_resource (str) – use this resource instead of parent resource +(kwargs)

  • +
+
+
+
+ +
+
+attach(api_object, on_cloud=False)[source]
+

Attach this attachment to an existing api_object. This +BaseAttachment object must be an orphan BaseAttachment created for the +sole purpose of attach it to something and therefore run this method.

+
+
Parameters:
+
    +
  • api_object – object to attach to

  • +
  • on_cloud – if the attachment is on cloud or not

  • +
+
+
Returns:
+

Success / Failure

+
+
Return type:
+

bool

+
+
+
+ +
+
+save(location=None, custom_name=None)[source]
+

Save the attachment locally to disk

+
+
Parameters:
+
    +
  • location (str) – path string to where the file is to be saved.

  • +
  • custom_name (str) – a custom name to be saved as

  • +
+
+
Returns:
+

Success / Failure

+
+
Return type:
+

bool

+
+
+
+ +
+
+to_api_data()[source]
+

Returns a dict to communicate with the server

+
+
Return type:
+

dict

+
+
+
+ +
+
+attachment
+

Path to the attachment if on disk

   Type: Path

+
+ +
+
+attachment_id
+

The attachment’s id. Default ‘file’

   Type: str

+
+ +
+
+attachment_type
+

The attachment’s type. Default ‘file’

   Type: str

+
+ +
+
+content
+

Content of the attachment

   Type: any

+
+ +
+
+content_id
+

The attachment’s content id Default ‘file’.

   Type: str

+
+ +
+
+is_inline
+

true if the attachment is an inline attachment; otherwise, false.

   Type: bool

+
+ +
+
+name
+

The attachment’s file name.

   Type: str

+
+ +
+
+on_cloud
+

Indicates if the attachment is stored on cloud.

   Type: bool

+
+ +
+
+on_disk
+

Indicates if the attachment is stored on disk.

   Type: bool

+
+ +
+ +
+
+class O365.utils.attachment.BaseAttachments(parent, attachments=None)[source]
+

Bases: ApiComponent

+

A Collection of BaseAttachments

+
+
+__init__(parent, attachments=None)[source]
+

Attachments must be a list of path strings or dictionary elements

+
+
Parameters:
+
    +
  • parent (Account) – parent object

  • +
  • attachments (list[str] or list[Path] or str or Path or dict) – list of attachments

  • +
+
+
+
+ +
+
+add(attachments)[source]
+

Add more attachments

+
+
Parameters:
+

attachments (list[str] or list[Path] or str or Path or dict) – list of attachments

+
+
+
+ +
+
+clear()[source]
+

Clear the attachments

+
+ +
+
+download_attachments()[source]
+

Downloads this message attachments into memory. +Need a call to ‘attachment.save’ to save them on disk.

+
+
Returns:
+

Success / Failure

+
+
Return type:
+

bool

+
+
+
+ +
+
+remove(attachments)[source]
+

Remove the specified attachments

+
+
Parameters:
+

attachments (list[str] or list[Path] or str or Path or dict) – list of attachments

+
+
+
+ +
+
+to_api_data()[source]
+

Returns a dict to communicate with the server

+
+
Return type:
+

dict

+
+
+
+ +
+ +
+
+class O365.utils.attachment.UploadSessionRequest(parent, attachment)[source]
+

Bases: ApiComponent

+
+
+__init__(parent, attachment)[source]
+

Object initialization

+
+
Parameters:
+
    +
  • protocol (Protocol) – A protocol class or instance to be used with +this connection

  • +
  • main_resource (str) – main_resource to be used in these API +communications

  • +
+
+
+
+ +
+
+to_api_data()[source]
+
+ +
+ +
+ + +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/docs/latest/api/utils/query.html b/docs/latest/api/utils/query.html new file mode 100644 index 00000000..7dba9e59 --- /dev/null +++ b/docs/latest/api/utils/query.html @@ -0,0 +1,906 @@ + + + + + + + + + Query — O365 documentation + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+ +
+
+
+
+ +
+

Query

+
+
+class O365.utils.query.ChainFilter(operation: str, filter_instances: list[QueryFilter])[source]
+

Bases: OperationQueryFilter

+
+
+__init__(operation: str, filter_instances: list[QueryFilter])[source]
+
+ +
+
+render(item_name: str | None = None) str[source]
+
+ +
+ +
+
+class O365.utils.query.CompositeFilter(*, filters: QueryFilter | None = None, search: SearchFilter | None = None, order_by: OrderByFilter | None = None, select: SelectFilter | None = None, expand: ExpandFilter | None = None)[source]
+

Bases: QueryBase

+

A Query object that holds all query parameters.

+
+
+__init__(*, filters: QueryFilter | None = None, search: SearchFilter | None = None, order_by: OrderByFilter | None = None, select: SelectFilter | None = None, expand: ExpandFilter | None = None)[source]
+
+ +
+
+as_params() dict[source]
+
+ +
+
+clear_filters() None[source]
+

Removes all filters from the query

+
+ +
+
+render() str[source]
+
+ +
+
+expand: ExpandFilter | None
+
+ +
+
+filters: QueryFilter | None
+
+ +
+
+property has_expands: bool
+

Returns if this CompositeFilter has expands

+
+ +
+
+property has_filters: bool
+

Returns if this CompositeFilter has filters

+
+ +
+
+property has_only_filters: bool
+

Returns true if it only has filters

+
+ +
+
+property has_order_by: bool
+

Returns if this CompositeFilter has order_by

+
+ +
+ +

Returns if this CompositeFilter has search

+
+ +
+
+property has_selects: bool
+

Returns if this CompositeFilter has selects

+
+ +
+
+order_by: OrderByFilter | None
+
+ +
+
+search: SearchFilter | None
+
+ +
+
+select: SelectFilter | None
+
+ +
+ +
+
+class O365.utils.query.ContainerQueryFilter(*args: str | tuple[str, SelectFilter])[source]
+

Bases: QueryBase

+
+
+__init__(*args: str | tuple[str, SelectFilter])[source]
+
+ +
+
+append(item: str | tuple[str, SelectFilter]) None[source]
+
+ +
+
+as_params() dict[source]
+
+ +
+
+render() str[source]
+
+ +
+ +
+
+class O365.utils.query.ExpandFilter(*args: str | tuple[str, SelectFilter])[source]
+

Bases: ContainerQueryFilter

+
+
+__init__(*args: str | tuple[str, SelectFilter])[source]
+
+ +
+
+render() str[source]
+
+ +
+ +
+
+class O365.utils.query.FunctionFilter(operation: str, attribute: str, word: str)[source]
+

Bases: LogicalFilter

+
+
+render(item_name: str | None = None) str[source]
+
+ +
+ +
+
+class O365.utils.query.GroupFilter(filter_instance: QueryFilter)[source]
+

Bases: ModifierQueryFilter

+
+
+render(item_name: str | None = None) str[source]
+
+ +
+ +
+
+class O365.utils.query.IterableFilter(operation: str, collection: str, filter_instance: QueryFilter, *, item_name: str = 'a')[source]
+

Bases: OperationQueryFilter

+
+
+__init__(operation: str, collection: str, filter_instance: QueryFilter, *, item_name: str = 'a')[source]
+
+ +
+
+render(item_name: str | None = None) str[source]
+
+ +
+ +
+
+class O365.utils.query.LogicalFilter(operation: str, attribute: str, word: str)[source]
+

Bases: OperationQueryFilter

+
+
+__init__(operation: str, attribute: str, word: str)[source]
+
+ +
+
+render(item_name: str | None = None) str[source]
+
+ +
+ +
+
+class O365.utils.query.ModifierQueryFilter(filter_instance: QueryFilter)[source]
+

Bases: QueryFilter, ABC

+
+
+__init__(filter_instance: QueryFilter)[source]
+
+ +
+ +
+
+class O365.utils.query.NegateFilter(filter_instance: QueryFilter)[source]
+

Bases: ModifierQueryFilter

+
+
+render(item_name: str | None = None) str[source]
+
+ +
+ +
+
+class O365.utils.query.OperationQueryFilter(operation: str)[source]
+

Bases: QueryFilter, ABC

+
+
+__init__(operation: str)[source]
+
+ +
+ +
+
+class O365.utils.query.OrderByFilter[source]
+

Bases: QueryBase

+
+
+__init__()[source]
+
+ +
+
+add(attribute: str, ascending: bool = True) None[source]
+
+ +
+
+as_params() dict[source]
+
+ +
+
+render() str[source]
+
+ +
+ +
+
+class O365.utils.query.QueryBase[source]
+

Bases: ABC

+
+
+abstractmethod as_params() dict[source]
+
+ +
+
+get_filter_by_attribute(attribute: str) str | None[source]
+

Returns a filter value by attribute name. It will match the attribute to the start of each filter attribute +and return the first found.

+
+
Parameters:
+

attribute – the attribute you want to search

+
+
Returns:
+

The value applied to that attribute or None

+
+
+
+ +
+
+abstractmethod render() str[source]
+
+ +
+ +
+
+class O365.utils.query.QueryBuilder(protocol: Protocol | Type[Protocol])[source]
+

Bases: object

+
+
+static group(filter_instance: CompositeFilter) CompositeFilter[source]
+

Applies a grouping to the provided filter_instance

+
+ +
+
+static negate(filter_instance: CompositeFilter) CompositeFilter[source]
+

Apply a not operator to the provided QueryFilter +:param filter_instance: a CompositeFilter instance +:return: a CompositeFilter with its filter negated

+
+ +
+
+static orderby(*attributes: tuple[str | tuple[str, bool]]) CompositeFilter[source]
+

Returns an ‘order by’ query param +This is useful to order the result set of query from a resource. +Note that not all attributes can be sorted and that all resources have different sort capabilities

+
+
Parameters:
+

attributes – the attributes to orderby

+
+
Returns:
+

a CompositeFilter instance that can render the OData order by operation

+
+
+
+ +
+
+__init__(protocol: Protocol | Type[Protocol])[source]
+

Build a query to apply OData filters +https://docs.microsoft.com/en-us/graph/query-parameters

+
+
Parameters:
+

protocol (Protocol) – protocol to retrieve the timezone from

+
+
+
+ +
+
+all(collection: str, filter_instance: CompositeFilter, *, item_name: str = 'a') CompositeFilter[source]
+

Performs a filter with the OData ‘all’ keyword on the collection

+

For example: +q.all(collection=’email_addresses’, filter_instance=q.equals(‘address’, ‘george@best.com’))

+

will transform to a filter such as:

+

emailAddresses/all(a:a/address eq ‘george@best.com’)

+
+
Parameters:
+
    +
  • collection – the collection to apply the iterable operation on

  • +
  • filter_instance – a CompositeFilter Instance on which you will apply the iterable operation

  • +
  • item_name – the name of the collection item to be used on the filter_instance

  • +
+
+
Returns:
+

a CompositeFilter instance that can render the OData iterable operation

+
+
+
+ +
+
+any(collection: str, filter_instance: CompositeFilter, *, item_name: str = 'a') CompositeFilter[source]
+

Performs a filter with the OData ‘any’ keyword on the collection

+

For example: +q.any(collection=’email_addresses’, filter_instance=q.equals(‘address’, ‘george@best.com’))

+

will transform to a filter such as:

+

emailAddresses/any(a:a/address eq ‘george@best.com’)

+
+
Parameters:
+
    +
  • collection – the collection to apply the iterable operation on

  • +
  • filter_instance – a CompositeFilter Instance on which you will apply the iterable operation

  • +
  • item_name – the name of the collection item to be used on the filter_instance

  • +
+
+
Returns:
+

a CompositeFilter instance that can render the OData iterable operation

+
+
+
+ +
+
+chain_and(*filter_instances: CompositeFilter, group: bool = False) CompositeFilter[source]
+

Start a chain ‘and’ operation

+
+
Parameters:
+
    +
  • filter_instances – a list of other CompositeFilter you want to combine with the ‘and’ operation

  • +
  • group – will group this chain operation if True

  • +
+
+
Returns:
+

a CompositeFilter with the filter instances combined with an ‘and’ operation

+
+
+
+ +
+
+chain_or(*filter_instances: CompositeFilter, group: bool = False) CompositeFilter[source]
+

Start a chain ‘or’ operation. Will automatically apply a grouping.

+
+
Parameters:
+
    +
  • filter_instances – a list of other CompositeFilter you want to combine with the ‘or’ operation

  • +
  • group – will group this chain operation if True

  • +
+
+
Returns:
+

a CompositeFilter with the filter instances combined with an ‘or’ operation

+
+
+
+ +
+
+contains(attribute: str, word: str | bool | None | date | int | float) CompositeFilter[source]
+

Adds a contains word check

+
+
Parameters:
+
    +
  • attribute – the name of the attribute on which to apply the function

  • +
  • word – value to feed the function

  • +
+
+
Returns:
+

a CompositeFilter instance that can render the OData function operation

+
+
+
+ +
+
+endswith(attribute: str, word: str | bool | None | date | int | float) CompositeFilter[source]
+

Adds a endswith word check

+
+
Parameters:
+
    +
  • attribute – the name of the attribute on which to apply the function

  • +
  • word – value to feed the function

  • +
+
+
Returns:
+

a CompositeFilter instance that can render the OData function operation

+
+
+
+ +
+
+equals(attribute: str, word: str | bool | None | date | int | float) CompositeFilter[source]
+

Return an equals check

+
+
Parameters:
+
    +
  • attribute – attribute to compare word with

  • +
  • word – word to compare with

  • +
+
+
Returns:
+

a CompositeFilter instance that can render the OData this logical operation

+
+
+
+ +
+
+expand(relationship: str, select: CompositeFilter | None = None) CompositeFilter[source]
+

Returns an ‘expand’ query param +Important: If the ‘expand’ is a relationship (e.g. “event” or “attachments”), then the ApiComponent using +this query should know how to handle the relationship (e.g. Message knows how to handle attachments, +and event (if it’s an EventMessage). +Important: When using expand on multi-value relationships a max of 20 items will be returned.

+
+
Parameters:
+
    +
  • relationship – a relationship that will be expanded

  • +
  • select – a CompositeFilter instance to select attributes on the expanded relationship

  • +
+
+
Returns:
+

a CompositeFilter instance that can render the OData expand operation

+
+
+
+ +
+
+function_operation(operation: str, attribute: str, word: str | bool | None | date | int | float) CompositeFilter[source]
+

Apply a function operation

+
+
Parameters:
+
    +
  • operation – function name to operate on attribute

  • +
  • attribute – the name of the attribute on which to apply the function

  • +
  • word – value to feed the function

  • +
+
+
Returns:
+

a CompositeFilter instance that can render the OData function operation

+
+
+
+ +
+
+greater(attribute: str, word: str | bool | None | date | int | float) CompositeFilter[source]
+

Return a ‘greater than’ check

+
+
Parameters:
+
    +
  • attribute – attribute to compare word with

  • +
  • word – word to compare with

  • +
+
+
Returns:
+

a CompositeFilter instance that can render the OData this logical operation

+
+
+
+ +
+
+greater_equal(attribute: str, word: str | bool | None | date | int | float) CompositeFilter[source]
+

Return a ‘greater than or equal to’ check

+
+
Parameters:
+
    +
  • attribute – attribute to compare word with

  • +
  • word – word to compare with

  • +
+
+
Returns:
+

a CompositeFilter instance that can render the OData this logical operation

+
+
+
+ +
+
+iterable_operation(operation: str, collection: str, filter_instance: CompositeFilter, *, item_name: str = 'a') CompositeFilter[source]
+

Performs the provided filter operation on a collection by iterating over it.

+

For example:

+
q.iterable(
+    operation='any',
+    collection='email_addresses',
+    filter_instance=q.equals('address', 'george@best.com')
+)
+
+
+

will transform to a filter such as: +emailAddresses/any(a:a/address eq ‘george@best.com’)

+
+
Parameters:
+
    +
  • operation – the iterable operation name

  • +
  • collection – the collection to apply the iterable operation on

  • +
  • filter_instance – a CompositeFilter instance on which you will apply the iterable operation

  • +
  • item_name – the name of the collection item to be used on the filter_instance

  • +
+
+
Returns:
+

a CompositeFilter instance that can render the OData iterable operation

+
+
+
+ +
+
+less(attribute: str, word: str | bool | None | date | int | float) CompositeFilter[source]
+

Return a ‘less than’ check

+
+
Parameters:
+
    +
  • attribute – attribute to compare word with

  • +
  • word – word to compare with

  • +
+
+
Returns:
+

a CompositeFilter instance that can render the OData this logical operation

+
+
+
+ +
+
+less_equal(attribute: str, word: str | bool | None | date | int | float) CompositeFilter[source]
+

Return a ‘less than or equal to’ check

+
+
Parameters:
+
    +
  • attribute – attribute to compare word with

  • +
  • word – word to compare with

  • +
+
+
Returns:
+

a CompositeFilter instance that can render the OData this logical operation

+
+
+
+ +
+
+logical_operation(operation: str, attribute: str, word: str | bool | None | date | int | float) CompositeFilter[source]
+

Apply a logical operation like equals, less than, etc.

+
+
Parameters:
+
    +
  • operation – how to combine with a new one

  • +
  • attribute – attribute to compare word with

  • +
  • word – value to compare the attribute with

  • +
+
+
Returns:
+

a CompositeFilter instance that can render the OData logical operation

+
+
+
+ +
+
+search(word: str | int | bool, attribute: str | None = None) CompositeFilter[source]
+

Perform a search. +Note from graph docs:

+
+

You can currently search only message and person collections. +A $search request returns up to 250 results. +You cannot use $filter or $orderby in a search request.

+
+
+
Parameters:
+
    +
  • word – the text to search

  • +
  • attribute – the attribute to search the word on

  • +
+
+
Returns:
+

a CompositeFilter instance that can render the OData search operation

+
+
+
+ +
+
+select(*attributes: str) CompositeFilter[source]
+

Returns a ‘select’ query param +This is useful to return a limited set of attributes from a resource or return attributes that are not +returned by default by the resource.

+
+
Parameters:
+

attributes – a tuple of attribute names to select

+
+
Returns:
+

a CompositeFilter instance that can render the OData select operation

+
+
+
+ +
+
+startswith(attribute: str, word: str | bool | None | date | int | float) CompositeFilter[source]
+

Adds a startswith word check

+
+
Parameters:
+
    +
  • attribute – the name of the attribute on which to apply the function

  • +
  • word – value to feed the function

  • +
+
+
Returns:
+

a CompositeFilter instance that can render the OData function operation

+
+
+
+ +
+
+unequal(attribute: str, word: str | bool | None | date | int | float) CompositeFilter[source]
+

Return an unequal check

+
+
Parameters:
+
    +
  • attribute – attribute to compare word with

  • +
  • word – word to compare with

  • +
+
+
Returns:
+

a CompositeFilter instance that can render the OData this logical operation

+
+
+
+ +
+ +
+
+class O365.utils.query.QueryFilter[source]
+

Bases: QueryBase, ABC

+
+
+as_params() dict[source]
+
+ +
+
+abstractmethod render(item_name: str | None = None) str[source]
+
+ +
+ +
+
+class O365.utils.query.SearchFilter(word: str | int | bool | None = None, attribute: str | None = None)[source]
+

Bases: QueryBase

+
+
+__init__(word: str | int | bool | None = None, attribute: str | None = None)[source]
+
+ +
+
+as_params() dict[source]
+
+ +
+
+render() str[source]
+
+ +
+ +
+
+class O365.utils.query.SelectFilter(*args: str)[source]
+

Bases: ContainerQueryFilter

+
+
+__init__(*args: str)[source]
+
+ +
+ +
+ + +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/docs/latest/api/utils/token.html b/docs/latest/api/utils/token.html new file mode 100644 index 00000000..ea67bf27 --- /dev/null +++ b/docs/latest/api/utils/token.html @@ -0,0 +1,824 @@ + + + + + + + + + Token — O365 documentation + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+ +
+
+
+
+ +
+

Token

+
+
+class O365.utils.token.AWSS3Backend(bucket_name, filename)[source]
+

Bases: BaseTokenBackend

+

An AWS S3 backend to store tokens

+
+
+__init__(bucket_name, filename)[source]
+

Init Backend +:param str bucket_name: Name of the S3 bucket +:param str filename: Name of the S3 file

+
+ +
+
+check_token() bool[source]
+

Checks if the token exists +:return bool: True if it exists on the store

+
+ +
+
+delete_token() bool[source]
+

Deletes the token from the store +:return bool: Success / Failure

+
+ +
+
+load_token() bool[source]
+
+
Retrieves the token from the store
+
return bool:
+

Success / Failure

+
+
+
+
+
+ +
+
+save_token(force=False) bool[source]
+

Saves the token dict in the store +:param bool force: Force save even when state has not changed +:return bool: Success / Failure

+
+ +
+
+bucket_name
+

S3 bucket name.

   Type: str

+
+ +
+
+filename
+

S3 file name.

   Type: str

+
+ +
+ +
+
+class O365.utils.token.AWSSecretsBackend(secret_name, region_name)[source]
+

Bases: BaseTokenBackend

+

An AWS Secrets Manager backend to store tokens

+
+
+__init__(secret_name, region_name)[source]
+

Init Backend +:param str secret_name: Name of the secret stored in Secrets Manager +:param str region_name: AWS region hosting the secret (for example, ‘us-east-2’)

+
+ +
+
+check_token() bool[source]
+

Checks if the token exists +:return bool: True if it exists on the store

+
+ +
+
+delete_token() bool[source]
+

Deletes the token from the store +:return bool: Success / Failure

+
+ +
+
+load_token() bool[source]
+

Retrieves the token from the store +:return bool: Success / Failure

+
+ +
+
+save_token(force=False) bool[source]
+

Saves the token dict in the store +:param bool force: Force save even when state has not changed +:return bool: Success / Failure

+
+ +
+
+region_name
+

AWS Secret region name.

   Type: str

+
+ +
+
+secret_name
+

AWS Secret secret name.

   Type: str

+
+ +
+ +
+
+class O365.utils.token.BaseTokenBackend[source]
+

Bases: TokenCache

+

A base token storage class

+
+
+__init__()[source]
+
+ +
+
+add(event, **kwargs) None[source]
+

Add to the current cache.

+
+ +
+
+check_token() bool[source]
+

Optional Abstract method to check for the token existence in the backend

+
+ +
+
+delete_token() bool[source]
+

Optional Abstract method to delete the token from the backend

+
+ +
+
+deserialize(token_cache_state: bytes | str) dict[source]
+

Deserialize the cache from a state previously obtained by serialize()

+
+ +
+
+get_access_token(*, username: str | None = None) dict | None[source]
+

Retrieve the stored access token +If username is None, then the first access token will be retrieved +:param str username: The username from which retrieve the access token

+
+ +
+
+get_account(*, username: str | None = None, home_account_id: str | None = None) dict | None[source]
+

Gets the account object for the specified username or home_account_id

+
+ +
+
+get_all_accounts() list[dict][source]
+

Returns a list of all accounts present in the token cache

+
+ +
+
+get_id_token(*, username: str | None = None) dict | None[source]
+

Retrieve the stored id token +If username is None, then the first id token will be retrieved +:param str username: The username from which retrieve the id token

+
+ +
+
+get_refresh_token(*, username: str | None = None) dict | None[source]
+

Retrieve the stored refresh token +If username is None, then the first access token will be retrieved +:param str username: The username from which retrieve the refresh token

+
+ +
+
+get_token_scopes(*, username: str | None = None, remove_reserved: bool = False) list | None[source]
+

Retrieve the scopes the token (refresh first then access) has permissions on +:param str username: The username from which retrieve the refresh token +:param bool remove_reserved: if True RESERVED_SCOPES will be removed from the list

+
+ +
+
+load_token() bool[source]
+

Abstract method that will retrieve the token data from the backend +This MUST be implemented in subclasses

+
+ +
+
+modify(credential_type, old_entry, new_key_value_pairs=None) None[source]
+

Modify content in the cache.

+
+ +
+
+remove_data(*, username: str) bool[source]
+

Removes all tokens and all related data from the token cache for the specified username. +Returns success or failure. +:param str username: The username from which remove the tokens and related data

+
+ +
+
+save_token(force=False) bool[source]
+

Abstract method that will save the token data into the backend +This MUST be implemented in subclasses

+
+ +
+
+serialize() bytes | str[source]
+

Serialize the current cache state into a string.

+
+ +
+
+should_refresh_token(con: Connection | None = None, *, username: str | None = None) bool | None[source]
+

This method is intended to be implemented for environments +where multiple Connection instances are running on parallel.

+

This method should check if it’s time to refresh the token or not. +The chosen backend can store a flag somewhere to answer this question. +This can avoid race conditions between different instances trying to +refresh the token at once, when only one should make the refresh.

+

This is an example of how to achieve this:

+
+
    +
  1. Along with the token store a Flag

  2. +
  3. The first to see the Flag as True must transactional update it +to False. This method then returns True and therefore the +connection will refresh the token.

  4. +
  5. The save_token method should be rewritten to also update the flag +back to True always.

  6. +
  7. Meanwhile between steps 2 and 3, any other token backend checking +for this method should get the flag with a False value.

  8. +
+
+
This method should then wait and check again the flag.
+
This can be implemented as a call with an incremental backoff +factor to avoid too many calls to the database.
+
At a given point in time, the flag will return True.
+
Then this method should load the token and finally return False +signaling there is no need to refresh the token.
+
+
+
If this returns True, then the Connection will refresh the token.
+
If this returns False, then the Connection will NOT refresh the token as it was refreshed by +another instance or thread.
+
If this returns None, then this method has already executed the refresh and also updated the access +token into the connection session and therefore the Connection does not have to.
+
+

By default, this always returns True

+
+

There is an example of this in the example’s folder.

+
+
Parameters:
+
    +
  • con – the Connection instance passed by the caller. This is passed because maybe +the locking mechanism needs to refresh the token within the lock applied in this method.

  • +
  • username – The username from which retrieve the refresh token

  • +
+
+
Returns:
+

+
True if the Connection should refresh the token
+
False if the Connection should not refresh the token as it was refreshed by another instance
+
None if the token was refreshed by this method and therefore the Connection should do nothing.
+
+

+
+
+
+ +
+
+token_expiration_datetime(*, username: str | None = None) datetime | None[source]
+

Returns the current access token expiration datetime +If the refresh token is present, then the expiration datetime is extended by 3 months +:param str username: The username from which check the tokens +:return dt.datetime or None: The expiration datetime

+
+ +
+
+token_is_expired(*, username: str | None = None) bool[source]
+

Checks whether the current access token is expired +:param str username: The username from which check the tokens +:return bool: True if the token is expired, False otherwise

+
+ +
+
+token_is_long_lived(*, username: str | None = None) bool[source]
+

Returns if the token backend has a refresh token

+
+ +
+
+cryptography_manager: CryptographyManagerType | None
+

Optional cryptography manager.

   Type: CryptographyManagerType

+
+ +
+
+property has_data: bool
+

Does the token backend contain data.

+
+ +
+
+serializer = <module 'json' from '/opt/hostedtoolcache/Python/3.14.3/x64/lib/python3.14/json/__init__.py'>
+
+ +
+ +
+
+class O365.utils.token.BitwardenSecretsManagerBackend(access_token: str, secret_id: str)[source]
+

Bases: BaseTokenBackend

+

A Bitwarden Secrets Manager backend to store tokens

+
+
+__init__(access_token: str, secret_id: str)[source]
+

Init Backend +:param str access_token: Access Token used to access the Bitwarden Secrets Manager API +:param str secret_id: ID of Bitwarden Secret used to store the O365 token

+
+ +
+
+load_token() bool[source]
+

Retrieves the token from Bitwarden Secrets Manager +:return bool: Success / Failure

+
+ +
+
+save_token(force=False) bool[source]
+

Saves the token dict in Bitwarden Secrets Manager +:param bool force: Force save even when state has not changed +:return bool: Success / Failure

+
+ +
+
+client
+

Bitwarden client.

   Type: BitWardenClient

+
+ +
+
+secret
+

Bitwarden secret.

   Type: str

+
+ +
+
+secret_id
+

Bitwarden secret is.

   Type: str

+
+ +
+ +
+
+class O365.utils.token.CryptographyManagerType(*args, **kwargs)[source]
+

Bases: Protocol

+

Abstract cryptography manager

+
+
+__init__(*args, **kwargs)
+
+ +
+
+decrypt(data: bytes | str) bytes | str[source]
+
+ +
+
+encrypt(data: bytes | str) bytes | str[source]
+
+ +
+ +
+
+class O365.utils.token.DjangoTokenBackend(token_model=None)[source]
+

Bases: BaseTokenBackend

+

A Django database token backend to store tokens. To use this backend add the TokenModel +model below into your Django application.

+
class TokenModel(models.Model):
+    token = models.JSONField()
+    created_at = models.DateTimeField(auto_now_add=True)
+    updated_at = models.DateTimeField(auto_now=True)
+
+    def __str__(self):
+        return f"Token for {self.token.get('client_id', 'unknown')}"
+
+
+

Example usage:

+
from O365.utils import DjangoTokenBackend
+from models import TokenModel
+
+token_backend = DjangoTokenBackend(token_model=TokenModel)
+account = Account(credentials, token_backend=token_backend)
+
+
+
+
+__init__(token_model=None)[source]
+

Initializes the DjangoTokenBackend.

+
+
Parameters:
+

token_model – The Django model class to use for storing and retrieving tokens (defaults to TokenModel).

+
+
+
+ +
+
+check_token() bool[source]
+

Checks if any token exists in the Django database +:return bool: True if it exists, False otherwise

+
+ +
+
+delete_token() bool[source]
+

Deletes the latest token from the Django database +:return bool: Success / Failure

+
+ +
+
+load_token() bool[source]
+

Retrieves the latest token from the Django database +:return bool: Success / Failure

+
+ +
+
+save_token(force=False) bool[source]
+

Saves the token dict in the Django database +:param bool force: Force save even when state has not changed +:return bool: Success / Failure

+
+ +
+
+token_model
+

Django token model

   Type: TokenModel

+
+ +
+ +
+
+class O365.utils.token.EnvTokenBackend(token_env_name=None)[source]
+

Bases: BaseTokenBackend

+

A token backend based on environmental variable.

+
+
+__init__(token_env_name=None)[source]
+

Init Backend +:param str token_env_name: the name of the environmental variable that will hold the token

+
+ +
+
+check_token() bool[source]
+

Checks if the token exists in the environmental variables +:return bool: True if exists, False otherwise

+
+ +
+
+delete_token() bool[source]
+

Deletes the token environmental variable +:return bool: Success / Failure

+
+ +
+
+load_token() bool[source]
+

Retrieves the token from the environmental variable +:return bool: Success / Failure

+
+ +
+
+save_token(force=False) bool[source]
+

Saves the token dict in the specified environmental variable +:param bool force: Force save even when state has not changed +:return bool: Success / Failure

+
+ +
+
+token_env_name
+

Name of the environment token (Default - O365TOKEN).

   Type: str

+
+ +
+ +
+
+class O365.utils.token.FileSystemTokenBackend(token_path=None, token_filename=None)[source]
+

Bases: BaseTokenBackend

+

A token backend based on files on the filesystem

+
+
+__init__(token_path=None, token_filename=None)[source]
+

Init Backend +:param str or Path token_path: the path where to store the token +:param str token_filename: the name of the token file

+
+ +
+
+check_token() bool[source]
+

Checks if the token exists in the filesystem +:return bool: True if exists, False otherwise

+
+ +
+
+delete_token() bool[source]
+

Deletes the token file +:return bool: Success / Failure

+
+ +
+
+load_token() bool[source]
+

Retrieves the token from the File System and stores it in the cache +:return bool: Success / Failure

+
+ +
+
+save_token(force=False) bool[source]
+

Saves the token cache dict in the specified file +Will create the folder if it doesn’t exist +:param bool force: Force save even when state has not changed +:return bool: Success / Failure

+
+ +
+
+token_path
+

Path to the token stored in the file system.

   Type: str

+
+ +
+ +
+
+class O365.utils.token.FirestoreBackend(client, collection, doc_id, field_name='token')[source]
+

Bases: BaseTokenBackend

+

A Google Firestore database backend to store tokens

+
+
+__init__(client, collection, doc_id, field_name='token')[source]
+

Init Backend +:param firestore.Client client: the firestore Client instance +:param str collection: the firestore collection where to store tokens (can be a field_path) +:param str doc_id: # the key of the token document. Must be unique per-case. +:param str field_name: the name of the field that stores the token in the document

+
+ +
+
+check_token() bool[source]
+

Checks if the token exists +:return bool: True if it exists on the store

+
+ +
+
+delete_token() bool[source]
+

Deletes the token from the store +:return bool: Success / Failure

+
+ +
+
+load_token() bool[source]
+

Retrieves the token from the store +:return bool: Success / Failure

+
+ +
+
+save_token(force=False) bool[source]
+

Saves the token dict in the store +:param bool force: Force save even when state has not changed +:return bool: Success / Failure

+
+ +
+
+client
+

Fire store client.

   Type: firestore.Client

+
+ +
+
+collection
+

Fire store collection.

   Type: str

+
+ +
+
+doc_id
+

Fire store token document key.

   Type: str

+
+ +
+
+doc_ref
+

Fire store document reference.

   Type: any

+
+ +
+
+field_name
+

Fire store token field name (Default - token).

   Type: str

+
+ +
+ +
+
+class O365.utils.token.MemoryTokenBackend[source]
+

Bases: BaseTokenBackend

+

A token backend stored in memory.

+
+
+load_token() bool[source]
+

Abstract method that will retrieve the token data from the backend +This MUST be implemented in subclasses

+
+ +
+
+save_token(force=False) bool[source]
+

Abstract method that will save the token data into the backend +This MUST be implemented in subclasses

+
+ +
+ +
+ + +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/docs/latest/api/utils/utils.html b/docs/latest/api/utils/utils.html new file mode 100644 index 00000000..add30685 --- /dev/null +++ b/docs/latest/api/utils/utils.html @@ -0,0 +1,1356 @@ + + + + + + + + + Utils — O365 documentation + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+ +
+
+
+
+ +
+

Utils

+
+
+class O365.utils.utils.ApiComponent(*, protocol=None, main_resource=None, **kwargs)[source]
+

Bases: object

+

Base class for all object interactions with the Cloud Service API

+

Exposes common access methods to the api protocol within all Api objects

+
+
+__init__(*, protocol=None, main_resource=None, **kwargs)[source]
+

Object initialization

+
+
Parameters:
+
    +
  • protocol (Protocol) – A protocol class or instance to be used with +this connection

  • +
  • main_resource (str) – main_resource to be used in these API +communications

  • +
+
+
+
+ +
+
+build_base_url(resource)[source]
+

Builds the base url of this ApiComponent +:param str resource: the resource to build the base url

+
+ +
+
+build_url(endpoint)[source]
+

Returns a url for a given endpoint using the protocol +service url

+
+
Parameters:
+

endpoint (str) – endpoint to build the url for

+
+
Returns:
+

final url

+
+
Return type:
+

str

+
+
+
+ +
+
+new_query() QueryBuilder[source]
+

Create a new query to filter results

+
+
Parameters:
+

attribute (str) – attribute to apply the query for

+
+
Returns:
+

new QueryBuilder

+
+
Return type:
+

QueryBuilder

+
+
+
+ +
+
+q() QueryBuilder
+

Create a new query to filter results

+
+
Parameters:
+

attribute (str) – attribute to apply the query for

+
+
Returns:
+

new QueryBuilder

+
+
Return type:
+

QueryBuilder

+
+
+
+ +
+
+set_base_url(resource)[source]
+

Sets the base urls for this ApiComponent +:param str resource: the resource to build the base url

+
+ +
+
+main_resource
+

The main resource for the components.

   Type: str

+
+ +
+ +
+
+class O365.utils.utils.CaseEnum(new_class_name, /, names, *, module=None, qualname=None, type=None, start=1, boundary=None)[source]
+

Bases: Enum

+

A Enum that converts the value to a snake_case casing

+
+
+classmethod from_value(value)[source]
+

Gets a member by a snaked-case provided value

+
+ +
+ +
+
+class O365.utils.utils.ChainOperator(*values)[source]
+

Bases: Enum

+
+
+AND = 'and'
+
+ +
+
+OR = 'or'
+
+ +
+ +
+
+class O365.utils.utils.HandleRecipientsMixin[source]
+

Bases: object

+
+ +
+
+class O365.utils.utils.ImportanceLevel(*values)[source]
+

Bases: CaseEnum

+
+
+High = 'high'
+
+ +
+
+Low = 'low'
+
+ +
+
+Normal = 'normal'
+
+ +
+ +
+
+class O365.utils.utils.OneDriveWellKnowFolderNames(*values)[source]
+

Bases: Enum

+
+
+APP_ROOT = 'approot'
+
+ +
+
+ATTACHMENTS = 'attachments'
+
+ +
+
+CAMERA_ROLL = 'cameraroll'
+
+ +
+
+DOCUMENTS = 'documents'
+
+ +
+
+MUSIC = 'music'
+
+ +
+
+PHOTOS = 'photos'
+
+ +
+ +
+
+class O365.utils.utils.OutlookWellKnowFolderNames(*values)[source]
+

Bases: Enum

+
+
+ARCHIVE = 'Archive'
+
+ +
+
+CLUTTER = 'clutter'
+
+ +
+
+CONFLICTS = 'conflicts'
+
+ +
+
+CONVERSATIONHISTORY = 'conversationhistory'
+
+ +
+
+DELETED = 'DeletedItems'
+
+ +
+
+DRAFTS = 'Drafts'
+
+ +
+
+INBOX = 'Inbox'
+
+ +
+
+JUNK = 'JunkEmail'
+
+ +
+
+LOCALFAILURES = 'localfailures'
+
+ +
+
+OUTBOX = 'Outbox'
+
+ +
+
+RECOVERABLEITEMSDELETIONS = 'recoverableitemsdeletions'
+
+ +
+
+SCHEDULED = 'scheduled'
+
+ +
+
+SEARCHFOLDERS = 'searchfolders'
+
+ +
+
+SENT = 'SentItems'
+
+ +
+
+SERVERFAILURES = 'serverfailures'
+
+ +
+
+SYNCISSUES = 'syncissues'
+
+ +
+ +
+
+class O365.utils.utils.Pagination(*, parent=None, data=None, constructor=None, next_link=None, limit=None, **kwargs)[source]
+

Bases: ApiComponent

+

Utility class that allows batching requests to the server

+
+
+__init__(*, parent=None, data=None, constructor=None, next_link=None, limit=None, **kwargs)[source]
+

Returns an iterator that returns data until it’s exhausted. +Then will request more data (same amount as the original request) +to the server until this data is exhausted as well. +Stops when no more data exists or limit is reached.

+
+
Parameters:
+
    +
  • parent – the parent class. Must implement attributes: +con, api_version, main_resource

  • +
  • data – the start data to be return

  • +
  • constructor – the data constructor for the next batch. +It can be a function.

  • +
  • next_link (str) – the link to request more data to

  • +
  • limit (int) – when to stop retrieving more data

  • +
  • kwargs – any extra key-word arguments to pass to the +constructor.

  • +
+
+
+
+ +
+
+constructor
+

The constructor.

   Type: any

+
+ +
+
+data_count
+

Data count.

   Type: int

+
+ +
+
+extra_args
+

Extra args.

   Type: dict

+
+ +
+
+limit
+

The limit of when to stop.

   Type: int

+
+ +
+ +

The next link for the pagination.

   Type: str

+
+ +
+
+parent
+

The parent.

   Type: any

+
+ +
+
+state
+

State.

   Type: int

+
+ +
+
+total_count
+

Total count.

   Type: int

+
+ +
+ +
+
+class O365.utils.utils.Query(attribute=None, *, protocol)[source]
+

Bases: object

+

Helper to conform OData filters

+
+
+__init__(attribute=None, *, protocol)[source]
+

Build a query to apply OData filters +https://docs.microsoft.com/en-us/graph/query-parameters

+
+
Parameters:
+
    +
  • attribute (str) – attribute to apply the query for

  • +
  • protocol (Protocol) – protocol to use for connecting

  • +
+
+
+
+ +
+
+all(*, collection, word, attribute=None, func=None, operation=None, negation=False)[source]
+

Performs a filter with the OData ‘all’ keyword on the collection

+

For example: +q.any(collection=’email_addresses’, attribute=’address’, +operation=’eq’, word=’george@best.com’)

+

will transform to a filter such as:

+

emailAddresses/all(a:a/address eq ‘george@best.com’)

+
+
Parameters:
+
    +
  • collection (str) – the collection to apply the any keyword on

  • +
  • word (str) – the word to check

  • +
  • attribute (str) – the attribute of the collection to check

  • +
  • func (str) – the logical function to apply to the attribute +inside the collection

  • +
  • operation (str) – the logical operation to apply to the +attribute inside the collection

  • +
  • negation (bool) – negate the function or operation inside the iterable

  • +
+
+
Return type:
+

Query

+
+

Note

+

This method is part of fluent api and can be chained

+
+

+
+
+
+ +
+
+any(*, collection, word, attribute=None, func=None, operation=None, negation=False)[source]
+

Performs a filter with the OData ‘any’ keyword on the collection

+

For example: +q.any(collection=’email_addresses’, attribute=’address’, +operation=’eq’, word=’george@best.com’)

+

will transform to a filter such as:

+

emailAddresses/any(a:a/address eq ‘george@best.com’)

+
+
Parameters:
+
    +
  • collection (str) – the collection to apply the ‘any’ keyword on

  • +
  • word (str) – the word to check

  • +
  • attribute (str) – the attribute of the collection to check

  • +
  • func (str) – the logical function to apply to the attribute +inside the collection

  • +
  • operation (str) – the logical operation to apply to the +attribute inside the collection

  • +
  • negation (bool) – negates the function or operation inside the iterable

  • +
+
+
Return type:
+

Query

+
+

Note

+

This method is part of fluent api and can be chained

+
+

+
+
+
+ +
+
+as_params()[source]
+

Returns the filters, orders, select, expands and search as query parameters

+
+
Return type:
+

dict

+
+
+
+ +
+
+chain(operation=ChainOperator.AND)[source]
+

Start a chain operation

+
+
Parameters:
+

operation (ChainOperator, str) – how to combine with a new one

+
+
Return type:
+

Query

+
+

Note

+

This method is part of fluent api and can be chained

+
+

+
+
+
+ +
+
+clear()[source]
+

Clear everything

+
+
Return type:
+

Query

+
+

Note

+

This method is part of fluent api and can be chained

+
+

+
+
+
+ +
+
+clear_filters()[source]
+

Clear filters

+
+ +
+
+clear_order()[source]
+

Clears any order commands

+
+ +
+
+close_group()[source]
+

Closes a grouping for previous filters

+
+ +
+
+contains(word)[source]
+

Adds a contains word check

+
+
Parameters:
+

word (str) – word to check

+
+
Return type:
+

Query

+
+

Note

+

This method is part of fluent api and can be chained

+
+

+
+
+
+ +
+
+endswith(word)[source]
+

Adds a endswith word check

+
+
Parameters:
+

word (str) – word to check

+
+
Return type:
+

Query

+
+

Note

+

This method is part of fluent api and can be chained

+
+

+
+
+
+ +
+
+equals(word)[source]
+

Add an equals check

+
+
Parameters:
+

word – word to compare with

+
+
Return type:
+

Query

+
+

Note

+

This method is part of fluent api and can be chained

+
+

+
+
+
+ +
+
+expand(*relationships)[source]
+

Adds the relationships (e.g. “event” or “attachments”) +that should be expanded with the $expand parameter +Important: The ApiComponent using this should know how to handle this relationships.

+
+

eg: Message knows how to handle attachments, and event (if it’s an EventMessage)

+
+

Important: When using expand on multi-value relationships a max of 20 items will be returned.

+
+
Parameters:
+

relationships (str) – the relationships tuple to expand.

+
+
Return type:
+

Query

+
+

Note

+

This method is part of fluent api and can be chained

+
+

+
+
+
+ +
+
+function(function_name, word)[source]
+

Apply a function on given word

+
+
Parameters:
+
    +
  • function_name (str) – function to apply

  • +
  • word (str) – word to apply function on

  • +
+
+
Return type:
+

Query

+
+

Note

+

This method is part of fluent api and can be chained

+
+

+
+
+
+ +
+
+get_expands()[source]
+

Returns the result expand clause

+
+
Return type:
+

str or None

+
+
+
+ +
+
+get_filter_by_attribute(attribute)[source]
+

Returns a filter value by attribute name. It will match the attribute to the start of each filter attribute +and return the first found.

+
+
Parameters:
+

attribute – the attribute you want to search

+
+
Returns:
+

The value applied to that attribute or None

+
+
+
+ +
+
+get_filters()[source]
+

Returns the result filters

+
+
Return type:
+

str or None

+
+
+
+ +
+
+get_order()[source]
+

Returns the result order by clauses

+
+
Return type:
+

str or None

+
+
+
+ +
+
+get_selects()[source]
+

Returns the result select clause

+
+
Return type:
+

str or None

+
+
+
+ +
+
+greater(word)[source]
+

Add a greater than check

+
+
Parameters:
+

word – word to compare with

+
+
Return type:
+

Query

+
+

Note

+

This method is part of fluent api and can be chained

+
+

+
+
+
+ +
+
+greater_equal(word)[source]
+

Add a greater than or equal to check

+
+
Parameters:
+

word – word to compare with

+
+
Return type:
+

Query

+
+

Note

+

This method is part of fluent api and can be chained

+
+

+
+
+
+ +
+
+iterable(iterable_name, *, collection, word, attribute=None, func=None, operation=None, negation=False)[source]
+

Performs a filter with the OData ‘iterable_name’ keyword +on the collection

+

For example: +q.iterable(‘any’, collection=’email_addresses’, attribute=’address’, +operation=’eq’, word=’george@best.com’)

+

will transform to a filter such as: +emailAddresses/any(a:a/address eq ‘george@best.com’)

+
+
Parameters:
+
    +
  • iterable_name (str) – the OData name of the iterable

  • +
  • collection (str) – the collection to apply the ‘any’ keyword on

  • +
  • word (str) – the word to check

  • +
  • attribute (str) – the attribute of the collection to check

  • +
  • func (str) – the logical function to apply to the attribute inside +the collection

  • +
  • operation (str) – the logical operation to apply to the attribute +inside the collection

  • +
  • negation (bool) – negate the function or operation inside the iterable

  • +
+
+
Return type:
+

Query

+
+

Note

+

This method is part of fluent api and can be chained

+
+

+
+
+
+ +
+
+less(word)[source]
+

Add a less than check

+
+
Parameters:
+

word – word to compare with

+
+
Return type:
+

Query

+
+

Note

+

This method is part of fluent api and can be chained

+
+

+
+
+
+ +
+
+less_equal(word)[source]
+

Add a less than or equal to check

+
+
Parameters:
+

word – word to compare with

+
+
Return type:
+

Query

+
+

Note

+

This method is part of fluent api and can be chained

+
+

+
+
+
+ +
+
+logical_operator(operation, word)[source]
+

Apply a logical operator

+
+
Parameters:
+
    +
  • operation (str) – how to combine with a new one

  • +
  • word – other parameter for the operation +(a = b) would be like a.logical_operator(‘eq’, ‘b’)

  • +
+
+
Return type:
+

Query

+
+

Note

+

This method is part of fluent api and can be chained

+
+

+
+
+
+ +
+
+negate()[source]
+

Apply a not operator

+
+
Return type:
+

Query

+
+

Note

+

This method is part of fluent api and can be chained

+
+

+
+
+
+ +
+
+new(attribute, operation=ChainOperator.AND)[source]
+

Combine with a new query

+
+
Parameters:
+
    +
  • attribute (str) – attribute of new query

  • +
  • operation (ChainOperator) – operation to combine to new query

  • +
+
+
Return type:
+

Query

+
+

Note

+

This method is part of fluent api and can be chained

+
+

+
+
+
+ +
+
+on_attribute(attribute)[source]
+

Apply query on attribute, to be used along with chain()

+
+
Parameters:
+

attribute (str) – attribute name

+
+
Return type:
+

Query

+
+

Note

+

This method is part of fluent api and can be chained

+
+

+
+
+
+ +
+
+on_list_field(field)[source]
+

Apply query on a list field, to be used along with chain()

+
+
Parameters:
+

field (str) – field name (note: name is case sensitive)

+
+
Return type:
+

Query

+
+

Note

+

This method is part of fluent api and can be chained

+
+

+
+
+
+ +
+
+open_group()[source]
+

Applies a precedence grouping in the next filters

+
+ +
+
+order_by(attribute=None, *, ascending=True)[source]
+

Applies a order_by clause

+
+
Parameters:
+
    +
  • attribute (str) – attribute to apply on

  • +
  • ascending (bool) – should it apply ascending order or descending

  • +
+
+
Return type:
+

Query

+
+

Note

+

This method is part of fluent api and can be chained

+
+

+
+
+
+ +
+
+remove_filter(filter_attr)[source]
+

Removes a filter given the attribute name

+
+ +
+
+search(text)[source]
+

Perform a search. +Not from graph docs:

+
+

You can currently search only message and person collections. +A $search request returns up to 250 results. +You cannot use $filter or $orderby in a search request.

+
+
+
Parameters:
+

text (str) – the text to search

+
+
Returns:
+

the Query instance

+
+

Note

+

This method is part of fluent api and can be chained

+
+

+
+
+
+ +
+
+select(*attributes)[source]
+

Adds the attribute to the $select parameter

+
+
Parameters:
+

attributes (str) – the attributes tuple to select. +If empty, the on_attribute previously set is added.

+
+
Return type:
+

Query

+
+

Note

+

This method is part of fluent api and can be chained

+
+

+
+
+
+ +
+
+startswith(word)[source]
+

Adds a startswith word check

+
+
Parameters:
+

word (str) – word to check

+
+
Return type:
+

Query

+
+

Note

+

This method is part of fluent api and can be chained

+
+

+
+
+
+ +
+
+unequal(word)[source]
+

Add an unequals check

+
+
Parameters:
+

word – word to compare with

+
+
Return type:
+

Query

+
+

Note

+

This method is part of fluent api and can be chained

+
+

+
+
+
+ +
+
+property has_expands
+

Whether the query has relationships that should be expanded or not

+
+
Return type:
+

bool

+
+
+
+ +
+
+property has_filters
+

Whether the query has filters or not

+
+
Return type:
+

bool

+
+
+
+ +
+
+property has_order
+

Whether the query has order_by or not

+
+
Return type:
+

bool

+
+
+
+ +
+
+property has_selects
+

Whether the query has select filters or not

+
+
Return type:
+

bool

+
+
+
+ +
+
+protocol
+

Protocol to use.

   Type: protocol

+
+ +
+ +
+
+class O365.utils.utils.Recipient(address=None, name=None, parent=None, field=None)[source]
+

Bases: object

+

A single Recipient

+
+
+__init__(address=None, name=None, parent=None, field=None)[source]
+

Create a recipient with provided information

+
+
Parameters:
+
    +
  • address (str) – email address of the recipient

  • +
  • name (str) – name of the recipient

  • +
  • parent (HandleRecipientsMixin) – parent recipients handler

  • +
  • field (str) – name of the field to update back

  • +
+
+
+
+ +
+
+property address
+

Email address of the recipient

+
+
Getter:
+

Get the email address

+
+
Setter:
+

Set and update the email address

+
+
Type:
+

str

+
+
+
+ +
+
+property name
+

Name of the recipient

+
+
Getter:
+

Get the name

+
+
Setter:
+

Set and update the name

+
+
Type:
+

str

+
+
+
+ +
+ +
+
+class O365.utils.utils.Recipients(recipients=None, parent=None, field=None)[source]
+

Bases: object

+

A Sequence of Recipients

+
+
+__init__(recipients=None, parent=None, field=None)[source]
+

Recipients must be a list of either address strings or +tuples (name, address) or dictionary elements

+
+
Parameters:
+
    +
  • recipients (list[str] or list[tuple] or list[dict] +or list[Recipient]) – list of either address strings or +tuples (name, address) or dictionary elements

  • +
  • parent (HandleRecipientsMixin) – parent recipients handler

  • +
  • field (str) – name of the field to update back

  • +
+
+
+
+ +
+
+add(recipients)[source]
+

Add the supplied recipients to the exiting list

+
+
Parameters:
+

recipients (list[str] or list[tuple] or list[dict]) – list of either address strings or +tuples (name, address) or dictionary elements

+
+
+
+ +
+
+clear()[source]
+

Clear the list of recipients

+
+ +
+
+get_first_recipient_with_address()[source]
+

Returns the first recipient found with a non blank address

+
+
Returns:
+

First Recipient

+
+
Return type:
+

Recipient

+
+
+
+ +
+
+remove(address)[source]
+

Remove an address or multiple addresses

+
+
Parameters:
+

address (str or list[str]) – list of addresses to remove

+
+
+
+ +
+ +
+
+class O365.utils.utils.TrackerSet(*args, casing=None, **kwargs)[source]
+

Bases: set

+
+
+__init__(*args, casing=None, **kwargs)[source]
+

A Custom Set that changes the casing of it’s keys

+
+
Parameters:
+

casing (func) – a function to convert into specified case

+
+
+
+ +
+
+add(value)[source]
+

Add an element to a set.

+

This has no effect if the element is already present.

+
+ +
+
+remove(value)[source]
+

Remove an element from a set; it must be a member.

+

If the element is not a member, raise a KeyError.

+
+ +
+ +
+ + +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/docs/latest/genindex.html b/docs/latest/genindex.html new file mode 100644 index 00000000..6442d407 --- /dev/null +++ b/docs/latest/genindex.html @@ -0,0 +1,3866 @@ + + + + + + + + Index — O365 documentation + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+
    +
  • + +
  • +
  • +
+
+
+
+
+ + +

Index

+ +
+ _ + | A + | B + | C + | D + | E + | F + | G + | H + | I + | J + | K + | L + | M + | N + | O + | P + | Q + | R + | S + | T + | U + | V + | W + | Y + +
+

_

+ + +
+ +

A

+ + + +
+ +

B

+ + + +
+ +

C

+ + + +
+ +

D

+ + + +
+ +

E

+ + + +
+ +

F

+ + + +
+ +

G

+ + + +
+ +

H

+ + + +
+ +

I

+ + + +
+ +

J

+ + + +
+ +

K

+ + +
+ +

L

+ + + +
+ +

M

+ + + +
+ +

N

+ + + +
+ +

O

+ + + +
+ +

P

+ + + +
+ +

Q

+ + + +
+ +

R

+ + + +
+ +

S

+ + + +
+ +

T

+ + + +
+ +

U

+ + + +
+ +

V

+ + + +
+ +

W

+ + + +
+ +

Y

+ + +
+ + + +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/docs/latest/getting_started.html b/docs/latest/getting_started.html new file mode 100644 index 00000000..30b3343c --- /dev/null +++ b/docs/latest/getting_started.html @@ -0,0 +1,709 @@ + + + + + + + + + Getting Started — O365 documentation + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+ +
+
+
+
+ +
+

Getting Started

+
+

Installation

+
+

Stable Version (PyPI)

+

The latest stable package is hosted on PyPI.

+

To install using pip, run:

+
pip install o365
+
+
+

or use uv:

+
uv add o365
+
+
+

Requirements: >= Python 3.10

+

Project dependencies installed by pip:

+
    +
  • requests

  • +
  • msal

  • +
  • beatifulsoup4

  • +
  • python-dateutil

  • +
  • tzlocal

  • +
  • tzdata

  • +
+
+
+

Latest Development Version (GitHub)

+

The latest development version is available on GitHub. +This version may include new features but could be unstable. Use at your own risk.

+

Using pip, run:

+
pip install git+https://github.com/O365/python-o365.git
+
+
+

Or with uv, run:

+
uv add "o365 @ git+https://github.com/O365/python-o365"
+
+
+
+
+
+

Basic Usage

+

The first step to be able to work with this library is to register an application and retrieve the auth token. See Authentication.

+

With the access token retrieved and stored you will be able to perform api calls to the service.

+

A common pattern to check for authentication and use the library is this one:

+
requested_scopes = ['my_required_scopes']  # you can use scope helpers here (see Permissions and Scopes section)
+
+account = Account(credentials)
+
+if not account.is_authenticated:  # will check if there is a token and has not expired
+   # ask for a login using console based authentication. See Authentication for other flows
+   if account.authenticate(requested_scopes=requeated_scopes) is False:
+      raise RuntimeError('Authentication Failed')
+
+# now we are authenticated
+# use the library from now on
+
+# ...
+
+
+
+
+

Authentication

+
+

Types

+

You can only authenticate using OAuth authentication because Microsoft deprecated basic auth on November 1st 2018.

+
+

Important

+

With version 2.1 old access tokens will not work and the library will require a new authentication flow to get new access and refresh tokens.

+
+

There are currently three authentication methods:

+ +
+

Note

+

‘Authenticate with your own identity’ is not an allowed method for Microsoft Personal accounts.

+
+

When to use one or the other and requirements:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

Topic

On behalf of a user (auth_flow_type==’authorization’)

On behalf of a user (public) (auth_flow_type==’public’)

With your own identity (auth_flow_type==’credentials’)

Register the App

Required

Required

Required

Requires Admin Consent

Only on certain advanced permissions

Only on certain advanced permissions

Yes, for everything

App Permission Type

Delegated Permissions (on behalf of the user)

Delegated Permissions (on behalf of the user)

Application Permissions

Auth requirements

Client Id, Client Secret, Authorization Code

Client Id, Authorization Code

Client Id, Client Secret

Authentication

2 step authentication with user consent

2 step authentication with user consent

1 step authentication

Auth Scopes

Required

Required

None

Token Expiration

60 Minutes without refresh token or 90 days*

60 Minutes without refresh token or 90 days*

60 Minutes*

Login Expiration

Unlimited if there is a refresh token and as long as a +refresh is done within the 90 days

Unlimited if there is a refresh token and as long as a +refresh is done within the 90 days

Unlimited

Resources

Access the user resources, and any shared resources

Access the user resources, and any shared resources

All Azure AD users the app has access to

Microsoft Account Type

Any

Any

Not Allowed for Personal Accounts

Tenant ID Required

Defaults to “common”

Defaults to “common”

Required (can’t be “common”)

+

Note: *O365 will automatically refresh the token for you on either authentication method. The refresh token lasts 90 days, but it’s refreshed on each connection so as long as you connect within 90 days you can have unlimited access.

+

The Connection Class handles the authentication.

+

With auth_flow_type ‘credentials’ you can authenticate using a certificate based authentication by just passing the client_secret like so:

+
client_secret = {
+   "thumbprint": <thumbprint of cert file>,
+   "private_key": <private key from the private_key_file>
+}
+credentials = client_id, client_secret
+account = Account(credentials)
+
+
+
+
+

OAuth Setup (Prerequisite)

+

Before you can use python-o365, you must register your application in the +Microsoft Entra Admin Center. Follow the steps below:

+
    +
  1. Log in to the Microsoft Entra Admin Center

    + +
  2. +
  3. Create a new application and note its App (client) ID

    +
      +
    • In the left navigation bar, select Applications > App registrations.

    • +
    • Click + New registration.

    • +
    • Provide a Name for the application and keep all defaults.

    • +
    • From the Overview of your new application, copy the (client_id) Application (client) ID for later reference.

    • +
    +
  4. +
  5. Generate a new password (client_secret)

    +
      +
    • In the Overview window, select Certificates & secrets.

    • +
    • Click New client secret.

    • +
    • In the Add a client secret window, provide a Description and Expiration, then click Add.

    • +
    • Save the (client_secret) Value for later reference.

    • +
    +
  6. +
  7. Add redirect URIs

    +
      +
    • In the Overview window, click Add a redirect URI.

    • +
    • Click + Add a platform, then select Web.

    • +
    • Add https://login.microsoftonline.com/common/oauth2/nativeclient as the redirect URI.

    • +
    • Click Save.

    • +
    +
  8. +
  9. Add required permissions

    +
      +
    • In the left navigation bar, select API permissions.

    • +
    • Click + Add a permission.

    • +
    • Under Microsoft Graph, select Delegated permissions.

    • +
    • Add the delegated permissions you plan to use (for example):

      +
        +
      • Mail.Read

      • +
      • Mail.ReadWrite

      • +
      • Mail.Send

      • +
      • User.Read

      • +
      • User.ReadBasic.All

      • +
      • offline_access

      • +
      +
    • +
    • Click Add permissions.

    • +
    +
  10. +
+
+

Important

+

The offline_access permission is required for the refresh token to work.

+
+
+
+

Examples

+

Then you need to log in for the first time to get the access token that will grant access to the user resources.

+

To authenticate (login) you can use Different interfaces. On the following examples we will be using the Console Based Interface, but you can use any of them.

+
+

Important

+

In case you can’t secure the client secret you can use the auth flow type ‘public’ which only requires the client id.

+
+
    +
  • When authenticating on behalf of a user:

    +
      +
    1. Instantiate an Account object with the credentials (client id and client secret).

    2. +
    3. Call account.authenticate and pass the scopes you want (the ones you previously added on the app registration portal).

      +

      > Note: when using the “on behalf of a user” authentication, you can pass the scopes to either the Account init or to the authenticate method. Either way is correct.

      +

      You can pass “protocol scopes” (like: “https://graph.microsoft.com/Calendars.ReadWrite”) to the method or use “[scope helpers](https://github.com/O365/python-o365/blob/master/O365/connection.py#L34)” like (“message_all”). +If you pass protocol scopes, then the account instance must be initialized with the same protocol used by the scopes. By using scope helpers you can abstract the protocol from the scopes and let this library work for you. +Finally, you can mix and match “protocol scopes” with “scope helpers”. +Go to the [procotol section](#protocols) to know more about them.

      +

      For Example (following the previous permissions added):

      +
      from O365 import Account
      +credentials = ('my_client_id', 'my_client_secret')
      +
      +# the default protocol will be Microsoft Graph
      +# the default authentication method will be "on behalf of a user"
      +
      +account = Account(credentials)
      +if account.authenticate(requested_scopes=['basic', 'message_all']):
      +   print('Authenticated!')
      +
      +# 'basic' adds: 'https://graph.microsoft.com/User.Read'
      +# 'message_all' adds: 'https://graph.microsoft.com/Mail.ReadWrite' and 'https://graph.microsoft.com/Mail.Send'
      +
      +
      +

      When using the “on behalf of the user” authentication method, this method call will print an url that the user must visit to give consent to the app on the required permissions.

      +

      The user must then visit this url and give consent to the application. When consent is given, the page will rediret to: “https://login.microsoftonline.com/common/oauth2/nativeclient” by default (you can change this) with an url query param called ‘code’.

      +

      Then the user must copy the resulting page url and paste it back on the console. +The method will then return True if the login attempt was succesful.

      +
    4. +
    +
  • +
  • When authenticating with your own identity:

    +
      +
    1. Instantiate an Account object with the credentials (client id and client secret), specifying the parameter auth_flow_type to “credentials”. You also need to provide a ‘tenant_id’. You don’t need to specify any scopes.

    2. +
    3. Call account.authenticate. This call will request a token for you and store it in the backend. No user interaction is needed. The method will store the token in the backend and return True if the authentication succeeded.

    4. +
    +
    +

    For Example:

    +
    from O365 import Account
    +
    +credentials = ('my_client_id', 'my_client_secret')
    +
    +# the default protocol will be Microsoft Graph
    +
    +account = Account(credentials, auth_flow_type='credentials', tenant_id='my-tenant-id')
    +if account.authenticate():
    +   print('Authenticated!')
    +
    +
    +
    +
  • +
+

At this point you will have an access token stored that will provide valid credentials when using the api.

+

The access token only lasts 60 minutes, but the app will automatically request new access tokens if you added the ‘offline access’ permission.

+

When using the “on behalf of a user” authentication method this is accomplished through the refresh tokens (if and only if you added the “offline_access” permission), but note that a refresh token only lasts for 90 days. So you must use it before, or you will need to request a new access token again (no new consent needed by the user, just a login). If your application needs to work for more than 90 days without user interaction and without interacting with the API, then you must implement a periodic call to Connection.refresh_token before the 90 days have passed.

+
+

Important

+

Take care: the access (and refresh) token must remain protected from unauthorized users.

+
+
+
+

Different interfaces

+

To accomplish the authentication you can basically use different approaches. The following apply to the “on behalf of a user” authentication method as this is 2-step authentication flow. For the “with your own identity” authentication method, you can just use account.authenticate as it’s not going to require a console input.

+
    +
  1. Console based authentication interface:

    +

    You can authenticate using a console. The best way to achieve this is by using the authenticate method of the Account class.

    +

    account = Account(credentials) +account.authenticate(requested_scopes=[‘basic’, ‘message_all’]) +The authenticate method will print into the console an url that you will have to visit to achieve authentication. Then after visiting the link and authenticate you will have to paste back the resulting url into the console. The method will return True and print a message if it was succesful.

    +

    Tip: When using macOS the console is limited to 1024 characters. If your url has multiple scopes it can exceed this limit. To solve this. Just import readline at the top of your script.

    +
  2. +
  3. Web app based authentication interface:

    +

    You can authenticate your users in a web environment by following these steps:

    +
      +
    1. First ensure you are using an appropiate TokenBackend to store the auth tokens (See Token storage below).

    2. +
    3. From a handler redirect the user to the Microsoft login url. Provide a callback. Store the flow dictionary.

    4. +
    5. From the callback handler complete the authentication with the flow dict and other data.

    6. +
    +

    The following example is done using Flask.

    +
    from flask import request
    +from O365 import Account
    +
    +
    +@route('/stepone')
    +def auth_step_one():
    +   # callback = absolute url to auth_step_two_callback() page, https://domain.tld/steptwo
    +   callback = url_for('auth_step_two_callback', _external=True)  # Flask example
    +
    +   account = Account(credentials)
    +   url, flow = account.con.get_authorization_url(requested_scopes=my_scopes,
    +                                                   redirect_uri=callback)
    +
    +   flow_as_string = serialize(flow)  # convert the dict into a string using json for example
    +   # the flow must be saved somewhere as it will be needed later
    +   my_db.store_flow(flow_as_string) # example...
    +
    +   return redirect(url)
    +
    +@route('/steptwo')
    +def auth_step_two_callback():
    +   account = Account(credentials)
    +
    +   # retrieve the state saved in auth_step_one
    +   my_saved_flow_str = my_db.get_flow()  # example...
    +   my_saved_flow = deserialize(my_saved_flow_str)  # convert from a string to a dict using json for example.
    +
    +   # rebuild the redirect_uri used in auth_step_one
    +   callback = 'my absolute url to auth_step_two_callback'
    +
    +   # get the request URL of the page which will include additional auth information
    +   # Example request: /steptwo?code=abc123&state=xyz456
    +   requested_url = request.url  # uses Flask's request() method
    +
    +   result = account.con.request_token(requested_url,
    +                                       flow=my_saved_flow)
    +   # if result is True, then authentication was successful
    +   #  and the auth token is stored in the token backend
    +   if result:
    +      return render_template('auth_complete.html')
    +   # else ....
    +
    +
    +
  4. +
  5. Other authentication interfaces:

    +

    Finally, you can configure any other flow by using connection.get_authorization_url and connection.request_token as you want.

    +
  6. +
+
+
+
+

Permissions & Scopes

+
+

Permissions

+

When using oauth, you create an application and allow some resources to be accessed and used by its users. These resources are managed with permissions. These can either be delegated (on behalf of a user) or application permissions. The former are used when the authentication method is “on behalf of a user”. Some of these require administrator consent. The latter when using the “with your own identity” authentication method. All of these require administrator consent.

+
+
+

Scopes

+

The scopes only matter when using the “on behalf of a user” authentication method.

+
+

Note

+

You only need the scopes when login as those are kept stored within the token on the token backend.

+
+

The user of this library can then request access to one or more of these resources by providing scopes to the OAuth provider.

+
+

Note

+

If you later on change the scopes requested, the current token will be invalid, and you will have to re-authenticate. The user that logins will be asked for consent.

+
+

For example your application can have Calendar.Read, Mail.ReadWrite and Mail.Send permissions, but the application can request access only to the Mail.ReadWrite and Mail.Send permission. This is done by providing scopes to the Account instance or account.authenticate method like so:

+
from O365 import Account
+
+credentials = ('client_id', 'client_secret')
+
+requested_scopes = ['Mail.ReadWrite', 'Mail.Send']
+
+account = Account(credentials, requested_scopes=requested_scopes)
+account.authenticate()
+
+# The latter is exactly the same as passing scopes to the authenticate method like so:
+# account = Account(credentials)
+# account.authenticate(requested_scopes=requested_scopes)
+
+
+

Scope implementation depends on the protocol used. So by using protocol data you can automatically set the scopes needed. This is implemented by using ‘scope helpers’. Those are little helpers that group scope functionality and abstract the protocol used.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

Scope Helper

Scopes included

basic

‘User.Read’

mailbox

‘Mail.Read’

mailbox_shared

‘Mail.Read.Shared’

mailbox_settings

‘MailboxSettings.ReadWrite’

message_send

‘Mail.Send’

message_send_shared

‘Mail.Send.Shared’

message_all

‘Mail.ReadWrite’ and ‘Mail.Send’

message_all_shared

‘Mail.ReadWrite.Shared’ and ‘Mail.Send.Shared’

address_book

‘Contacts.Read’

address_book_shared

‘Contacts.Read.Shared’

address_book_all

‘Contacts.ReadWrite’

address_book_all_shared

‘Contacts.ReadWrite.Shared’

calendar

‘Calendars.Read’

calendar_shared

‘Calendars.Read.Shared’

calendar_all

‘Calendars.ReadWrite’

calendar_shared_all

‘Calendars.ReadWrite.Shared’

users

‘User.ReadBasic.All’

onedrive

‘Files.Read.All’

onedrive_all

‘Files.ReadWrite.All’

sharepoint

‘Sites.Read.All’

sharepoint_dl

‘Sites.ReadWrite.All’

tasks

‘Tasks.Read’

tasks_all

‘Tasks.ReadWrite’

presence

‘Presence.Read’

+

You can get the same scopes as before using protocols and scope helpers like this:

+
protocol_graph = MSGraphProtocol()
+
+scopes_graph = protocol.get_scopes_for('message_all')
+# scopes here are: ['https://graph.microsoft.com/Mail.ReadWrite', 'https://graph.microsoft.com/Mail.Send']
+
+account = Account(credentials, requested_scopes=scopes_graph)
+
+
+
+

Note

+

When passing scopes at the Account initialization or on the account.authenticate method, the scope helpers are automatically converted to the protocol flavour. Those are the only places where you can use scope helpers. Any other object using scopes (such as the Connection object) expects scopes that are already set for the protocol.

+
+
+
+
+

Token Storage

+

When authenticating you will retrieve OAuth tokens. If you don’t want a one time access you will have to store the token somewhere. O365 makes no assumptions on where to store the token and tries to abstract this from the library usage point of view.

+

You can choose where and how to store tokens by using the proper Token Backend.

+
+

Caution

+

The access (and refresh) token must remain protected from unauthorized users. You can plug in a “cryptography_manager” (object that can call encrypt and decrypt) into TokenBackends “cryptography_manager” attribute.

+
+

The library will call (at different stages) the token backend methods to load and save the token.

+

Methods that load tokens:

+
    +
  • account.is_authenticated property will try to load the token if is not already loaded.

  • +
  • connection.get_session: this method is called when there isn’t a request session set.

  • +
+

Methods that stores tokens:

+
    +
  • connection.request_token: by default will store the token, but you can set store_token=False to avoid it.

  • +
  • connection.refresh_token: by default will store the token. To avoid it change connection.store_token_after_refresh to False. This however it’s a global setting (that only affects the refresh_token method). If you only want the next refresh operation to not store the token you will have to set it back to True afterward.

  • +
+

To store the token you will have to provide a properly configured TokenBackend.

+

There are a few TokenBackend classes implemented (and you can easily implement more like a CookieBackend, RedisBackend, etc.):

+
    +
  • FileSystemTokenBackend (Default backend): Stores and retrieves tokens from the file system. Tokens are stored as text files.

  • +
  • MemoryTokenBackend: Stores the tokens in memory. Basically load_token and save_token does nothing.

  • +
  • EnvTokenBackend: Stores and retrieves tokens from environment variables.

  • +
  • FirestoreTokenBackend: Stores and retrieves tokens from a Google Firestore Datastore. Tokens are stored as documents within a collection.

  • +
  • AWSS3Backend: Stores and retrieves tokens from an AWS S3 bucket. Tokens are stored as a file within a S3 bucket.

  • +
  • AWSSecretsBackend: Stores and retrieves tokens from an AWS Secrets Management vault.

  • +
  • BitwardenSecretsManagerBackend: Stores and retrieves tokens from Bitwarden Secrets Manager.

  • +
  • DjangoTokenBackend: Stores and retrieves tokens using a Django model.

  • +
+

For example using the FileSystem Token Backend:

+
from O365 import Account, FileSystemTokenBackend
+
+credentials = ('id', 'secret')
+
+# this will store the token under: "my_project_folder/my_folder/my_token.txt".
+# you can pass strings to token_path or Path instances from pathlib
+token_backend = FileSystemTokenBackend(token_path='my_folder', token_filename='my_token.txt')
+account = Account(credentials, token_backend=token_backend)
+
+# This account instance tokens will be stored on the token_backend configured before.
+# You don't have to do anything more
+# ...
+
+
+

And now using the same example using FirestoreTokenBackend:

+
from O365 import Account
+from O365.utils import FirestoreBackend
+from google.cloud import firestore
+
+credentials = ('id', 'secret')
+
+# this will store the token on firestore under the tokens collection on the defined doc_id.
+# you can pass strings to token_path or Path instances from pathlib
+user_id = 'whatever the user id is'  # used to create the token document id
+document_id = f"token_{user_id}"  # used to uniquely store this token
+token_backend = FirestoreBackend(client=firestore.Client(), collection='tokens', doc_id=document_id)
+account = Account(credentials, token_backend=token_backend)
+
+# This account instance tokens will be stored on the token_backend configured before.
+# You don't have to do anything more
+# ...
+
+
+

To implement a new TokenBackend:

+
    +
  1. Subclass BaseTokenBackend

  2. +
  3. Implement the following methods:

    +
      +
    • __init__ (don’t forget to call super().__init__)

    • +
    • load_token: this should load the token from the desired backend and return a Token instance or None

    • +
    • save_token: this should store the self.token in the desired backend.

    • +
    • Optionally you can implement: check_token, delete_token and should_refresh_token

    • +
    +
  4. +
+

The should_refresh_token method is intended to be implemented for environments where multiple Connection instances are running on parallel. This method should check if it’s time to refresh the token or not. The chosen backend can store a flag somewhere to answer this question. This can avoid race conditions between different instances trying to refresh the token at once, when only one should make the refresh. The method should return three possible values:

+
    +
  • True: then the Connection will refresh the token.

  • +
  • False: then the Connection will NOT refresh the token.

  • +
  • None: then this method already executed the refresh and therefore the Connection does not have to.

  • +
+

By default, this always returns True as it’s assuming there is are no parallel connections running at once.

+

There are two examples of this method in the examples folder here.

+
+
+ + +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/docs/latest/index.html b/docs/latest/index.html new file mode 100644 index 00000000..c6bd6127 --- /dev/null +++ b/docs/latest/index.html @@ -0,0 +1,374 @@ + + + + + + + + + Welcome to O365’s documentation! — O365 documentation + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+ +
+
+
+
+ +
+

Welcome to O365’s documentation!

+
+

Contents:

+ +
+
+
+

Indices and tables

+ +
+ + +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/docs/latest/objects.inv b/docs/latest/objects.inv new file mode 100644 index 00000000..5be27c66 Binary files /dev/null and b/docs/latest/objects.inv differ diff --git a/docs/latest/overview.html b/docs/latest/overview.html new file mode 100644 index 00000000..000c4ee8 --- /dev/null +++ b/docs/latest/overview.html @@ -0,0 +1,190 @@ + + + + + + + + + Overview — O365 documentation + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+ +
+
+
+
+ +
+

Overview

+

O365 - Microsoft Graph API made easy

+
+

Important

+

With version 2.1 old access tokens will not work, and the library will require a new authentication flow to get new access and refresh tokens.

+
+

This project aims to make interacting with Microsoft Graph easy to do in a Pythonic way. Access to Email, Calendar, Contacts, OneDrive, etc. Are easy to do in a way that feel easy and straight forward to beginners and feels just right to seasoned python programmer.

+

The project is currently developed and maintained by alejcas.

+
+

Core developers

+ +

We are always open to new pull requests!

+
+
+

Quick example

+

Here is a simple example showing how to send an email using python-o365. +Create a Python file and add the following code:

+
from O365 import Account
+
+credentials = ('client_id', 'client_secret')
+account = Account(credentials)
+
+m = account.new_message()
+m.to.add('to_example@example.com')
+m.subject = 'Testing!'
+m.body = "George Best quote: I've stopped drinking, but only while I'm asleep."
+m.send()
+
+
+
+
+

Why choose O365?

+
    +
  • Almost Full Support for MsGraph Rest Api.

  • +
  • Full OAuth support with automatic handling of refresh tokens.

  • +
  • Automatic handling between local datetimes and server datetimes. Work with your local datetime and let this library do the rest.

  • +
  • Change between different resource with ease: access shared mailboxes, other users resources, SharePoint resources, etc.

  • +
  • Pagination support through a custom iterator that handles future requests automatically. Request Infinite items!

  • +
  • A query helper to help you build custom OData queries (filter, order, select and search).

  • +
  • Modular ApiComponents can be created and built to achieve further functionality.

  • +
+
+

This project was also a learning resource for us. This is a list of not so common python idioms used in this project:

+
    +
  • New unpacking technics: def method(argument, *, with_name=None, **other_params):

  • +
  • Enums: from enum import Enum

  • +
  • Factory paradigm

  • +
  • Package organization

  • +
  • Timezone conversion and timezone aware datetimes

  • +
  • Etc. (see the code!)

  • +
+
+
+

Rebuilding HTML Docs

+
    +
  • Install sphinx python library:

  • +
+
pip install sphinx sphinx-rtd-theme
+
+
+
    +
  • Run the shell script build_docs.sh, or copy the command from the file when using on Windows

  • +
+
+
+ + +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/docs/latest/py-modindex.html b/docs/latest/py-modindex.html new file mode 100644 index 00000000..dbbe6a1a --- /dev/null +++ b/docs/latest/py-modindex.html @@ -0,0 +1,225 @@ + + + + + + + + Python Module Index — O365 documentation + + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+
    +
  • + +
  • +
  • +
+
+
+
+
+ + +

Python Module Index

+ +
+ o +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
 
+ o
+ O365 +
    + O365.account +
    + O365.address_book +
    + O365.calendar +
    + O365.category +
    + O365.connection +
    + O365.directory +
    + O365.drive +
    + O365.excel +
    + O365.groups +
    + O365.mailbox +
    + O365.message +
    + O365.planner +
    + O365.sharepoint +
    + O365.subscriptions +
    + O365.tasks +
    + O365.teams +
    + O365.utils.attachment +
    + O365.utils.query +
    + O365.utils.token +
    + O365.utils.utils +
+ + +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/docs/latest/search.html b/docs/latest/search.html new file mode 100644 index 00000000..fa02f230 --- /dev/null +++ b/docs/latest/search.html @@ -0,0 +1,125 @@ + + + + + + + + Search — O365 documentation + + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+
    +
  • + +
  • +
  • +
+
+
+
+
+ + + + +
+ +
+ +
+
+ +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/docs/latest/searchindex.js b/docs/latest/searchindex.js new file mode 100644 index 00000000..d069236b --- /dev/null +++ b/docs/latest/searchindex.js @@ -0,0 +1 @@ +Search.setIndex({"alltitles":{"Account":[[1,null],[27,null]],"Account Class and Modularity":[[27,"account-class-and-modularity"]],"Address Book":[[2,null],[28,null]],"Attachment":[[19,null]],"Authenticating your Account":[[27,"authenticating-your-account"]],"Authentication":[[23,"authentication"]],"Available Objects":[[32,"available-objects"]],"Basic Usage":[[23,"basic-usage"]],"Calendar":[[3,null],[29,null]],"Category":[[4,null]],"Chat":[[40,"chat"]],"Connecting to API Account":[[27,"connecting-to-api-account"]],"Connection":[[5,null]],"Contact Folders":[[28,"contact-folders"]],"Contents:":[[0,null],[18,null],[24,null],[26,null],[41,null]],"Core developers":[[25,"core-developers"]],"Create a Subscription":[[38,"create-a-subscription"]],"Delete a Subscription":[[38,"delete-a-subscription"]],"Detailed Usage":[[26,null]],"Different interfaces":[[23,"different-interfaces"]],"Directory":[[6,null]],"Directory and Users":[[31,null]],"Email Folder":[[34,"email-folder"]],"Examples":[[23,"examples"]],"Excel":[[7,null],[32,null]],"FileSystemTokenBackend":[[43,"filesystemtokenbackend"]],"Getting Started":[[23,null]],"Global Address List":[[28,"global-address-list"]],"Group":[[9,null],[33,null]],"Indices and tables":[[24,"indices-and-tables"]],"Installation":[[23,"installation"]],"Latest Development Version (GitHub)":[[23,"latest-development-version-github"]],"List Subscriptions":[[38,"list-subscriptions"]],"Mailbox":[[10,null],[34,null]],"Mailbox Settings":[[34,"mailbox-settings"]],"Mailbox and Messages":[[34,"mailbox-and-messages"]],"Message":[[11,null],[34,"message"]],"Multi-user handling":[[27,"multi-user-handling"]],"O365 API":[[0,null]],"OAuth Setup (Prerequisite)":[[23,"oauth-setup-prerequisite"]],"One Drive":[[12,null]],"OneDrive":[[35,null]],"Outlook Categories":[[34,"outlook-categories"]],"Overview":[[25,null]],"Pagination":[[44,"pagination"]],"Permissions":[[23,"permissions"]],"Permissions & Scopes":[[23,"permissions-scopes"]],"Planner":[[13,null],[36,null]],"Presence":[[40,"presence"]],"Protocols":[[30,null]],"Query":[[20,null],[42,null]],"Query Builder":[[42,"query-builder"]],"Query helper":[[44,"query-helper"]],"Quick example":[[25,"quick-example"]],"Rebuilding HTML Docs":[[25,"rebuilding-html-docs"]],"Renew a Subscription":[[38,"renew-a-subscription"]],"Request Error Handling":[[44,"request-error-handling"]],"Resources":[[30,"resources"]],"Scopes":[[23,"scopes"]],"Setting Proxy":[[27,"setting-proxy"]],"Setting Scopes":[[27,"setting-scopes"]],"Setting your Account Instance":[[27,"setting-your-account-instance"]],"Sharepoint":[[14,null],[37,null]],"Sharepoint List Items":[[37,"sharepoint-list-items"]],"Sharepoint Lists":[[37,"sharepoint-lists"]],"Stable Version (PyPI)":[[23,"stable-version-pypi"]],"Subscriptions":[[15,null],[38,null]],"Tasks":[[16,null],[39,null]],"Team":[[40,"team"]],"Teams":[[17,null],[40,null]],"Token":[[21,null],[43,null]],"Token Storage":[[23,"token-storage"]],"Types":[[23,"types"]],"Using Different Resource":[[27,"using-different-resource"]],"Utils":[[18,null],[22,null],[41,null],[44,null]],"Webhook":[[38,"webhook"]],"Welcome to O365\u2019s documentation!":[[24,null]],"Why choose O365?":[[25,"why-choose-o365"]],"Workbook Sessions":[[32,"workbook-sessions"]]},"docnames":["api","api/account","api/address_book","api/calendar","api/category","api/connection","api/directory","api/excel","api/global","api/group","api/mailbox","api/message","api/onedrive","api/planner","api/sharepoint","api/subscriptions","api/tasks","api/teams","api/utils","api/utils/attachment","api/utils/query","api/utils/token","api/utils/utils","getting_started","index","overview","usage","usage/account","usage/addressbook","usage/calendar","usage/connection","usage/directory","usage/excel","usage/group","usage/mailbox","usage/onedrive","usage/planner","usage/sharepoint","usage/subscriptions","usage/tasks","usage/teams","usage/utils","usage/utils/query","usage/utils/token","usage/utils/utils"],"envversion":{"sphinx":66,"sphinx.domains.c":3,"sphinx.domains.changeset":1,"sphinx.domains.citation":1,"sphinx.domains.cpp":9,"sphinx.domains.index":1,"sphinx.domains.javascript":3,"sphinx.domains.math":2,"sphinx.domains.python":4,"sphinx.domains.rst":2,"sphinx.domains.std":2,"sphinx.ext.todo":2,"sphinx.ext.viewcode":1},"filenames":["api.rst","api/account.rst","api/address_book.rst","api/calendar.rst","api/category.rst","api/connection.rst","api/directory.rst","api/excel.rst","api/global.rst","api/group.rst","api/mailbox.rst","api/message.rst","api/onedrive.rst","api/planner.rst","api/sharepoint.rst","api/subscriptions.rst","api/tasks.rst","api/teams.rst","api/utils.rst","api/utils/attachment.rst","api/utils/query.rst","api/utils/token.rst","api/utils/utils.rst","getting_started.rst","index.rst","overview.rst","usage.rst","usage/account.rst","usage/addressbook.rst","usage/calendar.rst","usage/connection.rst","usage/directory.rst","usage/excel.rst","usage/group.rst","usage/mailbox.rst","usage/onedrive.rst","usage/planner.rst","usage/sharepoint.rst","usage/subscriptions.rst","usage/tasks.rst","usage/teams.rst","usage/utils.rst","usage/utils/query.rst","usage/utils/token.rst","usage/utils/utils.rst"],"indexentries":{"__init__() (o365.account.account method)":[[1,"O365.account.Account.__init__",false]],"__init__() (o365.address_book.addressbook method)":[[2,"O365.address_book.AddressBook.__init__",false]],"__init__() (o365.address_book.basecontactfolder method)":[[2,"O365.address_book.BaseContactFolder.__init__",false]],"__init__() (o365.address_book.contact method)":[[2,"O365.address_book.Contact.__init__",false]],"__init__() (o365.calendar.attendee method)":[[3,"O365.calendar.Attendee.__init__",false]],"__init__() (o365.calendar.attendees method)":[[3,"O365.calendar.Attendees.__init__",false]],"__init__() (o365.calendar.calendar method)":[[3,"O365.calendar.Calendar.__init__",false]],"__init__() (o365.calendar.dailyeventfrequency method)":[[3,"O365.calendar.DailyEventFrequency.__init__",false]],"__init__() (o365.calendar.event method)":[[3,"O365.calendar.Event.__init__",false]],"__init__() (o365.calendar.eventrecurrence method)":[[3,"O365.calendar.EventRecurrence.__init__",false]],"__init__() (o365.calendar.responsestatus method)":[[3,"O365.calendar.ResponseStatus.__init__",false]],"__init__() (o365.calendar.schedule method)":[[3,"O365.calendar.Schedule.__init__",false]],"__init__() (o365.category.categories method)":[[4,"O365.category.Categories.__init__",false]],"__init__() (o365.category.category method)":[[4,"O365.category.Category.__init__",false]],"__init__() (o365.connection.connection method)":[[5,"O365.connection.Connection.__init__",false]],"__init__() (o365.connection.msbusinesscentral365protocol method)":[[5,"O365.connection.MSBusinessCentral365Protocol.__init__",false]],"__init__() (o365.connection.msgraphprotocol method)":[[5,"O365.connection.MSGraphProtocol.__init__",false]],"__init__() (o365.connection.protocol method)":[[5,"O365.connection.Protocol.__init__",false]],"__init__() (o365.directory.directory method)":[[6,"O365.directory.Directory.__init__",false]],"__init__() (o365.directory.user method)":[[6,"O365.directory.User.__init__",false]],"__init__() (o365.drive.copyoperation method)":[[12,"O365.drive.CopyOperation.__init__",false]],"__init__() (o365.drive.drive method)":[[12,"O365.drive.Drive.__init__",false]],"__init__() (o365.drive.driveitem method)":[[12,"O365.drive.DriveItem.__init__",false]],"__init__() (o365.drive.driveitempermission method)":[[12,"O365.drive.DriveItemPermission.__init__",false]],"__init__() (o365.drive.driveitemversion method)":[[12,"O365.drive.DriveItemVersion.__init__",false]],"__init__() (o365.drive.file method)":[[12,"O365.drive.File.__init__",false]],"__init__() (o365.drive.folder method)":[[12,"O365.drive.Folder.__init__",false]],"__init__() (o365.drive.image method)":[[12,"O365.drive.Image.__init__",false]],"__init__() (o365.drive.photo method)":[[12,"O365.drive.Photo.__init__",false]],"__init__() (o365.drive.storage method)":[[12,"O365.drive.Storage.__init__",false]],"__init__() (o365.excel.namedrange method)":[[7,"O365.excel.NamedRange.__init__",false]],"__init__() (o365.excel.range method)":[[7,"O365.excel.Range.__init__",false]],"__init__() (o365.excel.rangeformat method)":[[7,"O365.excel.RangeFormat.__init__",false]],"__init__() (o365.excel.rangeformatfont method)":[[7,"O365.excel.RangeFormatFont.__init__",false]],"__init__() (o365.excel.table method)":[[7,"O365.excel.Table.__init__",false]],"__init__() (o365.excel.tablecolumn method)":[[7,"O365.excel.TableColumn.__init__",false]],"__init__() (o365.excel.tablerow method)":[[7,"O365.excel.TableRow.__init__",false]],"__init__() (o365.excel.workbook method)":[[7,"O365.excel.WorkBook.__init__",false]],"__init__() (o365.excel.workbookapplication method)":[[7,"O365.excel.WorkbookApplication.__init__",false]],"__init__() (o365.excel.workbooksession method)":[[7,"O365.excel.WorkbookSession.__init__",false]],"__init__() (o365.excel.worksheet method)":[[7,"O365.excel.WorkSheet.__init__",false]],"__init__() (o365.groups.group method)":[[9,"O365.groups.Group.__init__",false]],"__init__() (o365.groups.groups method)":[[9,"O365.groups.Groups.__init__",false]],"__init__() (o365.mailbox.automaticrepliessettings method)":[[10,"O365.mailbox.AutomaticRepliesSettings.__init__",false]],"__init__() (o365.mailbox.folder method)":[[10,"O365.mailbox.Folder.__init__",false]],"__init__() (o365.mailbox.mailbox method)":[[10,"O365.mailbox.MailBox.__init__",false]],"__init__() (o365.mailbox.mailboxsettings method)":[[10,"O365.mailbox.MailboxSettings.__init__",false]],"__init__() (o365.message.message method)":[[11,"O365.message.Message.__init__",false]],"__init__() (o365.message.messageflag method)":[[11,"O365.message.MessageFlag.__init__",false]],"__init__() (o365.planner.bucket method)":[[13,"O365.planner.Bucket.__init__",false]],"__init__() (o365.planner.plan method)":[[13,"O365.planner.Plan.__init__",false]],"__init__() (o365.planner.plandetails method)":[[13,"O365.planner.PlanDetails.__init__",false]],"__init__() (o365.planner.planner method)":[[13,"O365.planner.Planner.__init__",false]],"__init__() (o365.planner.task method)":[[13,"O365.planner.Task.__init__",false]],"__init__() (o365.planner.taskdetails method)":[[13,"O365.planner.TaskDetails.__init__",false]],"__init__() (o365.sharepoint.sharepoint method)":[[14,"O365.sharepoint.Sharepoint.__init__",false]],"__init__() (o365.sharepoint.sharepointlist method)":[[14,"O365.sharepoint.SharepointList.__init__",false]],"__init__() (o365.sharepoint.sharepointlistcolumn method)":[[14,"O365.sharepoint.SharepointListColumn.__init__",false]],"__init__() (o365.sharepoint.sharepointlistitem method)":[[14,"O365.sharepoint.SharepointListItem.__init__",false]],"__init__() (o365.sharepoint.site method)":[[14,"O365.sharepoint.Site.__init__",false]],"__init__() (o365.subscriptions.subscriptions method)":[[15,"O365.subscriptions.Subscriptions.__init__",false]],"__init__() (o365.tasks.checklistitem method)":[[16,"O365.tasks.ChecklistItem.__init__",false]],"__init__() (o365.tasks.folder method)":[[16,"O365.tasks.Folder.__init__",false]],"__init__() (o365.tasks.task method)":[[16,"O365.tasks.Task.__init__",false]],"__init__() (o365.tasks.todo method)":[[16,"O365.tasks.ToDo.__init__",false]],"__init__() (o365.teams.app method)":[[17,"O365.teams.App.__init__",false]],"__init__() (o365.teams.channel method)":[[17,"O365.teams.Channel.__init__",false]],"__init__() (o365.teams.channelmessage method)":[[17,"O365.teams.ChannelMessage.__init__",false]],"__init__() (o365.teams.chat method)":[[17,"O365.teams.Chat.__init__",false]],"__init__() (o365.teams.chatmessage method)":[[17,"O365.teams.ChatMessage.__init__",false]],"__init__() (o365.teams.conversationmember method)":[[17,"O365.teams.ConversationMember.__init__",false]],"__init__() (o365.teams.presence method)":[[17,"O365.teams.Presence.__init__",false]],"__init__() (o365.teams.team method)":[[17,"O365.teams.Team.__init__",false]],"__init__() (o365.teams.teams method)":[[17,"O365.teams.Teams.__init__",false]],"__init__() (o365.utils.attachment.attachablemixin method)":[[19,"O365.utils.attachment.AttachableMixin.__init__",false]],"__init__() (o365.utils.attachment.baseattachment method)":[[19,"O365.utils.attachment.BaseAttachment.__init__",false]],"__init__() (o365.utils.attachment.baseattachments method)":[[19,"O365.utils.attachment.BaseAttachments.__init__",false]],"__init__() (o365.utils.attachment.uploadsessionrequest method)":[[19,"O365.utils.attachment.UploadSessionRequest.__init__",false]],"__init__() (o365.utils.query.chainfilter method)":[[20,"O365.utils.query.ChainFilter.__init__",false]],"__init__() (o365.utils.query.compositefilter method)":[[20,"O365.utils.query.CompositeFilter.__init__",false]],"__init__() (o365.utils.query.containerqueryfilter method)":[[20,"O365.utils.query.ContainerQueryFilter.__init__",false]],"__init__() (o365.utils.query.expandfilter method)":[[20,"O365.utils.query.ExpandFilter.__init__",false]],"__init__() (o365.utils.query.iterablefilter method)":[[20,"O365.utils.query.IterableFilter.__init__",false]],"__init__() (o365.utils.query.logicalfilter method)":[[20,"O365.utils.query.LogicalFilter.__init__",false]],"__init__() (o365.utils.query.modifierqueryfilter method)":[[20,"O365.utils.query.ModifierQueryFilter.__init__",false]],"__init__() (o365.utils.query.operationqueryfilter method)":[[20,"O365.utils.query.OperationQueryFilter.__init__",false]],"__init__() (o365.utils.query.orderbyfilter method)":[[20,"O365.utils.query.OrderByFilter.__init__",false]],"__init__() (o365.utils.query.querybuilder method)":[[20,"O365.utils.query.QueryBuilder.__init__",false]],"__init__() (o365.utils.query.searchfilter method)":[[20,"O365.utils.query.SearchFilter.__init__",false]],"__init__() (o365.utils.query.selectfilter method)":[[20,"O365.utils.query.SelectFilter.__init__",false]],"__init__() (o365.utils.token.awss3backend method)":[[21,"O365.utils.token.AWSS3Backend.__init__",false]],"__init__() (o365.utils.token.awssecretsbackend method)":[[21,"O365.utils.token.AWSSecretsBackend.__init__",false]],"__init__() (o365.utils.token.basetokenbackend method)":[[21,"O365.utils.token.BaseTokenBackend.__init__",false]],"__init__() (o365.utils.token.bitwardensecretsmanagerbackend method)":[[21,"O365.utils.token.BitwardenSecretsManagerBackend.__init__",false]],"__init__() (o365.utils.token.cryptographymanagertype method)":[[21,"O365.utils.token.CryptographyManagerType.__init__",false]],"__init__() (o365.utils.token.djangotokenbackend method)":[[21,"O365.utils.token.DjangoTokenBackend.__init__",false]],"__init__() (o365.utils.token.envtokenbackend method)":[[21,"O365.utils.token.EnvTokenBackend.__init__",false]],"__init__() (o365.utils.token.filesystemtokenbackend method)":[[21,"O365.utils.token.FileSystemTokenBackend.__init__",false]],"__init__() (o365.utils.token.firestorebackend method)":[[21,"O365.utils.token.FirestoreBackend.__init__",false]],"__init__() (o365.utils.utils.apicomponent method)":[[22,"O365.utils.utils.ApiComponent.__init__",false]],"__init__() (o365.utils.utils.pagination method)":[[22,"O365.utils.utils.Pagination.__init__",false]],"__init__() (o365.utils.utils.query method)":[[22,"O365.utils.utils.Query.__init__",false]],"__init__() (o365.utils.utils.recipient method)":[[22,"O365.utils.utils.Recipient.__init__",false]],"__init__() (o365.utils.utils.recipients method)":[[22,"O365.utils.utils.Recipients.__init__",false]],"__init__() (o365.utils.utils.trackerset method)":[[22,"O365.utils.utils.TrackerSet.__init__",false]],"about_me (o365.directory.user attribute)":[[6,"O365.directory.User.about_me",false]],"accept_event() (o365.calendar.event method)":[[3,"O365.calendar.Event.accept_event",false]],"accepted (o365.calendar.eventresponse attribute)":[[3,"O365.calendar.EventResponse.Accepted",false]],"account (class in o365.account)":[[1,"O365.account.Account",false]],"account_enabled (o365.directory.user attribute)":[[6,"O365.directory.User.account_enabled",false]],"active_checklist_item_count (o365.planner.task attribute)":[[13,"O365.planner.Task.active_checklist_item_count",false]],"activity (class in o365.teams)":[[17,"O365.teams.Activity",false]],"activity (o365.teams.presence attribute)":[[17,"O365.teams.Presence.activity",false]],"add() (o365.calendar.attendees method)":[[3,"O365.calendar.Attendees.add",false]],"add() (o365.utils.attachment.baseattachments method)":[[19,"O365.utils.attachment.BaseAttachments.add",false]],"add() (o365.utils.query.orderbyfilter method)":[[20,"O365.utils.query.OrderByFilter.add",false]],"add() (o365.utils.token.basetokenbackend method)":[[21,"O365.utils.token.BaseTokenBackend.add",false]],"add() (o365.utils.utils.recipients method)":[[22,"O365.utils.utils.Recipients.add",false]],"add() (o365.utils.utils.trackerset method)":[[22,"O365.utils.utils.TrackerSet.add",false]],"add_category() (o365.message.message method)":[[11,"O365.message.Message.add_category",false]],"add_column() (o365.excel.table method)":[[7,"O365.excel.Table.add_column",false]],"add_message_header() (o365.message.message method)":[[11,"O365.message.Message.add_message_header",false]],"add_named_range() (o365.excel.workbook method)":[[7,"O365.excel.WorkBook.add_named_range",false]],"add_named_range() (o365.excel.worksheet method)":[[7,"O365.excel.WorkSheet.add_named_range",false]],"add_rows() (o365.excel.table method)":[[7,"O365.excel.Table.add_rows",false]],"add_table() (o365.excel.worksheet method)":[[7,"O365.excel.WorkSheet.add_table",false]],"add_worksheet() (o365.excel.workbook method)":[[7,"O365.excel.WorkBook.add_worksheet",false]],"address (o365.calendar.attendee property)":[[3,"O365.calendar.Attendee.address",false]],"address (o365.excel.range attribute)":[[7,"O365.excel.Range.address",false]],"address (o365.utils.utils.recipient property)":[[22,"O365.utils.utils.Recipient.address",false]],"address_book() (o365.account.account method)":[[1,"O365.account.Account.address_book",false]],"address_local (o365.excel.range attribute)":[[7,"O365.excel.Range.address_local",false]],"addressbook (class in o365.address_book)":[[2,"O365.address_book.AddressBook",false]],"age_group (o365.directory.user attribute)":[[6,"O365.directory.User.age_group",false]],"all (o365.mailbox.externalaudience attribute)":[[10,"O365.mailbox.ExternalAudience.ALL",false]],"all() (o365.utils.query.querybuilder method)":[[20,"O365.utils.query.QueryBuilder.all",false]],"all() (o365.utils.utils.query method)":[[22,"O365.utils.utils.Query.all",false]],"alwaysenabled (o365.mailbox.autoreplystatus attribute)":[[10,"O365.mailbox.AutoReplyStatus.ALWAYSENABLED",false]],"and (o365.utils.utils.chainoperator attribute)":[[22,"O365.utils.utils.ChainOperator.AND",false]],"any() (o365.utils.query.querybuilder method)":[[20,"O365.utils.query.QueryBuilder.any",false]],"any() (o365.utils.utils.query method)":[[22,"O365.utils.utils.Query.any",false]],"api_version (o365.connection.protocol attribute)":[[5,"O365.connection.Protocol.api_version",false]],"apicomponent (class in o365.utils.utils)":[[22,"O365.utils.utils.ApiComponent",false]],"app (class in o365.teams)":[[17,"O365.teams.App",false]],"app_definition (o365.teams.app attribute)":[[17,"O365.teams.App.app_definition",false]],"app_root (o365.utils.utils.onedrivewellknowfoldernames attribute)":[[22,"O365.utils.utils.OneDriveWellKnowFolderNames.APP_ROOT",false]],"append() (o365.utils.query.containerqueryfilter method)":[[20,"O365.utils.query.ContainerQueryFilter.append",false]],"append_rows() (o365.excel.worksheet method)":[[7,"O365.excel.WorkSheet.append_rows",false]],"applied_categories (o365.planner.task attribute)":[[13,"O365.planner.Task.applied_categories",false]],"apply_filter() (o365.excel.tablecolumn method)":[[7,"O365.excel.TableColumn.apply_filter",false]],"archive (o365.utils.utils.outlookwellknowfoldernames attribute)":[[22,"O365.utils.utils.OutlookWellKnowFolderNames.ARCHIVE",false]],"archive_folder() (o365.mailbox.mailbox method)":[[10,"O365.mailbox.MailBox.archive_folder",false]],"as_params() (o365.utils.query.compositefilter method)":[[20,"O365.utils.query.CompositeFilter.as_params",false]],"as_params() (o365.utils.query.containerqueryfilter method)":[[20,"O365.utils.query.ContainerQueryFilter.as_params",false]],"as_params() (o365.utils.query.orderbyfilter method)":[[20,"O365.utils.query.OrderByFilter.as_params",false]],"as_params() (o365.utils.query.querybase method)":[[20,"O365.utils.query.QueryBase.as_params",false]],"as_params() (o365.utils.query.queryfilter method)":[[20,"O365.utils.query.QueryFilter.as_params",false]],"as_params() (o365.utils.query.searchfilter method)":[[20,"O365.utils.query.SearchFilter.as_params",false]],"as_params() (o365.utils.utils.query method)":[[22,"O365.utils.utils.Query.as_params",false]],"assigned_licenses (o365.directory.user attribute)":[[6,"O365.directory.User.assigned_licenses",false]],"assigned_plans (o365.directory.user attribute)":[[6,"O365.directory.User.assigned_plans",false]],"assignee_priority (o365.planner.task attribute)":[[13,"O365.planner.Task.assignee_priority",false]],"assignments (o365.planner.task attribute)":[[13,"O365.planner.Task.assignments",false]],"attach() (o365.utils.attachment.baseattachment method)":[[19,"O365.utils.attachment.BaseAttachment.attach",false]],"attachablemixin (class in o365.utils.attachment)":[[19,"O365.utils.attachment.AttachableMixin",false]],"attachment (o365.utils.attachment.baseattachment attribute)":[[19,"O365.utils.attachment.BaseAttachment.attachment",false]],"attachment_id (o365.utils.attachment.baseattachment attribute)":[[19,"O365.utils.attachment.BaseAttachment.attachment_id",false]],"attachment_name (o365.utils.attachment.attachablemixin property)":[[19,"O365.utils.attachment.AttachableMixin.attachment_name",false]],"attachment_type (o365.utils.attachment.attachablemixin property)":[[19,"O365.utils.attachment.AttachableMixin.attachment_type",false]],"attachment_type (o365.utils.attachment.baseattachment attribute)":[[19,"O365.utils.attachment.BaseAttachment.attachment_type",false]],"attachments (o365.calendar.event property)":[[3,"O365.calendar.Event.attachments",false]],"attachments (o365.message.message property)":[[11,"O365.message.Message.attachments",false]],"attachments (o365.utils.utils.onedrivewellknowfoldernames attribute)":[[22,"O365.utils.utils.OneDriveWellKnowFolderNames.ATTACHMENTS",false]],"attendee (class in o365.calendar)":[[3,"O365.calendar.Attendee",false]],"attendee_type (o365.calendar.attendee property)":[[3,"O365.calendar.Attendee.attendee_type",false]],"attendees (class in o365.calendar)":[[3,"O365.calendar.Attendees",false]],"attendees (o365.calendar.event property)":[[3,"O365.calendar.Event.attendees",false]],"attendeetype (class in o365.calendar)":[[3,"O365.calendar.AttendeeType",false]],"auth (o365.connection.connection attribute)":[[5,"O365.connection.Connection.auth",false]],"auth_flow_type (o365.connection.connection property)":[[5,"O365.connection.Connection.auth_flow_type",false]],"authenticate() (o365.account.account method)":[[1,"O365.account.Account.authenticate",false]],"auto (o365.calendar.calendarcolor attribute)":[[3,"O365.calendar.CalendarColor.Auto",false]],"auto_fit_columns() (o365.excel.rangeformat method)":[[7,"O365.excel.RangeFormat.auto_fit_columns",false]],"auto_fit_rows() (o365.excel.rangeformat method)":[[7,"O365.excel.RangeFormat.auto_fit_rows",false]],"automaticrepliessettings (class in o365.mailbox)":[[10,"O365.mailbox.AutomaticRepliesSettings",false]],"automaticrepliessettings (o365.mailbox.mailboxsettings attribute)":[[10,"O365.mailbox.MailboxSettings.automaticrepliessettings",false]],"autoreplystatus (class in o365.mailbox)":[[10,"O365.mailbox.AutoReplyStatus",false]],"availability (class in o365.teams)":[[17,"O365.teams.Availability",false]],"availability (o365.teams.presence attribute)":[[17,"O365.teams.Presence.availability",false]],"available (o365.teams.activity attribute)":[[17,"O365.teams.Activity.AVAILABLE",false]],"available (o365.teams.availability attribute)":[[17,"O365.teams.Availability.AVAILABLE",false]],"available (o365.teams.preferredactivity attribute)":[[17,"O365.teams.PreferredActivity.AVAILABLE",false]],"available (o365.teams.preferredavailability attribute)":[[17,"O365.teams.PreferredAvailability.AVAILABLE",false]],"away (o365.teams.activity attribute)":[[17,"O365.teams.Activity.AWAY",false]],"away (o365.teams.availability attribute)":[[17,"O365.teams.Availability.AWAY",false]],"away (o365.teams.preferredactivity attribute)":[[17,"O365.teams.PreferredActivity.AWAY",false]],"away (o365.teams.preferredavailability attribute)":[[17,"O365.teams.PreferredAvailability.AWAY",false]],"awss3backend (class in o365.utils.token)":[[21,"O365.utils.token.AWSS3Backend",false]],"awssecretsbackend (class in o365.utils.token)":[[21,"O365.utils.token.AWSSecretsBackend",false]],"background_color (o365.excel.rangeformat property)":[[7,"O365.excel.RangeFormat.background_color",false]],"baseattachment (class in o365.utils.attachment)":[[19,"O365.utils.attachment.BaseAttachment",false]],"baseattachments (class in o365.utils.attachment)":[[19,"O365.utils.attachment.BaseAttachments",false]],"basecontactfolder (class in o365.address_book)":[[2,"O365.address_book.BaseContactFolder",false]],"basetokenbackend (class in o365.utils.token)":[[21,"O365.utils.token.BaseTokenBackend",false]],"bcc (o365.message.message property)":[[11,"O365.message.Message.bcc",false]],"bcc (o365.message.recipienttype attribute)":[[11,"O365.message.RecipientType.BCC",false]],"berightback (o365.teams.preferredactivity attribute)":[[17,"O365.teams.PreferredActivity.BERIGHTBACK",false]],"berightback (o365.teams.preferredavailability attribute)":[[17,"O365.teams.PreferredAvailability.BERIGHTBACK",false]],"birthday (o365.directory.user attribute)":[[6,"O365.directory.User.birthday",false]],"bitwardensecretsmanagerbackend (class in o365.utils.token)":[[21,"O365.utils.token.BitwardenSecretsManagerBackend",false]],"black (o365.category.categorycolor attribute)":[[4,"O365.category.CategoryColor.BLACK",false]],"blue (o365.category.categorycolor attribute)":[[4,"O365.category.CategoryColor.BLUE",false]],"body (o365.calendar.event property)":[[3,"O365.calendar.Event.body",false]],"body (o365.message.message property)":[[11,"O365.message.Message.body",false]],"body (o365.tasks.task property)":[[16,"O365.tasks.Task.body",false]],"body_preview (o365.message.message property)":[[11,"O365.message.Message.body_preview",false]],"body_type (o365.calendar.event attribute)":[[3,"O365.calendar.Event.body_type",false]],"body_type (o365.message.message attribute)":[[11,"O365.message.Message.body_type",false]],"body_type (o365.tasks.task attribute)":[[16,"O365.tasks.Task.body_type",false]],"bold (o365.excel.rangeformatfont property)":[[7,"O365.excel.RangeFormatFont.bold",false]],"brown (o365.category.categorycolor attribute)":[[4,"O365.category.CategoryColor.BROWN",false]],"bucket (class in o365.planner)":[[13,"O365.planner.Bucket",false]],"bucket_id (o365.planner.task attribute)":[[13,"O365.planner.Task.bucket_id",false]],"bucket_name (o365.utils.token.awss3backend attribute)":[[21,"O365.utils.token.AWSS3Backend.bucket_name",false]],"build_base_url() (o365.utils.utils.apicomponent method)":[[22,"O365.utils.utils.ApiComponent.build_base_url",false]],"build_field_filter() (o365.sharepoint.sharepointlist method)":[[14,"O365.sharepoint.SharepointList.build_field_filter",false]],"build_url() (o365.utils.utils.apicomponent method)":[[22,"O365.utils.utils.ApiComponent.build_url",false]],"business_address (o365.address_book.contact property)":[[2,"O365.address_book.Contact.business_address",false]],"business_phones (o365.address_book.contact property)":[[2,"O365.address_book.Contact.business_phones",false]],"business_phones (o365.directory.user attribute)":[[6,"O365.directory.User.business_phones",false]],"busy (o365.calendar.eventshowas attribute)":[[3,"O365.calendar.EventShowAs.Busy",false]],"busy (o365.teams.availability attribute)":[[17,"O365.teams.Availability.BUSY",false]],"busy (o365.teams.preferredactivity attribute)":[[17,"O365.teams.PreferredActivity.BUSY",false]],"busy (o365.teams.preferredavailability attribute)":[[17,"O365.teams.PreferredAvailability.BUSY",false]],"calendar (class in o365.calendar)":[[3,"O365.calendar.Calendar",false]],"calendar_id (o365.calendar.calendar attribute)":[[3,"O365.calendar.Calendar.calendar_id",false]],"calendar_id (o365.calendar.event attribute)":[[3,"O365.calendar.Event.calendar_id",false]],"calendarcolor (class in o365.calendar)":[[3,"O365.calendar.CalendarColor",false]],"camera_make (o365.drive.photo attribute)":[[12,"O365.drive.Photo.camera_make",false]],"camera_model (o365.drive.photo attribute)":[[12,"O365.drive.Photo.camera_model",false]],"camera_roll (o365.utils.utils.onedrivewellknowfoldernames attribute)":[[22,"O365.utils.utils.OneDriveWellKnowFolderNames.CAMERA_ROLL",false]],"can_edit (o365.calendar.calendar attribute)":[[3,"O365.calendar.Calendar.can_edit",false]],"can_share (o365.calendar.calendar attribute)":[[3,"O365.calendar.Calendar.can_share",false]],"can_view_private_items (o365.calendar.calendar attribute)":[[3,"O365.calendar.Calendar.can_view_private_items",false]],"cancel_event() (o365.calendar.event method)":[[3,"O365.calendar.Event.cancel_event",false]],"caseenum (class in o365.utils.utils)":[[22,"O365.utils.utils.CaseEnum",false]],"casing_function (o365.connection.protocol attribute)":[[5,"O365.connection.Protocol.casing_function",false]],"categories (class in o365.category)":[[4,"O365.category.Categories",false]],"categories (o365.address_book.contact property)":[[2,"O365.address_book.Contact.categories",false]],"categories (o365.calendar.event property)":[[3,"O365.calendar.Event.categories",false]],"categories (o365.message.message property)":[[11,"O365.message.Message.categories",false]],"category (class in o365.category)":[[4,"O365.category.Category",false]],"category_descriptions (o365.planner.plandetails attribute)":[[13,"O365.planner.PlanDetails.category_descriptions",false]],"categorycolor (class in o365.category)":[[4,"O365.category.CategoryColor",false]],"cc (o365.message.message property)":[[11,"O365.message.Message.cc",false]],"cc (o365.message.recipienttype attribute)":[[11,"O365.message.RecipientType.CC",false]],"cell_count (o365.excel.range attribute)":[[7,"O365.excel.Range.cell_count",false]],"chain() (o365.utils.utils.query method)":[[22,"O365.utils.utils.Query.chain",false]],"chain_and() (o365.utils.query.querybuilder method)":[[20,"O365.utils.query.QueryBuilder.chain_and",false]],"chain_or() (o365.utils.query.querybuilder method)":[[20,"O365.utils.query.QueryBuilder.chain_or",false]],"chainfilter (class in o365.utils.query)":[[20,"O365.utils.query.ChainFilter",false]],"chainoperator (class in o365.utils.utils)":[[22,"O365.utils.utils.ChainOperator",false]],"channel (class in o365.teams)":[[17,"O365.teams.Channel",false]],"channel_id (o365.teams.channelmessage attribute)":[[17,"O365.teams.ChannelMessage.channel_id",false]],"channel_identity (o365.teams.chatmessage attribute)":[[17,"O365.teams.ChatMessage.channel_identity",false]],"channelmessage (class in o365.teams)":[[17,"O365.teams.ChannelMessage",false]],"chat (class in o365.teams)":[[17,"O365.teams.Chat",false]],"chat_id (o365.teams.chatmessage attribute)":[[17,"O365.teams.ChatMessage.chat_id",false]],"chat_type (o365.teams.chat attribute)":[[17,"O365.teams.Chat.chat_type",false]],"chatmessage (class in o365.teams)":[[17,"O365.teams.ChatMessage",false]],"check_status() (o365.drive.copyoperation method)":[[12,"O365.drive.CopyOperation.check_status",false]],"check_token() (o365.utils.token.awss3backend method)":[[21,"O365.utils.token.AWSS3Backend.check_token",false]],"check_token() (o365.utils.token.awssecretsbackend method)":[[21,"O365.utils.token.AWSSecretsBackend.check_token",false]],"check_token() (o365.utils.token.basetokenbackend method)":[[21,"O365.utils.token.BaseTokenBackend.check_token",false]],"check_token() (o365.utils.token.djangotokenbackend method)":[[21,"O365.utils.token.DjangoTokenBackend.check_token",false]],"check_token() (o365.utils.token.envtokenbackend method)":[[21,"O365.utils.token.EnvTokenBackend.check_token",false]],"check_token() (o365.utils.token.filesystemtokenbackend method)":[[21,"O365.utils.token.FileSystemTokenBackend.check_token",false]],"check_token() (o365.utils.token.firestorebackend method)":[[21,"O365.utils.token.FirestoreBackend.check_token",false]],"checked (o365.tasks.checklistitem property)":[[16,"O365.tasks.ChecklistItem.checked",false]],"checklist (o365.planner.taskdetails attribute)":[[13,"O365.planner.TaskDetails.checklist",false]],"checklist_item_count (o365.planner.task attribute)":[[13,"O365.planner.Task.checklist_item_count",false]],"checklist_items (o365.tasks.task property)":[[16,"O365.tasks.Task.checklist_items",false]],"checklistitem (class in o365.tasks)":[[16,"O365.tasks.ChecklistItem",false]],"child_count (o365.drive.folder attribute)":[[12,"O365.drive.Folder.child_count",false]],"child_folders_count (o365.mailbox.folder attribute)":[[10,"O365.mailbox.Folder.child_folders_count",false]],"city (o365.directory.user attribute)":[[6,"O365.directory.User.city",false]],"clear() (o365.calendar.attendees method)":[[3,"O365.calendar.Attendees.clear",false]],"clear() (o365.excel.range method)":[[7,"O365.excel.Range.clear",false]],"clear() (o365.utils.attachment.baseattachments method)":[[19,"O365.utils.attachment.BaseAttachments.clear",false]],"clear() (o365.utils.utils.query method)":[[22,"O365.utils.utils.Query.clear",false]],"clear() (o365.utils.utils.recipients method)":[[22,"O365.utils.utils.Recipients.clear",false]],"clear_filter() (o365.excel.tablecolumn method)":[[7,"O365.excel.TableColumn.clear_filter",false]],"clear_filters() (o365.excel.table method)":[[7,"O365.excel.Table.clear_filters",false]],"clear_filters() (o365.utils.query.compositefilter method)":[[20,"O365.utils.query.CompositeFilter.clear_filters",false]],"clear_filters() (o365.utils.utils.query method)":[[22,"O365.utils.utils.Query.clear_filters",false]],"clear_order() (o365.utils.utils.query method)":[[22,"O365.utils.utils.Query.clear_order",false]],"client (o365.utils.token.bitwardensecretsmanagerbackend attribute)":[[21,"O365.utils.token.BitwardenSecretsManagerBackend.client",false]],"client (o365.utils.token.firestorebackend attribute)":[[21,"O365.utils.token.FirestoreBackend.client",false]],"close_group() (o365.utils.utils.query method)":[[22,"O365.utils.utils.Query.close_group",false]],"close_session() (o365.excel.workbooksession method)":[[7,"O365.excel.WorkbookSession.close_session",false]],"clutter (o365.utils.utils.outlookwellknowfoldernames attribute)":[[22,"O365.utils.utils.OutlookWellKnowFolderNames.CLUTTER",false]],"clutter_folder() (o365.mailbox.mailbox method)":[[10,"O365.mailbox.MailBox.clutter_folder",false]],"collection (o365.utils.token.firestorebackend attribute)":[[21,"O365.utils.token.FirestoreBackend.collection",false]],"color (o365.calendar.calendar attribute)":[[3,"O365.calendar.Calendar.color",false]],"color (o365.category.category attribute)":[[4,"O365.category.Category.color",false]],"color (o365.excel.rangeformatfont property)":[[7,"O365.excel.RangeFormatFont.color",false]],"column_count (o365.excel.range attribute)":[[7,"O365.excel.Range.column_count",false]],"column_group (o365.sharepoint.sharepointlistcolumn attribute)":[[14,"O365.sharepoint.SharepointListColumn.column_group",false]],"column_hidden (o365.excel.range property)":[[7,"O365.excel.Range.column_hidden",false]],"column_index (o365.excel.range attribute)":[[7,"O365.excel.Range.column_index",false]],"column_name_cw (o365.sharepoint.sharepointlist attribute)":[[14,"O365.sharepoint.SharepointList.column_name_cw",false]],"column_width (o365.excel.rangeformat property)":[[7,"O365.excel.RangeFormat.column_width",false]],"comment (o365.excel.namedrange attribute)":[[7,"O365.excel.NamedRange.comment",false]],"company_name (o365.address_book.contact property)":[[2,"O365.address_book.Contact.company_name",false]],"company_name (o365.directory.user attribute)":[[6,"O365.directory.User.company_name",false]],"complete (o365.message.flag attribute)":[[11,"O365.message.Flag.Complete",false]],"completed (o365.tasks.task property)":[[16,"O365.tasks.Task.completed",false]],"completed_date (o365.planner.task attribute)":[[13,"O365.planner.Task.completed_date",false]],"completion_percentage (o365.drive.copyoperation attribute)":[[12,"O365.drive.CopyOperation.completion_percentage",false]],"completition_date (o365.message.messageflag property)":[[11,"O365.message.MessageFlag.completition_date",false]],"compositefilter (class in o365.utils.query)":[[20,"O365.utils.query.CompositeFilter",false]],"confidential (o365.calendar.eventsensitivity attribute)":[[3,"O365.calendar.EventSensitivity.Confidential",false]],"conflicts (o365.utils.utils.outlookwellknowfoldernames attribute)":[[22,"O365.utils.utils.OutlookWellKnowFolderNames.CONFLICTS",false]],"conflicts_folder() (o365.mailbox.mailbox method)":[[10,"O365.mailbox.MailBox.conflicts_folder",false]],"connection (class in o365.connection)":[[5,"O365.connection.Connection",false]],"connection (o365.account.account property)":[[1,"O365.account.Account.connection",false]],"consent_provided_for_minor (o365.directory.user attribute)":[[6,"O365.directory.User.consent_provided_for_minor",false]],"constructor (o365.utils.utils.pagination attribute)":[[22,"O365.utils.utils.Pagination.constructor",false]],"contact (class in o365.address_book)":[[2,"O365.address_book.Contact",false]],"contactfolder (class in o365.address_book)":[[2,"O365.address_book.ContactFolder",false]],"contactsonly (o365.mailbox.externalaudience attribute)":[[10,"O365.mailbox.ExternalAudience.CONTACTSONLY",false]],"containerqueryfilter (class in o365.utils.query)":[[20,"O365.utils.query.ContainerQueryFilter",false]],"contains() (o365.utils.query.querybuilder method)":[[20,"O365.utils.query.QueryBuilder.contains",false]],"contains() (o365.utils.utils.query method)":[[22,"O365.utils.utils.Query.contains",false]],"content (o365.teams.chatmessage attribute)":[[17,"O365.teams.ChatMessage.content",false]],"content (o365.utils.attachment.baseattachment attribute)":[[19,"O365.utils.attachment.BaseAttachment.content",false]],"content_id (o365.utils.attachment.baseattachment attribute)":[[19,"O365.utils.attachment.BaseAttachment.content_id",false]],"content_type (o365.teams.chatmessage attribute)":[[17,"O365.teams.ChatMessage.content_type",false]],"content_type_id (o365.sharepoint.sharepointlistitem attribute)":[[14,"O365.sharepoint.SharepointListItem.content_type_id",false]],"content_types_enabled (o365.sharepoint.sharepointlist attribute)":[[14,"O365.sharepoint.SharepointList.content_types_enabled",false]],"conversation_id (o365.message.message attribute)":[[11,"O365.message.Message.conversation_id",false]],"conversation_index (o365.message.message attribute)":[[11,"O365.message.Message.conversation_index",false]],"conversation_thread_id (o365.planner.task attribute)":[[13,"O365.planner.Task.conversation_thread_id",false]],"conversationhistory (o365.utils.utils.outlookwellknowfoldernames attribute)":[[22,"O365.utils.utils.OutlookWellKnowFolderNames.CONVERSATIONHISTORY",false]],"conversationhistory_folder() (o365.mailbox.mailbox method)":[[10,"O365.mailbox.MailBox.conversationhistory_folder",false]],"conversationmember (class in o365.teams)":[[17,"O365.teams.ConversationMember",false]],"convert_case() (o365.connection.protocol method)":[[5,"O365.connection.Protocol.convert_case",false]],"convert_to_range() (o365.excel.table method)":[[7,"O365.excel.Table.convert_to_range",false]],"copy() (o365.drive.driveitem method)":[[12,"O365.drive.DriveItem.copy",false]],"copy() (o365.message.message method)":[[11,"O365.message.Message.copy",false]],"copy_folder() (o365.mailbox.folder method)":[[10,"O365.mailbox.Folder.copy_folder",false]],"copyoperation (class in o365.drive)":[[12,"O365.drive.CopyOperation",false]],"country (o365.directory.user attribute)":[[6,"O365.directory.User.country",false]],"cranberry (o365.category.categorycolor attribute)":[[4,"O365.category.CategoryColor.CRANBERRY",false]],"create_bucket() (o365.planner.plan method)":[[13,"O365.planner.Plan.create_bucket",false]],"create_category() (o365.category.categories method)":[[4,"O365.category.Categories.create_category",false]],"create_channel() (o365.teams.teams method)":[[17,"O365.teams.Teams.create_channel",false]],"create_child_folder() (o365.address_book.contactfolder method)":[[2,"O365.address_book.ContactFolder.create_child_folder",false]],"create_child_folder() (o365.drive.folder method)":[[12,"O365.drive.Folder.create_child_folder",false]],"create_child_folder() (o365.mailbox.folder method)":[[10,"O365.mailbox.Folder.create_child_folder",false]],"create_list() (o365.sharepoint.site method)":[[14,"O365.sharepoint.Site.create_list",false]],"create_list_item() (o365.sharepoint.sharepointlist method)":[[14,"O365.sharepoint.SharepointList.create_list_item",false]],"create_plan() (o365.planner.planner method)":[[13,"O365.planner.Planner.create_plan",false]],"create_session() (o365.excel.workbooksession method)":[[7,"O365.excel.WorkbookSession.create_session",false]],"create_subscription() (o365.subscriptions.subscriptions method)":[[15,"O365.subscriptions.Subscriptions.create_subscription",false]],"create_task() (o365.planner.bucket method)":[[13,"O365.planner.Bucket.create_task",false]],"created (o365.address_book.contact property)":[[2,"O365.address_book.Contact.created",false]],"created (o365.calendar.event property)":[[3,"O365.calendar.Event.created",false]],"created (o365.directory.user attribute)":[[6,"O365.directory.User.created",false]],"created (o365.drive.driveitem attribute)":[[12,"O365.drive.DriveItem.created",false]],"created (o365.message.message property)":[[11,"O365.message.Message.created",false]],"created (o365.sharepoint.sharepointlist attribute)":[[14,"O365.sharepoint.SharepointList.created",false]],"created (o365.sharepoint.sharepointlistitem attribute)":[[14,"O365.sharepoint.SharepointListItem.created",false]],"created (o365.sharepoint.site attribute)":[[14,"O365.sharepoint.Site.created",false]],"created (o365.tasks.checklistitem property)":[[16,"O365.tasks.ChecklistItem.created",false]],"created (o365.tasks.task property)":[[16,"O365.tasks.Task.created",false]],"created_by (o365.drive.driveitem attribute)":[[12,"O365.drive.DriveItem.created_by",false]],"created_by (o365.sharepoint.sharepointlist attribute)":[[14,"O365.sharepoint.SharepointList.created_by",false]],"created_by (o365.sharepoint.sharepointlistitem attribute)":[[14,"O365.sharepoint.SharepointListItem.created_by",false]],"created_date (o365.planner.task attribute)":[[13,"O365.planner.Task.created_date",false]],"created_date (o365.teams.chat attribute)":[[17,"O365.teams.Chat.created_date",false]],"created_date (o365.teams.chatmessage attribute)":[[17,"O365.teams.ChatMessage.created_date",false]],"created_date_time (o365.planner.plan attribute)":[[13,"O365.planner.Plan.created_date_time",false]],"cryptography_manager (o365.utils.token.basetokenbackend attribute)":[[21,"O365.utils.token.BaseTokenBackend.cryptography_manager",false]],"cryptographymanagertype (class in o365.utils.token)":[[21,"O365.utils.token.CryptographyManagerType",false]],"dailyeventfrequency (class in o365.calendar)":[[3,"O365.calendar.DailyEventFrequency",false]],"darkblue (o365.category.categorycolor attribute)":[[4,"O365.category.CategoryColor.DARKBLUE",false]],"darkbrown (o365.category.categorycolor attribute)":[[4,"O365.category.CategoryColor.DARKBROWN",false]],"darkcranberry (o365.category.categorycolor attribute)":[[4,"O365.category.CategoryColor.DARKCRANBERRY",false]],"darkgreen (o365.category.categorycolor attribute)":[[4,"O365.category.CategoryColor.DARKGREEN",false]],"darkgrey (o365.category.categorycolor attribute)":[[4,"O365.category.CategoryColor.DARKGREY",false]],"darkolive (o365.category.categorycolor attribute)":[[4,"O365.category.CategoryColor.DARKOLIVE",false]],"darkorange (o365.category.categorycolor attribute)":[[4,"O365.category.CategoryColor.DARKORANGE",false]],"darkpurple (o365.category.categorycolor attribute)":[[4,"O365.category.CategoryColor.DARKPURPLE",false]],"darkred (o365.category.categorycolor attribute)":[[4,"O365.category.CategoryColor.DARKRED",false]],"darksteel (o365.category.categorycolor attribute)":[[4,"O365.category.CategoryColor.DARKSTEEL",false]],"darkteal (o365.category.categorycolor attribute)":[[4,"O365.category.CategoryColor.DARKTEAL",false]],"darkyellow (o365.category.categorycolor attribute)":[[4,"O365.category.CategoryColor.DARKYELLOW",false]],"data_count (o365.utils.utils.pagination attribute)":[[22,"O365.utils.utils.Pagination.data_count",false]],"data_type (o365.excel.namedrange attribute)":[[7,"O365.excel.NamedRange.data_type",false]],"day_of_month (o365.calendar.eventrecurrence property)":[[3,"O365.calendar.EventRecurrence.day_of_month",false]],"days_of_week (o365.calendar.eventrecurrence property)":[[3,"O365.calendar.EventRecurrence.days_of_week",false]],"decline_event() (o365.calendar.event method)":[[3,"O365.calendar.Event.decline_event",false]],"declined (o365.calendar.eventresponse attribute)":[[3,"O365.calendar.EventResponse.Declined",false]],"decrypt() (o365.utils.token.cryptographymanagertype method)":[[21,"O365.utils.token.CryptographyManagerType.decrypt",false]],"default_headers (o365.connection.connection attribute)":[[5,"O365.connection.Connection.default_headers",false]],"default_resource (o365.connection.protocol attribute)":[[5,"O365.connection.Protocol.default_resource",false]],"delay_delivery() (o365.message.message method)":[[11,"O365.message.Message.delay_delivery",false]],"delete() (o365.address_book.contact method)":[[2,"O365.address_book.Contact.delete",false]],"delete() (o365.address_book.contactfolder method)":[[2,"O365.address_book.ContactFolder.delete",false]],"delete() (o365.calendar.calendar method)":[[3,"O365.calendar.Calendar.delete",false]],"delete() (o365.calendar.event method)":[[3,"O365.calendar.Event.delete",false]],"delete() (o365.category.category method)":[[4,"O365.category.Category.delete",false]],"delete() (o365.connection.connection method)":[[5,"O365.connection.Connection.delete",false]],"delete() (o365.drive.driveitem method)":[[12,"O365.drive.DriveItem.delete",false]],"delete() (o365.drive.driveitempermission method)":[[12,"O365.drive.DriveItemPermission.delete",false]],"delete() (o365.excel.range method)":[[7,"O365.excel.Range.delete",false]],"delete() (o365.excel.table method)":[[7,"O365.excel.Table.delete",false]],"delete() (o365.excel.tablecolumn method)":[[7,"O365.excel.TableColumn.delete",false]],"delete() (o365.excel.tablerow method)":[[7,"O365.excel.TableRow.delete",false]],"delete() (o365.excel.workbooksession method)":[[7,"O365.excel.WorkbookSession.delete",false]],"delete() (o365.excel.worksheet method)":[[7,"O365.excel.WorkSheet.delete",false]],"delete() (o365.mailbox.folder method)":[[10,"O365.mailbox.Folder.delete",false]],"delete() (o365.message.message method)":[[11,"O365.message.Message.delete",false]],"delete() (o365.planner.bucket method)":[[13,"O365.planner.Bucket.delete",false]],"delete() (o365.planner.plan method)":[[13,"O365.planner.Plan.delete",false]],"delete() (o365.planner.task method)":[[13,"O365.planner.Task.delete",false]],"delete() (o365.sharepoint.sharepointlistitem method)":[[14,"O365.sharepoint.SharepointListItem.delete",false]],"delete() (o365.tasks.checklistitem method)":[[16,"O365.tasks.ChecklistItem.delete",false]],"delete() (o365.tasks.folder method)":[[16,"O365.tasks.Folder.delete",false]],"delete() (o365.tasks.task method)":[[16,"O365.tasks.Task.delete",false]],"delete_column() (o365.excel.table method)":[[7,"O365.excel.Table.delete_column",false]],"delete_flag() (o365.message.messageflag method)":[[11,"O365.message.MessageFlag.delete_flag",false]],"delete_list_item() (o365.sharepoint.sharepointlist method)":[[14,"O365.sharepoint.SharepointList.delete_list_item",false]],"delete_message() (o365.mailbox.folder method)":[[10,"O365.mailbox.Folder.delete_message",false]],"delete_row() (o365.excel.table method)":[[7,"O365.excel.Table.delete_row",false]],"delete_subscription() (o365.subscriptions.subscriptions method)":[[15,"O365.subscriptions.Subscriptions.delete_subscription",false]],"delete_token() (o365.utils.token.awss3backend method)":[[21,"O365.utils.token.AWSS3Backend.delete_token",false]],"delete_token() (o365.utils.token.awssecretsbackend method)":[[21,"O365.utils.token.AWSSecretsBackend.delete_token",false]],"delete_token() (o365.utils.token.basetokenbackend method)":[[21,"O365.utils.token.BaseTokenBackend.delete_token",false]],"delete_token() (o365.utils.token.djangotokenbackend method)":[[21,"O365.utils.token.DjangoTokenBackend.delete_token",false]],"delete_token() (o365.utils.token.envtokenbackend method)":[[21,"O365.utils.token.EnvTokenBackend.delete_token",false]],"delete_token() (o365.utils.token.filesystemtokenbackend method)":[[21,"O365.utils.token.FileSystemTokenBackend.delete_token",false]],"delete_token() (o365.utils.token.firestorebackend method)":[[21,"O365.utils.token.FirestoreBackend.delete_token",false]],"delete_worksheet() (o365.excel.workbook method)":[[7,"O365.excel.WorkBook.delete_worksheet",false]],"deleted (o365.utils.utils.outlookwellknowfoldernames attribute)":[[22,"O365.utils.utils.OutlookWellKnowFolderNames.DELETED",false]],"deleted_date (o365.teams.chatmessage attribute)":[[17,"O365.teams.ChatMessage.deleted_date",false]],"deleted_folder() (o365.mailbox.mailbox method)":[[10,"O365.mailbox.MailBox.deleted_folder",false]],"department (o365.address_book.contact property)":[[2,"O365.address_book.Contact.department",false]],"department (o365.directory.user attribute)":[[6,"O365.directory.User.department",false]],"description (o365.drive.driveitem attribute)":[[12,"O365.drive.DriveItem.description",false]],"description (o365.groups.group attribute)":[[9,"O365.groups.Group.description",false]],"description (o365.planner.taskdetails attribute)":[[13,"O365.planner.TaskDetails.description",false]],"description (o365.sharepoint.sharepointlist attribute)":[[14,"O365.sharepoint.SharepointList.description",false]],"description (o365.sharepoint.sharepointlistcolumn attribute)":[[14,"O365.sharepoint.SharepointListColumn.description",false]],"description (o365.sharepoint.site attribute)":[[14,"O365.sharepoint.Site.description",false]],"description (o365.teams.channel attribute)":[[17,"O365.teams.Channel.description",false]],"description (o365.teams.team attribute)":[[17,"O365.teams.Team.description",false]],"deserialize() (o365.utils.token.basetokenbackend method)":[[21,"O365.utils.token.BaseTokenBackend.deserialize",false]],"dimensions (o365.drive.image property)":[[12,"O365.drive.Image.dimensions",false]],"directory (class in o365.directory)":[[6,"O365.directory.Directory",false]],"directory() (o365.account.account method)":[[1,"O365.account.Account.directory",false]],"disabled (o365.mailbox.autoreplystatus attribute)":[[10,"O365.mailbox.AutoReplyStatus.DISABLED",false]],"display_name (o365.address_book.contact property)":[[2,"O365.address_book.Contact.display_name",false]],"display_name (o365.directory.user attribute)":[[6,"O365.directory.User.display_name",false]],"display_name (o365.groups.group attribute)":[[9,"O365.groups.Group.display_name",false]],"display_name (o365.sharepoint.sharepointlist attribute)":[[14,"O365.sharepoint.SharepointList.display_name",false]],"display_name (o365.sharepoint.sharepointlistcolumn attribute)":[[14,"O365.sharepoint.SharepointListColumn.display_name",false]],"display_name (o365.sharepoint.site attribute)":[[14,"O365.sharepoint.Site.display_name",false]],"display_name (o365.teams.channel attribute)":[[17,"O365.teams.Channel.display_name",false]],"display_name (o365.teams.team attribute)":[[17,"O365.teams.Team.display_name",false]],"displayname (o365.tasks.checklistitem property)":[[16,"O365.tasks.ChecklistItem.displayname",false]],"djangotokenbackend (class in o365.utils.token)":[[21,"O365.utils.token.DjangoTokenBackend",false]],"doc_id (o365.utils.token.firestorebackend attribute)":[[21,"O365.utils.token.FirestoreBackend.doc_id",false]],"doc_ref (o365.utils.token.firestorebackend attribute)":[[21,"O365.utils.token.FirestoreBackend.doc_ref",false]],"documents (o365.utils.utils.onedrivewellknowfoldernames attribute)":[[22,"O365.utils.utils.OneDriveWellKnowFolderNames.DOCUMENTS",false]],"donotdisturb (o365.teams.availability attribute)":[[17,"O365.teams.Availability.DONOTDISTURB",false]],"donotdisturb (o365.teams.preferredactivity attribute)":[[17,"O365.teams.PreferredActivity.DONOTDISTURB",false]],"donotdisturb (o365.teams.preferredavailability attribute)":[[17,"O365.teams.PreferredAvailability.DONOTDISTURB",false]],"download() (o365.drive.downloadablemixin method)":[[12,"O365.drive.DownloadableMixin.download",false]],"download() (o365.drive.driveitemversion method)":[[12,"O365.drive.DriveItemVersion.download",false]],"download_attachments() (o365.utils.attachment.baseattachments method)":[[19,"O365.utils.attachment.BaseAttachments.download_attachments",false]],"download_contents() (o365.drive.folder method)":[[12,"O365.drive.Folder.download_contents",false]],"downloadablemixin (class in o365.drive)":[[12,"O365.drive.DownloadableMixin",false]],"drafts (o365.utils.utils.outlookwellknowfoldernames attribute)":[[22,"O365.utils.utils.OutlookWellKnowFolderNames.DRAFTS",false]],"drafts_folder() (o365.mailbox.mailbox method)":[[10,"O365.mailbox.MailBox.drafts_folder",false]],"drive (class in o365.drive)":[[12,"O365.drive.Drive",false]],"drive (o365.drive.driveitem attribute)":[[12,"O365.drive.DriveItem.drive",false]],"drive_id (o365.drive.driveitem attribute)":[[12,"O365.drive.DriveItem.drive_id",false]],"driveitem (class in o365.drive)":[[12,"O365.drive.DriveItem",false]],"driveitem_id (o365.drive.driveitempermission attribute)":[[12,"O365.drive.DriveItemPermission.driveitem_id",false]],"driveitem_id (o365.drive.driveitemversion attribute)":[[12,"O365.drive.DriveItemVersion.driveitem_id",false]],"driveitempermission (class in o365.drive)":[[12,"O365.drive.DriveItemPermission",false]],"driveitemversion (class in o365.drive)":[[12,"O365.drive.DriveItemVersion",false]],"due (o365.tasks.task property)":[[16,"O365.tasks.Task.due",false]],"due_date (o365.message.messageflag property)":[[11,"O365.message.MessageFlag.due_date",false]],"due_date_time (o365.planner.task attribute)":[[13,"O365.planner.Task.due_date_time",false]],"email (o365.teams.channel attribute)":[[17,"O365.teams.Channel.email",false]],"emails (o365.address_book.contact property)":[[2,"O365.address_book.Contact.emails",false]],"employee_id (o365.directory.user attribute)":[[6,"O365.directory.User.employee_id",false]],"encrypt() (o365.utils.token.cryptographymanagertype method)":[[21,"O365.utils.token.CryptographyManagerType.encrypt",false]],"end (o365.calendar.event property)":[[3,"O365.calendar.Event.end",false]],"end_date (o365.calendar.eventrecurrence property)":[[3,"O365.calendar.EventRecurrence.end_date",false]],"endswith() (o365.utils.query.querybuilder method)":[[20,"O365.utils.query.QueryBuilder.endswith",false]],"endswith() (o365.utils.utils.query method)":[[22,"O365.utils.utils.Query.endswith",false]],"enforce_unique_values (o365.sharepoint.sharepointlistcolumn attribute)":[[14,"O365.sharepoint.SharepointListColumn.enforce_unique_values",false]],"envtokenbackend (class in o365.utils.token)":[[21,"O365.utils.token.EnvTokenBackend",false]],"equals() (o365.utils.query.querybuilder method)":[[20,"O365.utils.query.QueryBuilder.equals",false]],"equals() (o365.utils.utils.query method)":[[22,"O365.utils.utils.Query.equals",false]],"event (class in o365.calendar)":[[3,"O365.calendar.Event",false]],"event_type (o365.calendar.event property)":[[3,"O365.calendar.Event.event_type",false]],"eventattachment (class in o365.calendar)":[[3,"O365.calendar.EventAttachment",false]],"eventattachments (class in o365.calendar)":[[3,"O365.calendar.EventAttachments",false]],"eventrecurrence (class in o365.calendar)":[[3,"O365.calendar.EventRecurrence",false]],"eventresponse (class in o365.calendar)":[[3,"O365.calendar.EventResponse",false]],"eventsensitivity (class in o365.calendar)":[[3,"O365.calendar.EventSensitivity",false]],"eventshowas (class in o365.calendar)":[[3,"O365.calendar.EventShowAs",false]],"eventtype (class in o365.calendar)":[[3,"O365.calendar.EventType",false]],"exception (o365.calendar.eventtype attribute)":[[3,"O365.calendar.EventType.Exception",false]],"expand (o365.utils.query.compositefilter attribute)":[[20,"O365.utils.query.CompositeFilter.expand",false]],"expand() (o365.utils.query.querybuilder method)":[[20,"O365.utils.query.QueryBuilder.expand",false]],"expand() (o365.utils.utils.query method)":[[22,"O365.utils.utils.Query.expand",false]],"expandfilter (class in o365.utils.query)":[[20,"O365.utils.query.ExpandFilter",false]],"exposure_denominator (o365.drive.photo attribute)":[[12,"O365.drive.Photo.exposure_denominator",false]],"exposure_numerator (o365.drive.photo attribute)":[[12,"O365.drive.Photo.exposure_numerator",false]],"extension (o365.drive.file property)":[[12,"O365.drive.File.extension",false]],"external_audience (o365.mailbox.automaticrepliessettings property)":[[10,"O365.mailbox.AutomaticRepliesSettings.external_audience",false]],"external_reply_message (o365.mailbox.automaticrepliessettings attribute)":[[10,"O365.mailbox.AutomaticRepliesSettings.external_reply_message",false]],"externalaudience (class in o365.mailbox)":[[10,"O365.mailbox.ExternalAudience",false]],"extra_args (o365.utils.utils.pagination attribute)":[[22,"O365.utils.utils.Pagination.extra_args",false]],"fax_number (o365.directory.user attribute)":[[6,"O365.directory.User.fax_number",false]],"field_name (o365.utils.token.firestorebackend attribute)":[[21,"O365.utils.token.FirestoreBackend.field_name",false]],"field_type (o365.sharepoint.sharepointlistcolumn attribute)":[[14,"O365.sharepoint.SharepointListColumn.field_type",false]],"fields (o365.sharepoint.sharepointlistitem attribute)":[[14,"O365.sharepoint.SharepointListItem.fields",false]],"file (class in o365.drive)":[[12,"O365.drive.File",false]],"fileas (o365.address_book.contact property)":[[2,"O365.address_book.Contact.fileAs",false]],"filename (o365.utils.token.awss3backend attribute)":[[21,"O365.utils.token.AWSS3Backend.filename",false]],"filesystemtokenbackend (class in o365.utils.token)":[[21,"O365.utils.token.FileSystemTokenBackend",false]],"filters (o365.utils.query.compositefilter attribute)":[[20,"O365.utils.query.CompositeFilter.filters",false]],"firestorebackend (class in o365.utils.token)":[[21,"O365.utils.token.FirestoreBackend",false]],"first_day_of_week (o365.calendar.eventrecurrence property)":[[3,"O365.calendar.EventRecurrence.first_day_of_week",false]],"flag (class in o365.message)":[[11,"O365.message.Flag",false]],"flag (o365.message.message property)":[[11,"O365.message.Message.flag",false]],"flagged (o365.message.flag attribute)":[[11,"O365.message.Flag.Flagged",false]],"fnumber (o365.drive.photo attribute)":[[12,"O365.drive.Photo.fnumber",false]],"focal_length (o365.drive.photo attribute)":[[12,"O365.drive.Photo.focal_length",false]],"folder (class in o365.drive)":[[12,"O365.drive.Folder",false]],"folder (class in o365.mailbox)":[[10,"O365.mailbox.Folder",false]],"folder (class in o365.tasks)":[[16,"O365.tasks.Folder",false]],"folder_id (o365.address_book.basecontactfolder attribute)":[[2,"O365.address_book.BaseContactFolder.folder_id",false]],"folder_id (o365.address_book.contact property)":[[2,"O365.address_book.Contact.folder_id",false]],"folder_id (o365.mailbox.folder attribute)":[[10,"O365.mailbox.Folder.folder_id",false]],"folder_id (o365.message.message attribute)":[[11,"O365.message.Message.folder_id",false]],"folder_id (o365.tasks.checklistitem attribute)":[[16,"O365.tasks.ChecklistItem.folder_id",false]],"folder_id (o365.tasks.folder attribute)":[[16,"O365.tasks.Folder.folder_id",false]],"folder_id (o365.tasks.task attribute)":[[16,"O365.tasks.Task.folder_id",false]],"font (o365.excel.rangeformat property)":[[7,"O365.excel.RangeFormat.font",false]],"formulas (o365.excel.range property)":[[7,"O365.excel.Range.formulas",false]],"formulas_local (o365.excel.range property)":[[7,"O365.excel.Range.formulas_local",false]],"formulas_r1_c1 (o365.excel.range property)":[[7,"O365.excel.Range.formulas_r1_c1",false]],"forward() (o365.message.message method)":[[11,"O365.message.Message.forward",false]],"free (o365.calendar.eventshowas attribute)":[[3,"O365.calendar.EventShowAs.Free",false]],"from_display_name (o365.teams.chatmessage attribute)":[[17,"O365.teams.ChatMessage.from_display_name",false]],"from_id (o365.teams.chatmessage attribute)":[[17,"O365.teams.ChatMessage.from_id",false]],"from_type (o365.teams.chatmessage attribute)":[[17,"O365.teams.ChatMessage.from_type",false]],"from_value() (o365.utils.utils.caseenum class method)":[[22,"O365.utils.utils.CaseEnum.from_value",false]],"full_name (o365.address_book.contact property)":[[2,"O365.address_book.Contact.full_name",false]],"full_name (o365.directory.user property)":[[6,"O365.directory.User.full_name",false]],"function() (o365.utils.utils.query method)":[[22,"O365.utils.utils.Query.function",false]],"function_operation() (o365.utils.query.querybuilder method)":[[20,"O365.utils.query.QueryBuilder.function_operation",false]],"functionexception":[[7,"O365.excel.FunctionException",false]],"functionfilter (class in o365.utils.query)":[[20,"O365.utils.query.FunctionFilter",false]],"get() (o365.category.categorycolor class method)":[[4,"O365.category.CategoryColor.get",false]],"get() (o365.connection.connection method)":[[5,"O365.connection.Connection.get",false]],"get() (o365.excel.workbooksession method)":[[7,"O365.excel.WorkbookSession.get",false]],"get_access_token() (o365.utils.token.basetokenbackend method)":[[21,"O365.utils.token.BaseTokenBackend.get_access_token",false]],"get_account() (o365.utils.token.basetokenbackend method)":[[21,"O365.utils.token.BaseTokenBackend.get_account",false]],"get_all_accounts() (o365.utils.token.basetokenbackend method)":[[21,"O365.utils.token.BaseTokenBackend.get_all_accounts",false]],"get_apps_in_team() (o365.teams.teams method)":[[17,"O365.teams.Teams.get_apps_in_team",false]],"get_authenticated_usernames() (o365.account.account method)":[[1,"O365.account.Account.get_authenticated_usernames",false]],"get_authorization_url() (o365.account.account method)":[[1,"O365.account.Account.get_authorization_url",false]],"get_authorization_url() (o365.connection.connection method)":[[5,"O365.connection.Connection.get_authorization_url",false]],"get_availability() (o365.calendar.schedule method)":[[3,"O365.calendar.Schedule.get_availability",false]],"get_body_soup() (o365.calendar.event method)":[[3,"O365.calendar.Event.get_body_soup",false]],"get_body_soup() (o365.message.message method)":[[11,"O365.message.Message.get_body_soup",false]],"get_body_soup() (o365.tasks.task method)":[[16,"O365.tasks.Task.get_body_soup",false]],"get_body_text() (o365.calendar.event method)":[[3,"O365.calendar.Event.get_body_text",false]],"get_body_text() (o365.message.message method)":[[11,"O365.message.Message.get_body_text",false]],"get_body_text() (o365.tasks.task method)":[[16,"O365.tasks.Task.get_body_text",false]],"get_bounding_rect() (o365.excel.range method)":[[7,"O365.excel.Range.get_bounding_rect",false]],"get_bucket_by_id() (o365.planner.planner method)":[[13,"O365.planner.Planner.get_bucket_by_id",false]],"get_calendar() (o365.calendar.schedule method)":[[3,"O365.calendar.Schedule.get_calendar",false]],"get_categories() (o365.category.categories method)":[[4,"O365.category.Categories.get_categories",false]],"get_category() (o365.category.categories method)":[[4,"O365.category.Categories.get_category",false]],"get_cell() (o365.excel.range method)":[[7,"O365.excel.Range.get_cell",false]],"get_cell() (o365.excel.worksheet method)":[[7,"O365.excel.WorkSheet.get_cell",false]],"get_channel() (o365.teams.team method)":[[17,"O365.teams.Team.get_channel",false]],"get_channel() (o365.teams.teams method)":[[17,"O365.teams.Teams.get_channel",false]],"get_channels() (o365.teams.team method)":[[17,"O365.teams.Team.get_channels",false]],"get_channels() (o365.teams.teams method)":[[17,"O365.teams.Teams.get_channels",false]],"get_checklist_item() (o365.tasks.task method)":[[16,"O365.tasks.Task.get_checklist_item",false]],"get_checklist_items() (o365.tasks.task method)":[[16,"O365.tasks.Task.get_checklist_items",false]],"get_child_folders() (o365.drive.drive method)":[[12,"O365.drive.Drive.get_child_folders",false]],"get_child_folders() (o365.drive.folder method)":[[12,"O365.drive.Folder.get_child_folders",false]],"get_column() (o365.excel.range method)":[[7,"O365.excel.Range.get_column",false]],"get_column() (o365.excel.table method)":[[7,"O365.excel.Table.get_column",false]],"get_column_at_index() (o365.excel.table method)":[[7,"O365.excel.Table.get_column_at_index",false]],"get_columns() (o365.excel.table method)":[[7,"O365.excel.Table.get_columns",false]],"get_columns_after() (o365.excel.range method)":[[7,"O365.excel.Range.get_columns_after",false]],"get_columns_before() (o365.excel.range method)":[[7,"O365.excel.Range.get_columns_before",false]],"get_contact_by_email() (o365.address_book.basecontactfolder method)":[[2,"O365.address_book.BaseContactFolder.get_contact_by_email",false]],"get_contacts() (o365.address_book.basecontactfolder method)":[[2,"O365.address_book.BaseContactFolder.get_contacts",false]],"get_current_user() (o365.directory.directory method)":[[6,"O365.directory.Directory.get_current_user",false]],"get_current_user_data() (o365.account.account method)":[[1,"O365.account.Account.get_current_user_data",false]],"get_data_body_range() (o365.excel.table method)":[[7,"O365.excel.Table.get_data_body_range",false]],"get_data_body_range() (o365.excel.tablecolumn method)":[[7,"O365.excel.TableColumn.get_data_body_range",false]],"get_default_calendar() (o365.calendar.schedule method)":[[3,"O365.calendar.Schedule.get_default_calendar",false]],"get_default_document_library() (o365.sharepoint.site method)":[[14,"O365.sharepoint.Site.get_default_document_library",false]],"get_default_drive() (o365.drive.storage method)":[[12,"O365.drive.Storage.get_default_drive",false]],"get_default_folder() (o365.tasks.todo method)":[[16,"O365.tasks.ToDo.get_default_folder",false]],"get_details() (o365.excel.workbookapplication method)":[[7,"O365.excel.WorkbookApplication.get_details",false]],"get_details() (o365.planner.plan method)":[[13,"O365.planner.Plan.get_details",false]],"get_details() (o365.planner.task method)":[[13,"O365.planner.Task.get_details",false]],"get_document_library() (o365.sharepoint.site method)":[[14,"O365.sharepoint.Site.get_document_library",false]],"get_drive() (o365.drive.driveitem method)":[[12,"O365.drive.DriveItem.get_drive",false]],"get_drive() (o365.drive.storage method)":[[12,"O365.drive.Storage.get_drive",false]],"get_drives() (o365.drive.storage method)":[[12,"O365.drive.Storage.get_drives",false]],"get_eml_as_object() (o365.message.messageattachments method)":[[11,"O365.message.MessageAttachments.get_eml_as_object",false]],"get_entire_column() (o365.excel.range method)":[[7,"O365.excel.Range.get_entire_column",false]],"get_event() (o365.calendar.calendar method)":[[3,"O365.calendar.Calendar.get_event",false]],"get_event() (o365.message.message method)":[[11,"O365.message.Message.get_event",false]],"get_events() (o365.calendar.calendar method)":[[3,"O365.calendar.Calendar.get_events",false]],"get_events() (o365.calendar.schedule method)":[[3,"O365.calendar.Schedule.get_events",false]],"get_expands() (o365.utils.utils.query method)":[[22,"O365.utils.utils.Query.get_expands",false]],"get_filter() (o365.excel.tablecolumn method)":[[7,"O365.excel.TableColumn.get_filter",false]],"get_filter_by_attribute() (o365.utils.query.querybase method)":[[20,"O365.utils.query.QueryBase.get_filter_by_attribute",false]],"get_filter_by_attribute() (o365.utils.utils.query method)":[[22,"O365.utils.utils.Query.get_filter_by_attribute",false]],"get_filters() (o365.utils.utils.query method)":[[22,"O365.utils.utils.Query.get_filters",false]],"get_first_recipient_with_address() (o365.utils.utils.recipients method)":[[22,"O365.utils.utils.Recipients.get_first_recipient_with_address",false]],"get_folder() (o365.address_book.contactfolder method)":[[2,"O365.address_book.ContactFolder.get_folder",false]],"get_folder() (o365.mailbox.folder method)":[[10,"O365.mailbox.Folder.get_folder",false]],"get_folder() (o365.tasks.todo method)":[[16,"O365.tasks.ToDo.get_folder",false]],"get_folders() (o365.address_book.contactfolder method)":[[2,"O365.address_book.ContactFolder.get_folders",false]],"get_folders() (o365.mailbox.folder method)":[[10,"O365.mailbox.Folder.get_folders",false]],"get_format() (o365.excel.range method)":[[7,"O365.excel.Range.get_format",false]],"get_group_by_id() (o365.groups.groups method)":[[9,"O365.groups.Groups.get_group_by_id",false]],"get_group_by_mail() (o365.groups.groups method)":[[9,"O365.groups.Groups.get_group_by_mail",false]],"get_group_members() (o365.groups.group method)":[[9,"O365.groups.Group.get_group_members",false]],"get_group_owners() (o365.groups.group method)":[[9,"O365.groups.Group.get_group_owners",false]],"get_header_row_range() (o365.excel.table method)":[[7,"O365.excel.Table.get_header_row_range",false]],"get_header_row_range() (o365.excel.tablecolumn method)":[[7,"O365.excel.TableColumn.get_header_row_range",false]],"get_id_token() (o365.utils.token.basetokenbackend method)":[[21,"O365.utils.token.BaseTokenBackend.get_id_token",false]],"get_intersection() (o365.excel.range method)":[[7,"O365.excel.Range.get_intersection",false]],"get_item() (o365.drive.copyoperation method)":[[12,"O365.drive.CopyOperation.get_item",false]],"get_item() (o365.drive.drive method)":[[12,"O365.drive.Drive.get_item",false]],"get_item_by_id() (o365.sharepoint.sharepointlist method)":[[14,"O365.sharepoint.SharepointList.get_item_by_id",false]],"get_item_by_path() (o365.drive.drive method)":[[12,"O365.drive.Drive.get_item_by_path",false]],"get_items() (o365.drive.drive method)":[[12,"O365.drive.Drive.get_items",false]],"get_items() (o365.drive.folder method)":[[12,"O365.drive.Folder.get_items",false]],"get_items() (o365.sharepoint.sharepointlist method)":[[14,"O365.sharepoint.SharepointList.get_items",false]],"get_last_cell() (o365.excel.range method)":[[7,"O365.excel.Range.get_last_cell",false]],"get_last_column() (o365.excel.range method)":[[7,"O365.excel.Range.get_last_column",false]],"get_last_row() (o365.excel.range method)":[[7,"O365.excel.Range.get_last_row",false]],"get_list_by_name() (o365.sharepoint.site method)":[[14,"O365.sharepoint.Site.get_list_by_name",false]],"get_list_columns() (o365.sharepoint.sharepointlist method)":[[14,"O365.sharepoint.SharepointList.get_list_columns",false]],"get_lists() (o365.sharepoint.site method)":[[14,"O365.sharepoint.Site.get_lists",false]],"get_member() (o365.teams.chat method)":[[17,"O365.teams.Chat.get_member",false]],"get_members() (o365.teams.chat method)":[[17,"O365.teams.Chat.get_members",false]],"get_message() (o365.mailbox.folder method)":[[10,"O365.mailbox.Folder.get_message",false]],"get_message() (o365.teams.channel method)":[[17,"O365.teams.Channel.get_message",false]],"get_message() (o365.teams.chat method)":[[17,"O365.teams.Chat.get_message",false]],"get_messages() (o365.mailbox.folder method)":[[10,"O365.mailbox.Folder.get_messages",false]],"get_messages() (o365.teams.channel method)":[[17,"O365.teams.Channel.get_messages",false]],"get_messages() (o365.teams.chat method)":[[17,"O365.teams.Chat.get_messages",false]],"get_mime_content() (o365.message.message method)":[[11,"O365.message.Message.get_mime_content",false]],"get_mime_content() (o365.message.messageattachments method)":[[11,"O365.message.MessageAttachments.get_mime_content",false]],"get_my_chats() (o365.teams.teams method)":[[17,"O365.teams.Teams.get_my_chats",false]],"get_my_presence() (o365.teams.teams method)":[[17,"O365.teams.Teams.get_my_presence",false]],"get_my_tasks() (o365.planner.planner method)":[[13,"O365.planner.Planner.get_my_tasks",false]],"get_my_teams() (o365.teams.teams method)":[[17,"O365.teams.Teams.get_my_teams",false]],"get_naive_session() (o365.connection.connection method)":[[5,"O365.connection.Connection.get_naive_session",false]],"get_named_range() (o365.excel.workbook method)":[[7,"O365.excel.WorkBook.get_named_range",false]],"get_named_range() (o365.excel.worksheet method)":[[7,"O365.excel.WorkSheet.get_named_range",false]],"get_named_ranges() (o365.excel.workbook method)":[[7,"O365.excel.WorkBook.get_named_ranges",false]],"get_occurrences() (o365.calendar.event method)":[[3,"O365.calendar.Event.get_occurrences",false]],"get_offset_range() (o365.excel.range method)":[[7,"O365.excel.Range.get_offset_range",false]],"get_order() (o365.utils.utils.query method)":[[22,"O365.utils.utils.Query.get_order",false]],"get_parent() (o365.drive.driveitem method)":[[12,"O365.drive.DriveItem.get_parent",false]],"get_parent_folder() (o365.mailbox.folder method)":[[10,"O365.mailbox.Folder.get_parent_folder",false]],"get_permissions() (o365.drive.driveitem method)":[[12,"O365.drive.DriveItem.get_permissions",false]],"get_plan_by_id() (o365.planner.planner method)":[[13,"O365.planner.Planner.get_plan_by_id",false]],"get_profile_photo() (o365.address_book.contact method)":[[2,"O365.address_book.Contact.get_profile_photo",false]],"get_profile_photo() (o365.directory.user method)":[[6,"O365.directory.User.get_profile_photo",false]],"get_range() (o365.excel.namedrange method)":[[7,"O365.excel.NamedRange.get_range",false]],"get_range() (o365.excel.table method)":[[7,"O365.excel.Table.get_range",false]],"get_range() (o365.excel.tablecolumn method)":[[7,"O365.excel.TableColumn.get_range",false]],"get_range() (o365.excel.tablerow method)":[[7,"O365.excel.TableRow.get_range",false]],"get_range() (o365.excel.worksheet method)":[[7,"O365.excel.WorkSheet.get_range",false]],"get_recent() (o365.drive.drive method)":[[12,"O365.drive.Drive.get_recent",false]],"get_refresh_token() (o365.utils.token.basetokenbackend method)":[[21,"O365.utils.token.BaseTokenBackend.get_refresh_token",false]],"get_replies() (o365.teams.channelmessage method)":[[17,"O365.teams.ChannelMessage.get_replies",false]],"get_reply() (o365.teams.channelmessage method)":[[17,"O365.teams.ChannelMessage.get_reply",false]],"get_resized_range() (o365.excel.range method)":[[7,"O365.excel.Range.get_resized_range",false]],"get_root_folder() (o365.drive.drive method)":[[12,"O365.drive.Drive.get_root_folder",false]],"get_root_site() (o365.sharepoint.sharepoint method)":[[14,"O365.sharepoint.Sharepoint.get_root_site",false]],"get_row() (o365.excel.range method)":[[7,"O365.excel.Range.get_row",false]],"get_row() (o365.excel.table method)":[[7,"O365.excel.Table.get_row",false]],"get_row_at_index() (o365.excel.table method)":[[7,"O365.excel.Table.get_row_at_index",false]],"get_rows() (o365.excel.table method)":[[7,"O365.excel.Table.get_rows",false]],"get_rows_above() (o365.excel.range method)":[[7,"O365.excel.Range.get_rows_above",false]],"get_rows_below() (o365.excel.range method)":[[7,"O365.excel.Range.get_rows_below",false]],"get_scopes_for() (o365.connection.protocol method)":[[5,"O365.connection.Protocol.get_scopes_for",false]],"get_selects() (o365.utils.utils.query method)":[[22,"O365.utils.utils.Query.get_selects",false]],"get_service_keyword() (o365.connection.protocol method)":[[5,"O365.connection.Protocol.get_service_keyword",false]],"get_session() (o365.connection.connection method)":[[5,"O365.connection.Connection.get_session",false]],"get_settings() (o365.mailbox.mailbox method)":[[10,"O365.mailbox.MailBox.get_settings",false]],"get_shared_with_me() (o365.drive.drive method)":[[12,"O365.drive.Drive.get_shared_with_me",false]],"get_site() (o365.sharepoint.sharepoint method)":[[14,"O365.sharepoint.Sharepoint.get_site",false]],"get_special_folder() (o365.drive.drive method)":[[12,"O365.drive.Drive.get_special_folder",false]],"get_subscription() (o365.subscriptions.subscriptions method)":[[15,"O365.subscriptions.Subscriptions.get_subscription",false]],"get_subsites() (o365.sharepoint.site method)":[[14,"O365.sharepoint.Site.get_subsites",false]],"get_table() (o365.excel.workbook method)":[[7,"O365.excel.WorkBook.get_table",false]],"get_table() (o365.excel.worksheet method)":[[7,"O365.excel.WorkSheet.get_table",false]],"get_tables() (o365.excel.workbook method)":[[7,"O365.excel.WorkBook.get_tables",false]],"get_tables() (o365.excel.worksheet method)":[[7,"O365.excel.WorkSheet.get_tables",false]],"get_task() (o365.tasks.folder method)":[[16,"O365.tasks.Folder.get_task",false]],"get_task_by_id() (o365.planner.planner method)":[[13,"O365.planner.Planner.get_task_by_id",false]],"get_tasks() (o365.tasks.folder method)":[[16,"O365.tasks.Folder.get_tasks",false]],"get_tasks() (o365.tasks.todo method)":[[16,"O365.tasks.ToDo.get_tasks",false]],"get_thumbnails() (o365.drive.driveitem method)":[[12,"O365.drive.DriveItem.get_thumbnails",false]],"get_token_scopes() (o365.utils.token.basetokenbackend method)":[[21,"O365.utils.token.BaseTokenBackend.get_token_scopes",false]],"get_total_row_range() (o365.excel.table method)":[[7,"O365.excel.Table.get_total_row_range",false]],"get_total_row_range() (o365.excel.tablecolumn method)":[[7,"O365.excel.TableColumn.get_total_row_range",false]],"get_used_range() (o365.excel.range method)":[[7,"O365.excel.Range.get_used_range",false]],"get_used_range() (o365.excel.worksheet method)":[[7,"O365.excel.WorkSheet.get_used_range",false]],"get_user() (o365.directory.directory method)":[[6,"O365.directory.Directory.get_user",false]],"get_user_direct_reports() (o365.directory.directory method)":[[6,"O365.directory.Directory.get_user_direct_reports",false]],"get_user_groups() (o365.groups.groups method)":[[9,"O365.groups.Groups.get_user_groups",false]],"get_user_manager() (o365.directory.directory method)":[[6,"O365.directory.Directory.get_user_manager",false]],"get_user_presence() (o365.teams.teams method)":[[17,"O365.teams.Teams.get_user_presence",false]],"get_users() (o365.directory.directory method)":[[6,"O365.directory.Directory.get_users",false]],"get_version() (o365.drive.driveitem method)":[[12,"O365.drive.DriveItem.get_version",false]],"get_versions() (o365.drive.driveitem method)":[[12,"O365.drive.DriveItem.get_versions",false]],"get_workbookapplication() (o365.excel.workbook method)":[[7,"O365.excel.WorkBook.get_workbookapplication",false]],"get_worksheet() (o365.excel.range method)":[[7,"O365.excel.Range.get_worksheet",false]],"get_worksheet() (o365.excel.table method)":[[7,"O365.excel.Table.get_worksheet",false]],"get_worksheet() (o365.excel.workbook method)":[[7,"O365.excel.WorkBook.get_worksheet",false]],"get_worksheets() (o365.excel.workbook method)":[[7,"O365.excel.WorkBook.get_worksheets",false]],"given_name (o365.directory.user attribute)":[[6,"O365.directory.User.given_name",false]],"granted_to (o365.drive.driveitempermission attribute)":[[12,"O365.drive.DriveItemPermission.granted_to",false]],"gray (o365.category.categorycolor attribute)":[[4,"O365.category.CategoryColor.GRAY",false]],"greater() (o365.utils.query.querybuilder method)":[[20,"O365.utils.query.QueryBuilder.greater",false]],"greater() (o365.utils.utils.query method)":[[22,"O365.utils.utils.Query.greater",false]],"greater_equal() (o365.utils.query.querybuilder method)":[[20,"O365.utils.query.QueryBuilder.greater_equal",false]],"greater_equal() (o365.utils.utils.query method)":[[22,"O365.utils.utils.Query.greater_equal",false]],"green (o365.category.categorycolor attribute)":[[4,"O365.category.CategoryColor.GREEN",false]],"group (class in o365.groups)":[[9,"O365.groups.Group",false]],"group() (o365.utils.query.querybuilder static method)":[[20,"O365.utils.query.QueryBuilder.group",false]],"group_id (o365.planner.plan attribute)":[[13,"O365.planner.Plan.group_id",false]],"groupfilter (class in o365.utils.query)":[[20,"O365.utils.query.GroupFilter",false]],"groups (class in o365.groups)":[[9,"O365.groups.Groups",false]],"groups() (o365.account.account method)":[[1,"O365.account.Account.groups",false]],"handlerecipientsmixin (class in o365.utils.utils)":[[22,"O365.utils.utils.HandleRecipientsMixin",false]],"has_attachments (o365.calendar.event attribute)":[[3,"O365.calendar.Event.has_attachments",false]],"has_attachments (o365.message.message property)":[[11,"O365.message.Message.has_attachments",false]],"has_data (o365.utils.token.basetokenbackend property)":[[21,"O365.utils.token.BaseTokenBackend.has_data",false]],"has_description (o365.planner.task attribute)":[[13,"O365.planner.Task.has_description",false]],"has_expands (o365.utils.query.compositefilter property)":[[20,"O365.utils.query.CompositeFilter.has_expands",false]],"has_expands (o365.utils.utils.query property)":[[22,"O365.utils.utils.Query.has_expands",false]],"has_filters (o365.utils.query.compositefilter property)":[[20,"O365.utils.query.CompositeFilter.has_filters",false]],"has_filters (o365.utils.utils.query property)":[[22,"O365.utils.utils.Query.has_filters",false]],"has_only_filters (o365.utils.query.compositefilter property)":[[20,"O365.utils.query.CompositeFilter.has_only_filters",false]],"has_order (o365.utils.utils.query property)":[[22,"O365.utils.utils.Query.has_order",false]],"has_order_by (o365.utils.query.compositefilter property)":[[20,"O365.utils.query.CompositeFilter.has_order_by",false]],"has_search (o365.utils.query.compositefilter property)":[[20,"O365.utils.query.CompositeFilter.has_search",false]],"has_selects (o365.utils.query.compositefilter property)":[[20,"O365.utils.query.CompositeFilter.has_selects",false]],"has_selects (o365.utils.utils.query property)":[[22,"O365.utils.utils.Query.has_selects",false]],"hashes (o365.drive.file attribute)":[[12,"O365.drive.File.hashes",false]],"height (o365.drive.image attribute)":[[12,"O365.drive.Image.height",false]],"hex_color (o365.calendar.calendar attribute)":[[3,"O365.calendar.Calendar.hex_color",false]],"hidden (o365.excel.range attribute)":[[7,"O365.excel.Range.hidden",false]],"hidden (o365.sharepoint.sharepointlist attribute)":[[14,"O365.sharepoint.SharepointList.hidden",false]],"hidden (o365.sharepoint.sharepointlistcolumn attribute)":[[14,"O365.sharepoint.SharepointListColumn.hidden",false]],"high (o365.utils.utils.importancelevel attribute)":[[22,"O365.utils.utils.ImportanceLevel.High",false]],"highlight_first_column (o365.excel.table attribute)":[[7,"O365.excel.Table.highlight_first_column",false]],"highlight_last_column (o365.excel.table attribute)":[[7,"O365.excel.Table.highlight_last_column",false]],"hire_date (o365.directory.user attribute)":[[6,"O365.directory.User.hire_date",false]],"home_address (o365.address_book.contact property)":[[2,"O365.address_book.Contact.home_address",false]],"home_phones (o365.address_book.contact property)":[[2,"O365.address_book.Contact.home_phones",false]],"horizontal_alignment (o365.excel.rangeformat property)":[[7,"O365.excel.RangeFormat.horizontal_alignment",false]],"ical_uid (o365.calendar.event attribute)":[[3,"O365.calendar.Event.ical_uid",false]],"im_addresses (o365.directory.user attribute)":[[6,"O365.directory.User.im_addresses",false]],"image (class in o365.drive)":[[12,"O365.drive.Image",false]],"importance (o365.calendar.event property)":[[3,"O365.calendar.Event.importance",false]],"importance (o365.message.message property)":[[11,"O365.message.Message.importance",false]],"importance (o365.tasks.task property)":[[16,"O365.tasks.Task.importance",false]],"importance (o365.teams.chatmessage attribute)":[[17,"O365.teams.ChatMessage.importance",false]],"importancelevel (class in o365.utils.utils)":[[22,"O365.utils.utils.ImportanceLevel",false]],"inacall (o365.teams.activity attribute)":[[17,"O365.teams.Activity.INACALL",false]],"inaconferencecall (o365.teams.activity attribute)":[[17,"O365.teams.Activity.INACONFERENCECALL",false]],"inactivity_limit (o365.excel.workbooksession attribute)":[[7,"O365.excel.WorkbookSession.inactivity_limit",false]],"inbox (o365.utils.utils.outlookwellknowfoldernames attribute)":[[22,"O365.utils.utils.OutlookWellKnowFolderNames.INBOX",false]],"inbox_folder() (o365.mailbox.mailbox method)":[[10,"O365.mailbox.MailBox.inbox_folder",false]],"index (o365.calendar.eventrecurrence property)":[[3,"O365.calendar.EventRecurrence.index",false]],"index (o365.excel.tablecolumn attribute)":[[7,"O365.excel.TableColumn.index",false]],"index (o365.excel.tablerow attribute)":[[7,"O365.excel.TableRow.index",false]],"indexed (o365.sharepoint.sharepointlistcolumn attribute)":[[14,"O365.sharepoint.SharepointListColumn.indexed",false]],"inference_classification (o365.message.message property)":[[11,"O365.message.Message.inference_classification",false]],"inherited_from (o365.drive.driveitempermission attribute)":[[12,"O365.drive.DriveItemPermission.inherited_from",false]],"insert_range() (o365.excel.range method)":[[7,"O365.excel.Range.insert_range",false]],"interests (o365.directory.user attribute)":[[6,"O365.directory.User.interests",false]],"internal_name (o365.sharepoint.sharepointlistcolumn attribute)":[[14,"O365.sharepoint.SharepointListColumn.internal_name",false]],"internal_reply_message (o365.mailbox.automaticrepliessettings attribute)":[[10,"O365.mailbox.AutomaticRepliesSettings.internal_reply_message",false]],"internet_message_id (o365.message.message attribute)":[[11,"O365.message.Message.internet_message_id",false]],"interval (o365.calendar.eventrecurrence property)":[[3,"O365.calendar.EventRecurrence.interval",false]],"invite_user() (o365.directory.directory method)":[[6,"O365.directory.Directory.invite_user",false]],"invited_by (o365.drive.driveitempermission attribute)":[[12,"O365.drive.DriveItemPermission.invited_by",false]],"invoke_function() (o365.excel.workbook method)":[[7,"O365.excel.WorkBook.invoke_function",false]],"is_all_day (o365.calendar.event property)":[[3,"O365.calendar.Event.is_all_day",false]],"is_archived (o365.teams.team attribute)":[[17,"O365.teams.Team.is_archived",false]],"is_authenticated (o365.account.account property)":[[1,"O365.account.Account.is_authenticated",false]],"is_cancelled (o365.calendar.event attribute)":[[3,"O365.calendar.Event.is_cancelled",false]],"is_checked (o365.tasks.checklistitem property)":[[16,"O365.tasks.ChecklistItem.is_checked",false]],"is_completed (o365.message.messageflag property)":[[11,"O365.message.MessageFlag.is_completed",false]],"is_completed (o365.tasks.task property)":[[16,"O365.tasks.Task.is_completed",false]],"is_default (o365.tasks.folder attribute)":[[16,"O365.tasks.Folder.is_default",false]],"is_delivery_receipt_requested (o365.message.message property)":[[11,"O365.message.Message.is_delivery_receipt_requested",false]],"is_draft (o365.message.message property)":[[11,"O365.message.Message.is_draft",false]],"is_event_message (o365.message.message property)":[[11,"O365.message.Message.is_event_message",false]],"is_file (o365.drive.driveitem property)":[[12,"O365.drive.DriveItem.is_file",false]],"is_flagged (o365.message.messageflag property)":[[11,"O365.message.MessageFlag.is_flagged",false]],"is_folder (o365.drive.driveitem property)":[[12,"O365.drive.DriveItem.is_folder",false]],"is_image (o365.drive.driveitem property)":[[12,"O365.drive.DriveItem.is_image",false]],"is_inline (o365.utils.attachment.baseattachment attribute)":[[19,"O365.utils.attachment.BaseAttachment.is_inline",false]],"is_online_meeting (o365.calendar.event property)":[[3,"O365.calendar.Event.is_online_meeting",false]],"is_organizer (o365.calendar.event attribute)":[[3,"O365.calendar.Event.is_organizer",false]],"is_photo (o365.drive.driveitem property)":[[12,"O365.drive.DriveItem.is_photo",false]],"is_read (o365.message.message property)":[[11,"O365.message.Message.is_read",false]],"is_read_receipt_requested (o365.message.message property)":[[11,"O365.message.Message.is_read_receipt_requested",false]],"is_reminder_on (o365.calendar.event property)":[[3,"O365.calendar.Event.is_reminder_on",false]],"is_reminder_on (o365.tasks.task property)":[[16,"O365.tasks.Task.is_reminder_on",false]],"is_resource_account (o365.directory.user attribute)":[[6,"O365.directory.User.is_resource_account",false]],"is_starred (o365.tasks.task property)":[[16,"O365.tasks.Task.is_starred",false]],"iso (o365.drive.photo attribute)":[[12,"O365.drive.Photo.iso",false]],"italic (o365.excel.rangeformatfont property)":[[7,"O365.excel.RangeFormatFont.italic",false]],"item_id (o365.drive.copyoperation attribute)":[[12,"O365.drive.CopyOperation.item_id",false]],"item_id (o365.tasks.checklistitem attribute)":[[16,"O365.tasks.ChecklistItem.item_id",false]],"iterable() (o365.utils.utils.query method)":[[22,"O365.utils.utils.Query.iterable",false]],"iterable_operation() (o365.utils.query.querybuilder method)":[[20,"O365.utils.query.QueryBuilder.iterable_operation",false]],"iterablefilter (class in o365.utils.query)":[[20,"O365.utils.query.IterableFilter",false]],"job_title (o365.address_book.contact property)":[[2,"O365.address_book.Contact.job_title",false]],"job_title (o365.directory.user attribute)":[[6,"O365.directory.User.job_title",false]],"json_encoder (o365.connection.connection attribute)":[[5,"O365.connection.Connection.json_encoder",false]],"junk (o365.utils.utils.outlookwellknowfoldernames attribute)":[[22,"O365.utils.utils.OutlookWellKnowFolderNames.JUNK",false]],"junk_folder() (o365.mailbox.mailbox method)":[[10,"O365.mailbox.MailBox.junk_folder",false]],"keyword_data_store (o365.connection.protocol attribute)":[[5,"O365.connection.Protocol.keyword_data_store",false]],"last_activity (o365.excel.workbooksession attribute)":[[7,"O365.excel.WorkbookSession.last_activity",false]],"last_edited_date (o365.teams.chatmessage attribute)":[[17,"O365.teams.ChatMessage.last_edited_date",false]],"last_modified_date (o365.teams.chatmessage attribute)":[[17,"O365.teams.ChatMessage.last_modified_date",false]],"last_password_change (o365.directory.user attribute)":[[6,"O365.directory.User.last_password_change",false]],"last_update_date (o365.teams.chat attribute)":[[17,"O365.teams.Chat.last_update_date",false]],"legacy_id (o365.excel.table attribute)":[[7,"O365.excel.Table.legacy_id",false]],"legal_age_group_classification (o365.directory.user attribute)":[[6,"O365.directory.User.legal_age_group_classification",false]],"less() (o365.utils.query.querybuilder method)":[[20,"O365.utils.query.QueryBuilder.less",false]],"less() (o365.utils.utils.query method)":[[22,"O365.utils.utils.Query.less",false]],"less_equal() (o365.utils.query.querybuilder method)":[[20,"O365.utils.query.QueryBuilder.less_equal",false]],"less_equal() (o365.utils.utils.query method)":[[22,"O365.utils.utils.Query.less_equal",false]],"license_assignment_states (o365.directory.user attribute)":[[6,"O365.directory.User.license_assignment_states",false]],"lightblue (o365.calendar.calendarcolor attribute)":[[3,"O365.calendar.CalendarColor.LightBlue",false]],"lightbrown (o365.calendar.calendarcolor attribute)":[[3,"O365.calendar.CalendarColor.LightBrown",false]],"lightgray (o365.calendar.calendarcolor attribute)":[[3,"O365.calendar.CalendarColor.LightGray",false]],"lightgreen (o365.calendar.calendarcolor attribute)":[[3,"O365.calendar.CalendarColor.LightGreen",false]],"lightorange (o365.calendar.calendarcolor attribute)":[[3,"O365.calendar.CalendarColor.LightOrange",false]],"lightpink (o365.calendar.calendarcolor attribute)":[[3,"O365.calendar.CalendarColor.LightPink",false]],"lightred (o365.calendar.calendarcolor attribute)":[[3,"O365.calendar.CalendarColor.LightRed",false]],"lightteal (o365.calendar.calendarcolor attribute)":[[3,"O365.calendar.CalendarColor.LightTeal",false]],"lightyellow (o365.calendar.calendarcolor attribute)":[[3,"O365.calendar.CalendarColor.LightYellow",false]],"limit (o365.utils.utils.pagination attribute)":[[22,"O365.utils.utils.Pagination.limit",false]],"list_buckets() (o365.planner.plan method)":[[13,"O365.planner.Plan.list_buckets",false]],"list_calendars() (o365.calendar.schedule method)":[[3,"O365.calendar.Schedule.list_calendars",false]],"list_document_libraries() (o365.sharepoint.site method)":[[14,"O365.sharepoint.Site.list_document_libraries",false]],"list_folders() (o365.tasks.todo method)":[[16,"O365.tasks.ToDo.list_folders",false]],"list_folders_delta() (o365.tasks.todo method)":[[16,"O365.tasks.ToDo.list_folders_delta",false]],"list_group_plans() (o365.planner.planner method)":[[13,"O365.planner.Planner.list_group_plans",false]],"list_groups() (o365.groups.groups method)":[[9,"O365.groups.Groups.list_groups",false]],"list_subscriptions() (o365.subscriptions.subscriptions method)":[[15,"O365.subscriptions.Subscriptions.list_subscriptions",false]],"list_tasks() (o365.planner.bucket method)":[[13,"O365.planner.Bucket.list_tasks",false]],"list_tasks() (o365.planner.plan method)":[[13,"O365.planner.Plan.list_tasks",false]],"list_user_tasks() (o365.planner.planner method)":[[13,"O365.planner.Planner.list_user_tasks",false]],"load_token() (o365.utils.token.awss3backend method)":[[21,"O365.utils.token.AWSS3Backend.load_token",false]],"load_token() (o365.utils.token.awssecretsbackend method)":[[21,"O365.utils.token.AWSSecretsBackend.load_token",false]],"load_token() (o365.utils.token.basetokenbackend method)":[[21,"O365.utils.token.BaseTokenBackend.load_token",false]],"load_token() (o365.utils.token.bitwardensecretsmanagerbackend method)":[[21,"O365.utils.token.BitwardenSecretsManagerBackend.load_token",false]],"load_token() (o365.utils.token.djangotokenbackend method)":[[21,"O365.utils.token.DjangoTokenBackend.load_token",false]],"load_token() (o365.utils.token.envtokenbackend method)":[[21,"O365.utils.token.EnvTokenBackend.load_token",false]],"load_token() (o365.utils.token.filesystemtokenbackend method)":[[21,"O365.utils.token.FileSystemTokenBackend.load_token",false]],"load_token() (o365.utils.token.firestorebackend method)":[[21,"O365.utils.token.FirestoreBackend.load_token",false]],"load_token() (o365.utils.token.memorytokenbackend method)":[[21,"O365.utils.token.MemoryTokenBackend.load_token",false]],"load_token_from_backend() (o365.connection.connection method)":[[5,"O365.connection.Connection.load_token_from_backend",false]],"localfailures (o365.utils.utils.outlookwellknowfoldernames attribute)":[[22,"O365.utils.utils.OutlookWellKnowFolderNames.LOCALFAILURES",false]],"localfailures_folder() (o365.mailbox.mailbox method)":[[10,"O365.mailbox.MailBox.localfailures_folder",false]],"location (o365.calendar.event property)":[[3,"O365.calendar.Event.location",false]],"locations (o365.calendar.event attribute)":[[3,"O365.calendar.Event.locations",false]],"logical_operation() (o365.utils.query.querybuilder method)":[[20,"O365.utils.query.QueryBuilder.logical_operation",false]],"logical_operator() (o365.utils.utils.query method)":[[22,"O365.utils.utils.Query.logical_operator",false]],"logicalfilter (class in o365.utils.query)":[[20,"O365.utils.query.LogicalFilter",false]],"low (o365.utils.utils.importancelevel attribute)":[[22,"O365.utils.utils.ImportanceLevel.Low",false]],"mail (o365.directory.user attribute)":[[6,"O365.directory.User.mail",false]],"mail (o365.groups.group attribute)":[[9,"O365.groups.Group.mail",false]],"mail_nickname (o365.directory.user attribute)":[[6,"O365.directory.User.mail_nickname",false]],"mail_nickname (o365.groups.group attribute)":[[9,"O365.groups.Group.mail_nickname",false]],"mailbox (class in o365.mailbox)":[[10,"O365.mailbox.MailBox",false]],"mailbox() (o365.account.account method)":[[1,"O365.account.Account.mailbox",false]],"mailbox_settings (o365.directory.user attribute)":[[6,"O365.directory.User.mailbox_settings",false]],"mailboxsettings (class in o365.mailbox)":[[10,"O365.mailbox.MailboxSettings",false]],"main_email (o365.address_book.contact property)":[[2,"O365.address_book.Contact.main_email",false]],"main_resource (o365.account.account attribute)":[[1,"O365.account.Account.main_resource",false]],"main_resource (o365.utils.utils.apicomponent attribute)":[[22,"O365.utils.utils.ApiComponent.main_resource",false]],"mark_as_read() (o365.message.message method)":[[11,"O365.message.Message.mark_as_read",false]],"mark_as_unread() (o365.message.message method)":[[11,"O365.message.Message.mark_as_unread",false]],"mark_checked() (o365.tasks.checklistitem method)":[[16,"O365.tasks.ChecklistItem.mark_checked",false]],"mark_completed() (o365.tasks.task method)":[[16,"O365.tasks.Task.mark_completed",false]],"mark_unchecked() (o365.tasks.checklistitem method)":[[16,"O365.tasks.ChecklistItem.mark_unchecked",false]],"mark_uncompleted() (o365.tasks.task method)":[[16,"O365.tasks.Task.mark_uncompleted",false]],"max_top_value (o365.connection.msbusinesscentral365protocol attribute)":[[5,"O365.connection.MSBusinessCentral365Protocol.max_top_value",false]],"max_top_value (o365.connection.msgraphprotocol attribute)":[[5,"O365.connection.MSGraphProtocol.max_top_value",false]],"max_top_value (o365.connection.protocol attribute)":[[5,"O365.connection.Protocol.max_top_value",false]],"maxcolor (o365.calendar.calendarcolor attribute)":[[3,"O365.calendar.CalendarColor.MaxColor",false]],"meeting_message_type (o365.message.message property)":[[11,"O365.message.Message.meeting_message_type",false]],"meetingaccepted (o365.message.meetingmessagetype attribute)":[[11,"O365.message.MeetingMessageType.MeetingAccepted",false]],"meetingcancelled (o365.message.meetingmessagetype attribute)":[[11,"O365.message.MeetingMessageType.MeetingCancelled",false]],"meetingdeclined (o365.message.meetingmessagetype attribute)":[[11,"O365.message.MeetingMessageType.MeetingDeclined",false]],"meetingmessagetype (class in o365.message)":[[11,"O365.message.MeetingMessageType",false]],"meetingrequest (o365.message.meetingmessagetype attribute)":[[11,"O365.message.MeetingMessageType.MeetingRequest",false]],"meetingtentativelyaccepted (o365.message.meetingmessagetype attribute)":[[11,"O365.message.MeetingMessageType.MeetingTentativelyAccepted",false]],"memorytokenbackend (class in o365.utils.token)":[[21,"O365.utils.token.MemoryTokenBackend",false]],"merge() (o365.excel.range method)":[[7,"O365.excel.Range.merge",false]],"message (class in o365.message)":[[11,"O365.message.Message",false]],"message_headers (o365.message.message property)":[[11,"O365.message.Message.message_headers",false]],"message_type (o365.teams.chatmessage attribute)":[[17,"O365.teams.ChatMessage.message_type",false]],"messageattachment (class in o365.message)":[[11,"O365.message.MessageAttachment",false]],"messageattachments (class in o365.message)":[[11,"O365.message.MessageAttachments",false]],"messageflag (class in o365.message)":[[11,"O365.message.MessageFlag",false]],"mime_type (o365.drive.file attribute)":[[12,"O365.drive.File.mime_type",false]],"mobile_phone (o365.address_book.contact property)":[[2,"O365.address_book.Contact.mobile_phone",false]],"mobile_phone (o365.directory.user attribute)":[[6,"O365.directory.User.mobile_phone",false]],"modified (o365.address_book.contact property)":[[2,"O365.address_book.Contact.modified",false]],"modified (o365.calendar.event property)":[[3,"O365.calendar.Event.modified",false]],"modified (o365.drive.driveitem attribute)":[[12,"O365.drive.DriveItem.modified",false]],"modified (o365.drive.driveitemversion attribute)":[[12,"O365.drive.DriveItemVersion.modified",false]],"modified (o365.message.message property)":[[11,"O365.message.Message.modified",false]],"modified (o365.sharepoint.sharepointlist attribute)":[[14,"O365.sharepoint.SharepointList.modified",false]],"modified (o365.sharepoint.sharepointlistitem attribute)":[[14,"O365.sharepoint.SharepointListItem.modified",false]],"modified (o365.sharepoint.site attribute)":[[14,"O365.sharepoint.Site.modified",false]],"modified (o365.tasks.task property)":[[16,"O365.tasks.Task.modified",false]],"modified_by (o365.drive.driveitem attribute)":[[12,"O365.drive.DriveItem.modified_by",false]],"modified_by (o365.drive.driveitemversion attribute)":[[12,"O365.drive.DriveItemVersion.modified_by",false]],"modified_by (o365.sharepoint.sharepointlist attribute)":[[14,"O365.sharepoint.SharepointList.modified_by",false]],"modified_by (o365.sharepoint.sharepointlistitem attribute)":[[14,"O365.sharepoint.SharepointListItem.modified_by",false]],"modifierqueryfilter (class in o365.utils.query)":[[20,"O365.utils.query.ModifierQueryFilter",false]],"modify() (o365.utils.token.basetokenbackend method)":[[21,"O365.utils.token.BaseTokenBackend.modify",false]],"module":[[1,"module-O365.account",false],[2,"module-O365.address_book",false],[3,"module-O365.calendar",false],[4,"module-O365.category",false],[5,"module-O365.connection",false],[6,"module-O365.directory",false],[7,"module-O365.excel",false],[9,"module-O365.groups",false],[10,"module-O365.mailbox",false],[11,"module-O365.message",false],[12,"module-O365.drive",false],[13,"module-O365.planner",false],[14,"module-O365.sharepoint",false],[15,"module-O365.subscriptions",false],[16,"module-O365.tasks",false],[17,"module-O365.teams",false],[19,"module-O365.utils.attachment",false],[20,"module-O365.utils.query",false],[21,"module-O365.utils.token",false],[22,"module-O365.utils.utils",false]],"monitor_url (o365.drive.copyoperation attribute)":[[12,"O365.drive.CopyOperation.monitor_url",false]],"month (o365.calendar.eventrecurrence property)":[[3,"O365.calendar.EventRecurrence.month",false]],"move() (o365.drive.driveitem method)":[[12,"O365.drive.DriveItem.move",false]],"move() (o365.message.message method)":[[11,"O365.message.Message.move",false]],"move_folder() (o365.address_book.contactfolder method)":[[2,"O365.address_book.ContactFolder.move_folder",false]],"move_folder() (o365.mailbox.folder method)":[[10,"O365.mailbox.Folder.move_folder",false]],"msal_client (o365.connection.connection property)":[[5,"O365.connection.Connection.msal_client",false]],"msbusinesscentral365protocol (class in o365.connection)":[[5,"O365.connection.MSBusinessCentral365Protocol",false]],"msgraphprotocol (class in o365.connection)":[[5,"O365.connection.MSGraphProtocol",false]],"music (o365.utils.utils.onedrivewellknowfoldernames attribute)":[[22,"O365.utils.utils.OneDriveWellKnowFolderNames.MUSIC",false]],"my_site (o365.directory.user attribute)":[[6,"O365.directory.User.my_site",false]],"naive_request() (o365.connection.connection method)":[[5,"O365.connection.Connection.naive_request",false]],"naive_session (o365.connection.connection attribute)":[[5,"O365.connection.Connection.naive_session",false]],"name (o365.address_book.basecontactfolder attribute)":[[2,"O365.address_book.BaseContactFolder.name",false]],"name (o365.address_book.contact property)":[[2,"O365.address_book.Contact.name",false]],"name (o365.calendar.attendee property)":[[3,"O365.calendar.Attendee.name",false]],"name (o365.calendar.calendar attribute)":[[3,"O365.calendar.Calendar.name",false]],"name (o365.category.category attribute)":[[4,"O365.category.Category.name",false]],"name (o365.drive.driveitem attribute)":[[12,"O365.drive.DriveItem.name",false]],"name (o365.drive.driveitemversion attribute)":[[12,"O365.drive.DriveItemVersion.name",false]],"name (o365.excel.namedrange attribute)":[[7,"O365.excel.NamedRange.name",false]],"name (o365.excel.rangeformatfont property)":[[7,"O365.excel.RangeFormatFont.name",false]],"name (o365.excel.table attribute)":[[7,"O365.excel.Table.name",false]],"name (o365.excel.tablecolumn attribute)":[[7,"O365.excel.TableColumn.name",false]],"name (o365.excel.workbook attribute)":[[7,"O365.excel.WorkBook.name",false]],"name (o365.excel.worksheet attribute)":[[7,"O365.excel.WorkSheet.name",false]],"name (o365.mailbox.folder attribute)":[[10,"O365.mailbox.Folder.name",false]],"name (o365.planner.bucket attribute)":[[13,"O365.planner.Bucket.name",false]],"name (o365.sharepoint.sharepointlist attribute)":[[14,"O365.sharepoint.SharepointList.name",false]],"name (o365.sharepoint.site attribute)":[[14,"O365.sharepoint.Site.name",false]],"name (o365.tasks.folder attribute)":[[16,"O365.tasks.Folder.name",false]],"name (o365.utils.attachment.baseattachment attribute)":[[19,"O365.utils.attachment.BaseAttachment.name",false]],"name (o365.utils.utils.recipient property)":[[22,"O365.utils.utils.Recipient.name",false]],"namedrange (class in o365.excel)":[[7,"O365.excel.NamedRange",false]],"negate() (o365.utils.query.querybuilder static method)":[[20,"O365.utils.query.QueryBuilder.negate",false]],"negate() (o365.utils.utils.query method)":[[22,"O365.utils.utils.Query.negate",false]],"negatefilter (class in o365.utils.query)":[[20,"O365.utils.query.NegateFilter",false]],"new() (o365.utils.utils.query method)":[[22,"O365.utils.utils.Query.new",false]],"new_calendar() (o365.calendar.schedule method)":[[3,"O365.calendar.Schedule.new_calendar",false]],"new_checklist_item() (o365.tasks.task method)":[[16,"O365.tasks.Task.new_checklist_item",false]],"new_contact() (o365.address_book.contactfolder method)":[[2,"O365.address_book.ContactFolder.new_contact",false]],"new_event() (o365.calendar.calendar method)":[[3,"O365.calendar.Calendar.new_event",false]],"new_event() (o365.calendar.schedule method)":[[3,"O365.calendar.Schedule.new_event",false]],"new_folder() (o365.tasks.todo method)":[[16,"O365.tasks.ToDo.new_folder",false]],"new_message() (o365.account.account method)":[[1,"O365.account.Account.new_message",false]],"new_message() (o365.address_book.contact method)":[[2,"O365.address_book.Contact.new_message",false]],"new_message() (o365.address_book.contactfolder method)":[[2,"O365.address_book.ContactFolder.new_message",false]],"new_message() (o365.directory.user method)":[[6,"O365.directory.User.new_message",false]],"new_message() (o365.mailbox.folder method)":[[10,"O365.mailbox.Folder.new_message",false]],"new_query() (o365.utils.utils.apicomponent method)":[[22,"O365.utils.utils.ApiComponent.new_query",false]],"new_task() (o365.tasks.folder method)":[[16,"O365.tasks.Folder.new_task",false]],"new_task() (o365.tasks.todo method)":[[16,"O365.tasks.ToDo.new_task",false]],"next_link (o365.utils.utils.pagination attribute)":[[22,"O365.utils.utils.Pagination.next_link",false]],"no_forwarding (o365.calendar.event property)":[[3,"O365.calendar.Event.no_forwarding",false]],"none (o365.mailbox.externalaudience attribute)":[[10,"O365.mailbox.ExternalAudience.NONE",false]],"normal (o365.calendar.eventsensitivity attribute)":[[3,"O365.calendar.EventSensitivity.Normal",false]],"normal (o365.utils.utils.importancelevel attribute)":[[22,"O365.utils.utils.ImportanceLevel.Normal",false]],"notflagged (o365.message.flag attribute)":[[11,"O365.message.Flag.NotFlagged",false]],"notresponded (o365.calendar.eventresponse attribute)":[[3,"O365.calendar.EventResponse.NotResponded",false]],"number_format (o365.excel.range property)":[[7,"O365.excel.Range.number_format",false]],"o365.account":[[1,"module-O365.account",false]],"o365.address_book":[[2,"module-O365.address_book",false]],"o365.calendar":[[3,"module-O365.calendar",false]],"o365.category":[[4,"module-O365.category",false]],"o365.connection":[[5,"module-O365.connection",false]],"o365.directory":[[6,"module-O365.directory",false]],"o365.drive":[[12,"module-O365.drive",false]],"o365.excel":[[7,"module-O365.excel",false]],"o365.groups":[[9,"module-O365.groups",false]],"o365.mailbox":[[10,"module-O365.mailbox",false]],"o365.message":[[11,"module-O365.message",false]],"o365.planner":[[13,"module-O365.planner",false]],"o365.sharepoint":[[14,"module-O365.sharepoint",false]],"o365.subscriptions":[[15,"module-O365.subscriptions",false]],"o365.tasks":[[16,"module-O365.tasks",false]],"o365.teams":[[17,"module-O365.teams",false]],"o365.utils.attachment":[[19,"module-O365.utils.attachment",false]],"o365.utils.query":[[20,"module-O365.utils.query",false]],"o365.utils.token":[[21,"module-O365.utils.token",false]],"o365.utils.utils":[[22,"module-O365.utils.utils",false]],"oauth_authentication_flow() (in module o365.connection)":[[5,"O365.connection.oauth_authentication_flow",false]],"oauth_redirect_url (o365.connection.connection attribute)":[[5,"O365.connection.Connection.oauth_redirect_url",false]],"oauth_request() (o365.connection.connection method)":[[5,"O365.connection.Connection.oauth_request",false]],"object_id (o365.address_book.contact attribute)":[[2,"O365.address_book.Contact.object_id",false]],"object_id (o365.calendar.event attribute)":[[3,"O365.calendar.Event.object_id",false]],"object_id (o365.category.category attribute)":[[4,"O365.category.Category.object_id",false]],"object_id (o365.directory.user attribute)":[[6,"O365.directory.User.object_id",false]],"object_id (o365.drive.driveitem attribute)":[[12,"O365.drive.DriveItem.object_id",false]],"object_id (o365.drive.driveitempermission attribute)":[[12,"O365.drive.DriveItemPermission.object_id",false]],"object_id (o365.drive.driveitemversion attribute)":[[12,"O365.drive.DriveItemVersion.object_id",false]],"object_id (o365.excel.namedrange attribute)":[[7,"O365.excel.NamedRange.object_id",false]],"object_id (o365.excel.range attribute)":[[7,"O365.excel.Range.object_id",false]],"object_id (o365.excel.table attribute)":[[7,"O365.excel.Table.object_id",false]],"object_id (o365.excel.tablecolumn attribute)":[[7,"O365.excel.TableColumn.object_id",false]],"object_id (o365.excel.tablerow attribute)":[[7,"O365.excel.TableRow.object_id",false]],"object_id (o365.excel.workbook attribute)":[[7,"O365.excel.WorkBook.object_id",false]],"object_id (o365.excel.worksheet attribute)":[[7,"O365.excel.WorkSheet.object_id",false]],"object_id (o365.groups.group attribute)":[[9,"O365.groups.Group.object_id",false]],"object_id (o365.message.message attribute)":[[11,"O365.message.Message.object_id",false]],"object_id (o365.planner.bucket attribute)":[[13,"O365.planner.Bucket.object_id",false]],"object_id (o365.planner.plan attribute)":[[13,"O365.planner.Plan.object_id",false]],"object_id (o365.planner.plandetails attribute)":[[13,"O365.planner.PlanDetails.object_id",false]],"object_id (o365.planner.task attribute)":[[13,"O365.planner.Task.object_id",false]],"object_id (o365.planner.taskdetails attribute)":[[13,"O365.planner.TaskDetails.object_id",false]],"object_id (o365.sharepoint.sharepointlist attribute)":[[14,"O365.sharepoint.SharepointList.object_id",false]],"object_id (o365.sharepoint.sharepointlistcolumn attribute)":[[14,"O365.sharepoint.SharepointListColumn.object_id",false]],"object_id (o365.sharepoint.sharepointlistitem attribute)":[[14,"O365.sharepoint.SharepointListItem.object_id",false]],"object_id (o365.sharepoint.site attribute)":[[14,"O365.sharepoint.Site.object_id",false]],"object_id (o365.teams.app attribute)":[[17,"O365.teams.App.object_id",false]],"object_id (o365.teams.channel attribute)":[[17,"O365.teams.Channel.object_id",false]],"object_id (o365.teams.chat attribute)":[[17,"O365.teams.Chat.object_id",false]],"object_id (o365.teams.chatmessage attribute)":[[17,"O365.teams.ChatMessage.object_id",false]],"object_id (o365.teams.presence attribute)":[[17,"O365.teams.Presence.object_id",false]],"object_id (o365.teams.team attribute)":[[17,"O365.teams.Team.object_id",false]],"occurrence (o365.calendar.eventtype attribute)":[[3,"O365.calendar.EventType.Occurrence",false]],"occurrences (o365.calendar.eventrecurrence property)":[[3,"O365.calendar.EventRecurrence.occurrences",false]],"office_location (o365.address_book.contact property)":[[2,"O365.address_book.Contact.office_location",false]],"office_location (o365.directory.user attribute)":[[6,"O365.directory.User.office_location",false]],"offline (o365.teams.preferredavailability attribute)":[[17,"O365.teams.PreferredAvailability.OFFLINE",false]],"offwork (o365.teams.preferredactivity attribute)":[[17,"O365.teams.PreferredActivity.OFFWORK",false]],"olive (o365.category.categorycolor attribute)":[[4,"O365.category.CategoryColor.OLIVE",false]],"on_attribute() (o365.utils.utils.query method)":[[22,"O365.utils.utils.Query.on_attribute",false]],"on_cloud (o365.utils.attachment.baseattachment attribute)":[[19,"O365.utils.attachment.BaseAttachment.on_cloud",false]],"on_disk (o365.utils.attachment.baseattachment attribute)":[[19,"O365.utils.attachment.BaseAttachment.on_disk",false]],"on_list_field() (o365.utils.utils.query method)":[[22,"O365.utils.utils.Query.on_list_field",false]],"on_premises_sam_account_name (o365.directory.user attribute)":[[6,"O365.directory.User.on_premises_sam_account_name",false]],"onedrivewellknowfoldernames (class in o365.utils.utils)":[[22,"O365.utils.utils.OneDriveWellKnowFolderNames",false]],"online_meeting (o365.calendar.event attribute)":[[3,"O365.calendar.Event.online_meeting",false]],"online_meeting_provider (o365.calendar.event property)":[[3,"O365.calendar.Event.online_meeting_provider",false]],"online_meeting_url (o365.calendar.event attribute)":[[3,"O365.calendar.Event.online_meeting_url",false]],"onlinemeetingprovidertype (class in o365.calendar)":[[3,"O365.calendar.OnlineMeetingProviderType",false]],"oof (o365.calendar.eventshowas attribute)":[[3,"O365.calendar.EventShowAs.Oof",false]],"open_group() (o365.utils.utils.query method)":[[22,"O365.utils.utils.Query.open_group",false]],"operationqueryfilter (class in o365.utils.query)":[[20,"O365.utils.query.OperationQueryFilter",false]],"optional (o365.calendar.attendeetype attribute)":[[3,"O365.calendar.AttendeeType.Optional",false]],"or (o365.utils.utils.chainoperator attribute)":[[22,"O365.utils.utils.ChainOperator.OR",false]],"orange (o365.category.categorycolor attribute)":[[4,"O365.category.CategoryColor.ORANGE",false]],"order_by (o365.utils.query.compositefilter attribute)":[[20,"O365.utils.query.CompositeFilter.order_by",false]],"order_by() (o365.utils.utils.query method)":[[22,"O365.utils.utils.Query.order_by",false]],"order_hint (o365.planner.bucket attribute)":[[13,"O365.planner.Bucket.order_hint",false]],"order_hint (o365.planner.task attribute)":[[13,"O365.planner.Task.order_hint",false]],"orderby() (o365.utils.query.querybuilder static method)":[[20,"O365.utils.query.QueryBuilder.orderby",false]],"orderbyfilter (class in o365.utils.query)":[[20,"O365.utils.query.OrderByFilter",false]],"organizer (o365.calendar.event property)":[[3,"O365.calendar.Event.organizer",false]],"organizer (o365.calendar.eventresponse attribute)":[[3,"O365.calendar.EventResponse.Organizer",false]],"other_address (o365.address_book.contact property)":[[2,"O365.address_book.Contact.other_address",false]],"other_mails (o365.directory.user attribute)":[[6,"O365.directory.User.other_mails",false]],"outbox (o365.utils.utils.outlookwellknowfoldernames attribute)":[[22,"O365.utils.utils.OutlookWellKnowFolderNames.OUTBOX",false]],"outbox_folder() (o365.mailbox.mailbox method)":[[10,"O365.mailbox.MailBox.outbox_folder",false]],"outlook_categories() (o365.account.account method)":[[1,"O365.account.Account.outlook_categories",false]],"outlookwellknowfoldernames (class in o365.utils.utils)":[[22,"O365.utils.utils.OutlookWellKnowFolderNames",false]],"owner (o365.calendar.calendar property)":[[3,"O365.calendar.Calendar.owner",false]],"pagination (class in o365.utils.utils)":[[22,"O365.utils.utils.Pagination",false]],"parent (o365.drive.copyoperation attribute)":[[12,"O365.drive.CopyOperation.parent",false]],"parent (o365.drive.drive attribute)":[[12,"O365.drive.Drive.parent",false]],"parent (o365.excel.rangeformatfont attribute)":[[7,"O365.excel.RangeFormatFont.parent",false]],"parent (o365.excel.table attribute)":[[7,"O365.excel.Table.parent",false]],"parent (o365.excel.workbookapplication attribute)":[[7,"O365.excel.WorkbookApplication.parent",false]],"parent (o365.mailbox.folder attribute)":[[10,"O365.mailbox.Folder.parent",false]],"parent (o365.utils.utils.pagination attribute)":[[22,"O365.utils.utils.Pagination.parent",false]],"parent_id (o365.address_book.basecontactfolder attribute)":[[2,"O365.address_book.BaseContactFolder.parent_id",false]],"parent_id (o365.drive.driveitem attribute)":[[12,"O365.drive.DriveItem.parent_id",false]],"parent_id (o365.mailbox.folder attribute)":[[10,"O365.mailbox.Folder.parent_id",false]],"parent_path (o365.drive.driveitem attribute)":[[12,"O365.drive.DriveItem.parent_path",false]],"password (o365.connection.connection attribute)":[[5,"O365.connection.Connection.password",false]],"password_policies (o365.directory.user attribute)":[[6,"O365.directory.User.password_policies",false]],"password_profile (o365.directory.user attribute)":[[6,"O365.directory.User.password_profile",false]],"past_projects (o365.directory.user attribute)":[[6,"O365.directory.User.past_projects",false]],"patch() (o365.connection.connection method)":[[5,"O365.connection.Connection.patch",false]],"patch() (o365.excel.workbooksession method)":[[7,"O365.excel.WorkbookSession.patch",false]],"percent_complete (o365.planner.task attribute)":[[13,"O365.planner.Task.percent_complete",false]],"permission_type (o365.drive.driveitempermission attribute)":[[12,"O365.drive.DriveItemPermission.permission_type",false]],"persist (o365.excel.workbooksession attribute)":[[7,"O365.excel.WorkbookSession.persist",false]],"personal (o365.calendar.eventsensitivity attribute)":[[3,"O365.calendar.EventSensitivity.Personal",false]],"personal_notes (o365.address_book.contact property)":[[2,"O365.address_book.Contact.personal_notes",false]],"photo (class in o365.drive)":[[12,"O365.drive.Photo",false]],"photos (o365.utils.utils.onedrivewellknowfoldernames attribute)":[[22,"O365.utils.utils.OneDriveWellKnowFolderNames.PHOTOS",false]],"plan (class in o365.planner)":[[13,"O365.planner.Plan",false]],"plan_id (o365.planner.bucket attribute)":[[13,"O365.planner.Bucket.plan_id",false]],"plan_id (o365.planner.task attribute)":[[13,"O365.planner.Task.plan_id",false]],"plandetails (class in o365.planner)":[[13,"O365.planner.PlanDetails",false]],"planner (class in o365.planner)":[[13,"O365.planner.Planner",false]],"planner() (o365.account.account method)":[[1,"O365.account.Account.planner",false]],"position (o365.excel.worksheet attribute)":[[7,"O365.excel.WorkSheet.position",false]],"post() (o365.connection.connection method)":[[5,"O365.connection.Connection.post",false]],"post() (o365.excel.workbooksession method)":[[7,"O365.excel.WorkbookSession.post",false]],"postal_code (o365.directory.user attribute)":[[6,"O365.directory.User.postal_code",false]],"preferred_data_location (o365.directory.user attribute)":[[6,"O365.directory.User.preferred_data_location",false]],"preferred_language (o365.address_book.contact property)":[[2,"O365.address_book.Contact.preferred_language",false]],"preferred_language (o365.directory.user attribute)":[[6,"O365.directory.User.preferred_language",false]],"preferred_name (o365.directory.user attribute)":[[6,"O365.directory.User.preferred_name",false]],"preferredactivity (class in o365.teams)":[[17,"O365.teams.PreferredActivity",false]],"preferredavailability (class in o365.teams)":[[17,"O365.teams.PreferredAvailability",false]],"prefix_scope() (o365.connection.protocol method)":[[5,"O365.connection.Protocol.prefix_scope",false]],"prepare_request() (o365.excel.workbooksession method)":[[7,"O365.excel.WorkbookSession.prepare_request",false]],"presence (class in o365.teams)":[[17,"O365.teams.Presence",false]],"presenting (o365.teams.activity attribute)":[[17,"O365.teams.Activity.PRESENTING",false]],"preview_type (o365.planner.task attribute)":[[13,"O365.planner.Task.preview_type",false]],"preview_type (o365.planner.taskdetails attribute)":[[13,"O365.planner.TaskDetails.preview_type",false]],"priority (o365.planner.task attribute)":[[13,"O365.planner.Task.priority",false]],"private (o365.calendar.eventsensitivity attribute)":[[3,"O365.calendar.EventSensitivity.Private",false]],"protocol (class in o365.connection)":[[5,"O365.connection.Protocol",false]],"protocol (o365.utils.utils.query attribute)":[[22,"O365.utils.utils.Query.protocol",false]],"protocol_scope_prefix (o365.connection.protocol attribute)":[[5,"O365.connection.Protocol.protocol_scope_prefix",false]],"protocol_url (o365.connection.protocol attribute)":[[5,"O365.connection.Protocol.protocol_url",false]],"provisioned_plans (o365.directory.user attribute)":[[6,"O365.directory.User.provisioned_plans",false]],"proxy (o365.connection.connection attribute)":[[5,"O365.connection.Connection.proxy",false]],"proxy_addresses (o365.directory.user attribute)":[[6,"O365.directory.User.proxy_addresses",false]],"purple (o365.category.categorycolor attribute)":[[4,"O365.category.CategoryColor.PURPLE",false]],"put() (o365.connection.connection method)":[[5,"O365.connection.Connection.put",false]],"put() (o365.excel.workbooksession method)":[[7,"O365.excel.WorkbookSession.put",false]],"q() (o365.utils.utils.apicomponent method)":[[22,"O365.utils.utils.ApiComponent.q",false]],"query (class in o365.utils.utils)":[[22,"O365.utils.utils.Query",false]],"querybase (class in o365.utils.query)":[[20,"O365.utils.query.QueryBase",false]],"querybuilder (class in o365.utils.query)":[[20,"O365.utils.query.QueryBuilder",false]],"queryfilter (class in o365.utils.query)":[[20,"O365.utils.query.QueryFilter",false]],"raise_http_errors (o365.connection.connection attribute)":[[5,"O365.connection.Connection.raise_http_errors",false]],"range (class in o365.excel)":[[7,"O365.excel.Range",false]],"range (o365.excel.rangeformat attribute)":[[7,"O365.excel.RangeFormat.range",false]],"rangeformat (class in o365.excel)":[[7,"O365.excel.RangeFormat",false]],"rangeformatfont (class in o365.excel)":[[7,"O365.excel.RangeFormatFont",false]],"read_only (o365.sharepoint.sharepointlistcolumn attribute)":[[14,"O365.sharepoint.SharepointListColumn.read_only",false]],"reapply_filters() (o365.excel.table method)":[[7,"O365.excel.Table.reapply_filters",false]],"received (o365.message.message property)":[[11,"O365.message.Message.received",false]],"recipient (class in o365.utils.utils)":[[22,"O365.utils.utils.Recipient",false]],"recipients (class in o365.utils.utils)":[[22,"O365.utils.utils.Recipients",false]],"recipienttype (class in o365.message)":[[11,"O365.message.RecipientType",false]],"recoverableitemsdeletions (o365.utils.utils.outlookwellknowfoldernames attribute)":[[22,"O365.utils.utils.OutlookWellKnowFolderNames.RECOVERABLEITEMSDELETIONS",false]],"recoverableitemsdeletions_folder() (o365.mailbox.mailbox method)":[[10,"O365.mailbox.MailBox.recoverableitemsdeletions_folder",false]],"recurrence (o365.calendar.event property)":[[3,"O365.calendar.Event.recurrence",false]],"recurrence_time_zone (o365.calendar.eventrecurrence property)":[[3,"O365.calendar.EventRecurrence.recurrence_time_zone",false]],"recurrence_type (o365.calendar.eventrecurrence property)":[[3,"O365.calendar.EventRecurrence.recurrence_type",false]],"red (o365.category.categorycolor attribute)":[[4,"O365.category.CategoryColor.RED",false]],"reference_count (o365.planner.task attribute)":[[13,"O365.planner.Task.reference_count",false]],"references (o365.planner.taskdetails attribute)":[[13,"O365.planner.TaskDetails.references",false]],"refresh() (o365.drive.drive method)":[[12,"O365.drive.Drive.refresh",false]],"refresh_folder() (o365.mailbox.folder method)":[[10,"O365.mailbox.Folder.refresh_folder",false]],"refresh_session() (o365.excel.workbooksession method)":[[7,"O365.excel.WorkbookSession.refresh_session",false]],"refresh_token() (o365.connection.connection method)":[[5,"O365.connection.Connection.refresh_token",false]],"region_name (o365.utils.token.awssecretsbackend attribute)":[[21,"O365.utils.token.AWSSecretsBackend.region_name",false]],"remind_before_minutes (o365.calendar.event property)":[[3,"O365.calendar.Event.remind_before_minutes",false]],"reminder (o365.tasks.task property)":[[16,"O365.tasks.Task.reminder",false]],"remote_item (o365.drive.driveitem attribute)":[[12,"O365.drive.DriveItem.remote_item",false]],"remove() (o365.calendar.attendees method)":[[3,"O365.calendar.Attendees.remove",false]],"remove() (o365.utils.attachment.baseattachments method)":[[19,"O365.utils.attachment.BaseAttachments.remove",false]],"remove() (o365.utils.utils.recipients method)":[[22,"O365.utils.utils.Recipients.remove",false]],"remove() (o365.utils.utils.trackerset method)":[[22,"O365.utils.utils.TrackerSet.remove",false]],"remove_data() (o365.utils.token.basetokenbackend method)":[[21,"O365.utils.token.BaseTokenBackend.remove_data",false]],"remove_filter() (o365.utils.utils.query method)":[[22,"O365.utils.utils.Query.remove_filter",false]],"remove_sheet_name_from_address() (o365.excel.worksheet static method)":[[7,"O365.excel.WorkSheet.remove_sheet_name_from_address",false]],"render() (o365.utils.query.chainfilter method)":[[20,"O365.utils.query.ChainFilter.render",false]],"render() (o365.utils.query.compositefilter method)":[[20,"O365.utils.query.CompositeFilter.render",false]],"render() (o365.utils.query.containerqueryfilter method)":[[20,"O365.utils.query.ContainerQueryFilter.render",false]],"render() (o365.utils.query.expandfilter method)":[[20,"O365.utils.query.ExpandFilter.render",false]],"render() (o365.utils.query.functionfilter method)":[[20,"O365.utils.query.FunctionFilter.render",false]],"render() (o365.utils.query.groupfilter method)":[[20,"O365.utils.query.GroupFilter.render",false]],"render() (o365.utils.query.iterablefilter method)":[[20,"O365.utils.query.IterableFilter.render",false]],"render() (o365.utils.query.logicalfilter method)":[[20,"O365.utils.query.LogicalFilter.render",false]],"render() (o365.utils.query.negatefilter method)":[[20,"O365.utils.query.NegateFilter.render",false]],"render() (o365.utils.query.orderbyfilter method)":[[20,"O365.utils.query.OrderByFilter.render",false]],"render() (o365.utils.query.querybase method)":[[20,"O365.utils.query.QueryBase.render",false]],"render() (o365.utils.query.queryfilter method)":[[20,"O365.utils.query.QueryFilter.render",false]],"render() (o365.utils.query.searchfilter method)":[[20,"O365.utils.query.SearchFilter.render",false]],"renew_subscription() (o365.subscriptions.subscriptions method)":[[15,"O365.subscriptions.Subscriptions.renew_subscription",false]],"reply() (o365.message.message method)":[[11,"O365.message.Message.reply",false]],"reply_to (o365.message.message property)":[[11,"O365.message.Message.reply_to",false]],"reply_to_id (o365.teams.chatmessage attribute)":[[17,"O365.teams.ChatMessage.reply_to_id",false]],"request_retries (o365.connection.connection attribute)":[[5,"O365.connection.Connection.request_retries",false]],"request_token() (o365.account.account method)":[[1,"O365.account.Account.request_token",false]],"request_token() (o365.connection.connection method)":[[5,"O365.connection.Connection.request_token",false]],"requests_delay (o365.connection.connection attribute)":[[5,"O365.connection.Connection.requests_delay",false]],"require_sign_in (o365.drive.driveitempermission attribute)":[[12,"O365.drive.DriveItemPermission.require_sign_in",false]],"required (o365.calendar.attendeetype attribute)":[[3,"O365.calendar.AttendeeType.Required",false]],"required (o365.sharepoint.sharepointlistcolumn attribute)":[[14,"O365.sharepoint.SharepointListColumn.required",false]],"resource (o365.calendar.attendeetype attribute)":[[3,"O365.calendar.AttendeeType.Resource",false]],"response_requested (o365.calendar.event property)":[[3,"O365.calendar.Event.response_requested",false]],"response_status (o365.calendar.attendee property)":[[3,"O365.calendar.Attendee.response_status",false]],"response_status (o365.calendar.event property)":[[3,"O365.calendar.Event.response_status",false]],"response_time (o365.calendar.responsestatus attribute)":[[3,"O365.calendar.ResponseStatus.response_time",false]],"responsestatus (class in o365.calendar)":[[3,"O365.calendar.ResponseStatus",false]],"responsibilities (o365.directory.user attribute)":[[6,"O365.directory.User.responsibilities",false]],"restore() (o365.drive.driveitemversion method)":[[12,"O365.drive.DriveItemVersion.restore",false]],"roles (o365.drive.driveitempermission attribute)":[[12,"O365.drive.DriveItemPermission.roles",false]],"root (o365.address_book.basecontactfolder attribute)":[[2,"O365.address_book.BaseContactFolder.root",false]],"root (o365.mailbox.folder attribute)":[[10,"O365.mailbox.Folder.root",false]],"root (o365.sharepoint.site attribute)":[[14,"O365.sharepoint.Site.root",false]],"row_count (o365.excel.range attribute)":[[7,"O365.excel.Range.row_count",false]],"row_height (o365.excel.rangeformat property)":[[7,"O365.excel.RangeFormat.row_height",false]],"row_hidden (o365.excel.range property)":[[7,"O365.excel.Range.row_hidden",false]],"row_index (o365.excel.range attribute)":[[7,"O365.excel.Range.row_index",false]],"run_calculations() (o365.excel.workbookapplication method)":[[7,"O365.excel.WorkbookApplication.run_calculations",false]],"save() (o365.address_book.contact method)":[[2,"O365.address_book.Contact.save",false]],"save() (o365.calendar.event method)":[[3,"O365.calendar.Event.save",false]],"save() (o365.mailbox.mailboxsettings method)":[[10,"O365.mailbox.MailboxSettings.save",false]],"save() (o365.tasks.checklistitem method)":[[16,"O365.tasks.ChecklistItem.save",false]],"save() (o365.tasks.task method)":[[16,"O365.tasks.Task.save",false]],"save() (o365.utils.attachment.baseattachment method)":[[19,"O365.utils.attachment.BaseAttachment.save",false]],"save_as_eml() (o365.message.message method)":[[11,"O365.message.Message.save_as_eml",false]],"save_as_eml() (o365.message.messageattachments method)":[[11,"O365.message.MessageAttachments.save_as_eml",false]],"save_draft() (o365.message.message method)":[[11,"O365.message.Message.save_draft",false]],"save_message() (o365.message.message method)":[[11,"O365.message.Message.save_message",false]],"save_token() (o365.utils.token.awss3backend method)":[[21,"O365.utils.token.AWSS3Backend.save_token",false]],"save_token() (o365.utils.token.awssecretsbackend method)":[[21,"O365.utils.token.AWSSecretsBackend.save_token",false]],"save_token() (o365.utils.token.basetokenbackend method)":[[21,"O365.utils.token.BaseTokenBackend.save_token",false]],"save_token() (o365.utils.token.bitwardensecretsmanagerbackend method)":[[21,"O365.utils.token.BitwardenSecretsManagerBackend.save_token",false]],"save_token() (o365.utils.token.djangotokenbackend method)":[[21,"O365.utils.token.DjangoTokenBackend.save_token",false]],"save_token() (o365.utils.token.envtokenbackend method)":[[21,"O365.utils.token.EnvTokenBackend.save_token",false]],"save_token() (o365.utils.token.filesystemtokenbackend method)":[[21,"O365.utils.token.FileSystemTokenBackend.save_token",false]],"save_token() (o365.utils.token.firestorebackend method)":[[21,"O365.utils.token.FirestoreBackend.save_token",false]],"save_token() (o365.utils.token.memorytokenbackend method)":[[21,"O365.utils.token.MemoryTokenBackend.save_token",false]],"save_updates() (o365.sharepoint.sharepointlistitem method)":[[14,"O365.sharepoint.SharepointListItem.save_updates",false]],"schedule (class in o365.calendar)":[[3,"O365.calendar.Schedule",false]],"schedule() (o365.account.account method)":[[1,"O365.account.Account.schedule",false]],"scheduled (o365.mailbox.autoreplystatus attribute)":[[10,"O365.mailbox.AutoReplyStatus.SCHEDULED",false]],"scheduled (o365.utils.utils.outlookwellknowfoldernames attribute)":[[22,"O365.utils.utils.OutlookWellKnowFolderNames.SCHEDULED",false]],"scheduled_enddatetime (o365.mailbox.automaticrepliessettings property)":[[10,"O365.mailbox.AutomaticRepliesSettings.scheduled_enddatetime",false]],"scheduled_folder() (o365.mailbox.mailbox method)":[[10,"O365.mailbox.MailBox.scheduled_folder",false]],"scheduled_startdatetime (o365.mailbox.automaticrepliessettings property)":[[10,"O365.mailbox.AutomaticRepliesSettings.scheduled_startdatetime",false]],"schools (o365.directory.user attribute)":[[6,"O365.directory.User.schools",false]],"scope (o365.excel.namedrange attribute)":[[7,"O365.excel.NamedRange.scope",false]],"search (o365.utils.query.compositefilter attribute)":[[20,"O365.utils.query.CompositeFilter.search",false]],"search() (o365.drive.drive method)":[[12,"O365.drive.Drive.search",false]],"search() (o365.drive.folder method)":[[12,"O365.drive.Folder.search",false]],"search() (o365.utils.query.querybuilder method)":[[20,"O365.utils.query.QueryBuilder.search",false]],"search() (o365.utils.utils.query method)":[[22,"O365.utils.utils.Query.search",false]],"search_site() (o365.sharepoint.sharepoint method)":[[14,"O365.sharepoint.Sharepoint.search_site",false]],"searchfilter (class in o365.utils.query)":[[20,"O365.utils.query.SearchFilter",false]],"searchfolders (o365.utils.utils.outlookwellknowfoldernames attribute)":[[22,"O365.utils.utils.OutlookWellKnowFolderNames.SEARCHFOLDERS",false]],"searchfolders_folder() (o365.mailbox.mailbox method)":[[10,"O365.mailbox.MailBox.searchfolders_folder",false]],"secret (o365.utils.token.bitwardensecretsmanagerbackend attribute)":[[21,"O365.utils.token.BitwardenSecretsManagerBackend.secret",false]],"secret_id (o365.utils.token.bitwardensecretsmanagerbackend attribute)":[[21,"O365.utils.token.BitwardenSecretsManagerBackend.secret_id",false]],"secret_name (o365.utils.token.awssecretsbackend attribute)":[[21,"O365.utils.token.AWSSecretsBackend.secret_name",false]],"select (o365.utils.query.compositefilter attribute)":[[20,"O365.utils.query.CompositeFilter.select",false]],"select() (o365.utils.query.querybuilder method)":[[20,"O365.utils.query.QueryBuilder.select",false]],"select() (o365.utils.utils.query method)":[[22,"O365.utils.utils.Query.select",false]],"selectfilter (class in o365.utils.query)":[[20,"O365.utils.query.SelectFilter",false]],"send() (o365.message.message method)":[[11,"O365.message.Message.send",false]],"send_message() (o365.teams.channel method)":[[17,"O365.teams.Channel.send_message",false]],"send_message() (o365.teams.chat method)":[[17,"O365.teams.Chat.send_message",false]],"send_reply() (o365.teams.channelmessage method)":[[17,"O365.teams.ChannelMessage.send_reply",false]],"sender (o365.message.message property)":[[11,"O365.message.Message.sender",false]],"sensitivity (o365.calendar.event property)":[[3,"O365.calendar.Event.sensitivity",false]],"sent (o365.message.message property)":[[11,"O365.message.Message.sent",false]],"sent (o365.utils.utils.outlookwellknowfoldernames attribute)":[[22,"O365.utils.utils.OutlookWellKnowFolderNames.SENT",false]],"sent_folder() (o365.mailbox.mailbox method)":[[10,"O365.mailbox.MailBox.sent_folder",false]],"serialize() (o365.utils.token.basetokenbackend method)":[[21,"O365.utils.token.BaseTokenBackend.serialize",false]],"serializer (o365.utils.token.basetokenbackend attribute)":[[21,"O365.utils.token.BaseTokenBackend.serializer",false]],"series_master_id (o365.calendar.event attribute)":[[3,"O365.calendar.Event.series_master_id",false]],"seriesmaster (o365.calendar.eventtype attribute)":[[3,"O365.calendar.EventType.SeriesMaster",false]],"serverfailures (o365.utils.utils.outlookwellknowfoldernames attribute)":[[22,"O365.utils.utils.OutlookWellKnowFolderNames.SERVERFAILURES",false]],"serverfailures_folder() (o365.mailbox.mailbox method)":[[10,"O365.mailbox.MailBox.serverfailures_folder",false]],"service_url (o365.connection.protocol attribute)":[[5,"O365.connection.Protocol.service_url",false]],"session (o365.connection.connection attribute)":[[5,"O365.connection.Connection.session",false]],"session (o365.excel.rangeformat attribute)":[[7,"O365.excel.RangeFormat.session",false]],"session (o365.excel.table attribute)":[[7,"O365.excel.Table.session",false]],"session (o365.excel.tablecolumn attribute)":[[7,"O365.excel.TableColumn.session",false]],"session (o365.excel.tablerow attribute)":[[7,"O365.excel.TableRow.session",false]],"session (o365.excel.workbook attribute)":[[7,"O365.excel.WorkBook.session",false]],"session (o365.excel.worksheet attribute)":[[7,"O365.excel.WorkSheet.session",false]],"session_id (o365.excel.workbooksession attribute)":[[7,"O365.excel.WorkbookSession.session_id",false]],"set_automatic_reply() (o365.mailbox.mailbox method)":[[10,"O365.mailbox.MailBox.set_automatic_reply",false]],"set_base_url() (o365.utils.utils.apicomponent method)":[[22,"O365.utils.utils.ApiComponent.set_base_url",false]],"set_borders() (o365.excel.rangeformat method)":[[7,"O365.excel.RangeFormat.set_borders",false]],"set_completed() (o365.message.messageflag method)":[[11,"O365.message.MessageFlag.set_completed",false]],"set_daily() (o365.calendar.eventrecurrence method)":[[3,"O365.calendar.EventRecurrence.set_daily",false]],"set_disable_reply() (o365.mailbox.mailbox method)":[[10,"O365.mailbox.MailBox.set_disable_reply",false]],"set_flagged() (o365.message.messageflag method)":[[11,"O365.message.MessageFlag.set_flagged",false]],"set_monthly() (o365.calendar.eventrecurrence method)":[[3,"O365.calendar.EventRecurrence.set_monthly",false]],"set_my_presence() (o365.teams.teams method)":[[17,"O365.teams.Teams.set_my_presence",false]],"set_my_user_preferred_presence() (o365.teams.teams method)":[[17,"O365.teams.Teams.set_my_user_preferred_presence",false]],"set_proxy() (o365.connection.connection method)":[[5,"O365.connection.Connection.set_proxy",false]],"set_range() (o365.calendar.eventrecurrence method)":[[3,"O365.calendar.EventRecurrence.set_range",false]],"set_weekly() (o365.calendar.eventrecurrence method)":[[3,"O365.calendar.EventRecurrence.set_weekly",false]],"set_yearly() (o365.calendar.eventrecurrence method)":[[3,"O365.calendar.EventRecurrence.set_yearly",false]],"share_email (o365.drive.driveitempermission attribute)":[[12,"O365.drive.DriveItemPermission.share_email",false]],"share_id (o365.drive.driveitempermission attribute)":[[12,"O365.drive.DriveItemPermission.share_id",false]],"share_link (o365.drive.driveitempermission attribute)":[[12,"O365.drive.DriveItemPermission.share_link",false]],"share_scope (o365.drive.driveitempermission attribute)":[[12,"O365.drive.DriveItemPermission.share_scope",false]],"share_type (o365.drive.driveitempermission attribute)":[[12,"O365.drive.DriveItemPermission.share_type",false]],"share_with_invite() (o365.drive.driveitem method)":[[12,"O365.drive.DriveItem.share_with_invite",false]],"share_with_link() (o365.drive.driveitem method)":[[12,"O365.drive.DriveItem.share_with_link",false]],"shared (o365.drive.driveitem attribute)":[[12,"O365.drive.DriveItem.shared",false]],"shared_with (o365.planner.plandetails attribute)":[[13,"O365.planner.PlanDetails.shared_with",false]],"sharepoint (class in o365.sharepoint)":[[14,"O365.sharepoint.Sharepoint",false]],"sharepoint() (o365.account.account method)":[[1,"O365.account.Account.sharepoint",false]],"sharepointlist (class in o365.sharepoint)":[[14,"O365.sharepoint.SharepointList",false]],"sharepointlistcolumn (class in o365.sharepoint)":[[14,"O365.sharepoint.SharepointListColumn",false]],"sharepointlistitem (class in o365.sharepoint)":[[14,"O365.sharepoint.SharepointListItem",false]],"should_refresh_token() (o365.utils.token.basetokenbackend method)":[[21,"O365.utils.token.BaseTokenBackend.should_refresh_token",false]],"show_as (o365.calendar.event property)":[[3,"O365.calendar.Event.show_as",false]],"show_banded_columns (o365.excel.table attribute)":[[7,"O365.excel.Table.show_banded_columns",false]],"show_banded_rows (o365.excel.table attribute)":[[7,"O365.excel.Table.show_banded_rows",false]],"show_filter_button (o365.excel.table attribute)":[[7,"O365.excel.Table.show_filter_button",false]],"show_headers (o365.excel.table attribute)":[[7,"O365.excel.Table.show_headers",false]],"show_in_address_list (o365.directory.user attribute)":[[6,"O365.directory.User.show_in_address_list",false]],"show_totals (o365.excel.table attribute)":[[7,"O365.excel.Table.show_totals",false]],"sign_in_sessions_valid_from (o365.directory.user attribute)":[[6,"O365.directory.User.sign_in_sessions_valid_from",false]],"single_value_extended_properties (o365.message.message property)":[[11,"O365.message.Message.single_value_extended_properties",false]],"singleinstance (o365.calendar.eventtype attribute)":[[3,"O365.calendar.EventType.SingleInstance",false]],"site (class in o365.sharepoint)":[[14,"O365.sharepoint.Site",false]],"site_storage (o365.sharepoint.site attribute)":[[14,"O365.sharepoint.Site.site_storage",false]],"size (o365.drive.driveitem attribute)":[[12,"O365.drive.DriveItem.size",false]],"size (o365.drive.driveitemversion attribute)":[[12,"O365.drive.DriveItemVersion.size",false]],"size (o365.excel.rangeformatfont property)":[[7,"O365.excel.RangeFormatFont.size",false]],"skills (o365.directory.user attribute)":[[6,"O365.directory.User.skills",false]],"skypeforbusiness (o365.calendar.onlinemeetingprovidertype attribute)":[[3,"O365.calendar.OnlineMeetingProviderType.SkypeForBusiness",false]],"skypeforconsumer (o365.calendar.onlinemeetingprovidertype attribute)":[[3,"O365.calendar.OnlineMeetingProviderType.SkypeForConsumer",false]],"special_folder (o365.drive.folder attribute)":[[12,"O365.drive.Folder.special_folder",false]],"start (o365.calendar.event property)":[[3,"O365.calendar.Event.start",false]],"start_date (o365.calendar.eventrecurrence property)":[[3,"O365.calendar.EventRecurrence.start_date",false]],"start_date (o365.message.messageflag property)":[[11,"O365.message.MessageFlag.start_date",false]],"start_date_time (o365.planner.task attribute)":[[13,"O365.planner.Task.start_date_time",false]],"startswith() (o365.utils.query.querybuilder method)":[[20,"O365.utils.query.QueryBuilder.startswith",false]],"startswith() (o365.utils.utils.query method)":[[22,"O365.utils.utils.Query.startswith",false]],"state (o365.directory.user attribute)":[[6,"O365.directory.User.state",false]],"state (o365.utils.utils.pagination attribute)":[[22,"O365.utils.utils.Pagination.state",false]],"status (o365.calendar.responsestatus attribute)":[[3,"O365.calendar.ResponseStatus.status",false]],"status (o365.drive.copyoperation attribute)":[[12,"O365.drive.CopyOperation.status",false]],"status (o365.mailbox.automaticrepliessettings property)":[[10,"O365.mailbox.AutomaticRepliesSettings.status",false]],"status (o365.message.messageflag property)":[[11,"O365.message.MessageFlag.status",false]],"status (o365.tasks.task property)":[[16,"O365.tasks.Task.status",false]],"steel (o365.category.categorycolor attribute)":[[4,"O365.category.CategoryColor.STEEL",false]],"storage (class in o365.drive)":[[12,"O365.drive.Storage",false]],"storage() (o365.account.account method)":[[1,"O365.account.Account.storage",false]],"store_token_after_refresh (o365.connection.connection attribute)":[[5,"O365.connection.Connection.store_token_after_refresh",false]],"street_address (o365.directory.user attribute)":[[6,"O365.directory.User.street_address",false]],"style (o365.excel.table attribute)":[[7,"O365.excel.Table.style",false]],"subject (o365.calendar.event property)":[[3,"O365.calendar.Event.subject",false]],"subject (o365.message.message property)":[[11,"O365.message.Message.subject",false]],"subject (o365.tasks.task property)":[[16,"O365.tasks.Task.subject",false]],"subject (o365.teams.chatmessage attribute)":[[17,"O365.teams.ChatMessage.subject",false]],"subscriptions (class in o365.subscriptions)":[[15,"O365.subscriptions.Subscriptions",false]],"subscriptions() (o365.account.account method)":[[1,"O365.account.Account.subscriptions",false]],"summary (o365.teams.chatmessage attribute)":[[17,"O365.teams.ChatMessage.summary",false]],"surname (o365.address_book.contact property)":[[2,"O365.address_book.Contact.surname",false]],"surname (o365.directory.user attribute)":[[6,"O365.directory.User.surname",false]],"syncissues (o365.utils.utils.outlookwellknowfoldernames attribute)":[[22,"O365.utils.utils.OutlookWellKnowFolderNames.SYNCISSUES",false]],"syncissues_folder() (o365.mailbox.mailbox method)":[[10,"O365.mailbox.MailBox.syncissues_folder",false]],"table (class in o365.excel)":[[7,"O365.excel.Table",false]],"table (o365.excel.tablecolumn attribute)":[[7,"O365.excel.TableColumn.table",false]],"table (o365.excel.tablerow attribute)":[[7,"O365.excel.TableRow.table",false]],"tablecolumn (class in o365.excel)":[[7,"O365.excel.TableColumn",false]],"tablerow (class in o365.excel)":[[7,"O365.excel.TableRow",false]],"taken_datetime (o365.drive.photo attribute)":[[12,"O365.drive.Photo.taken_datetime",false]],"target (o365.drive.copyoperation attribute)":[[12,"O365.drive.CopyOperation.target",false]],"task (class in o365.planner)":[[13,"O365.planner.Task",false]],"task (class in o365.tasks)":[[16,"O365.tasks.Task",false]],"task_id (o365.tasks.checklistitem attribute)":[[16,"O365.tasks.ChecklistItem.task_id",false]],"task_id (o365.tasks.task attribute)":[[16,"O365.tasks.Task.task_id",false]],"taskdetails (class in o365.planner)":[[13,"O365.planner.TaskDetails",false]],"tasks() (o365.account.account method)":[[1,"O365.account.Account.tasks",false]],"teal (o365.category.categorycolor attribute)":[[4,"O365.category.CategoryColor.TEAL",false]],"team (class in o365.teams)":[[17,"O365.teams.Team",false]],"team_id (o365.teams.channelmessage attribute)":[[17,"O365.teams.ChannelMessage.team_id",false]],"teams (class in o365.teams)":[[17,"O365.teams.Teams",false]],"teams() (o365.account.account method)":[[1,"O365.account.Account.teams",false]],"teamsforbusiness (o365.calendar.onlinemeetingprovidertype attribute)":[[3,"O365.calendar.OnlineMeetingProviderType.TeamsForBusiness",false]],"template (o365.sharepoint.sharepointlist attribute)":[[14,"O365.sharepoint.SharepointList.template",false]],"tenant_id (o365.connection.connection attribute)":[[5,"O365.connection.Connection.tenant_id",false]],"tentative (o365.calendar.eventshowas attribute)":[[3,"O365.calendar.EventShowAs.Tentative",false]],"tentativelyaccepted (o365.calendar.eventresponse attribute)":[[3,"O365.calendar.EventResponse.TentativelyAccepted",false]],"text (o365.excel.range attribute)":[[7,"O365.excel.Range.text",false]],"thumbnails (o365.drive.driveitem attribute)":[[12,"O365.drive.DriveItem.thumbnails",false]],"timeout (o365.connection.connection attribute)":[[5,"O365.connection.Connection.timeout",false]],"timezone (o365.connection.msbusinesscentral365protocol property)":[[5,"O365.connection.MSBusinessCentral365Protocol.timezone",false]],"timezone (o365.connection.msgraphprotocol property)":[[5,"O365.connection.MSGraphProtocol.timezone",false]],"timezone (o365.connection.protocol property)":[[5,"O365.connection.Protocol.timezone",false]],"timezone (o365.mailbox.mailboxsettings attribute)":[[10,"O365.mailbox.MailboxSettings.timezone",false]],"title (o365.address_book.contact property)":[[2,"O365.address_book.Contact.title",false]],"title (o365.planner.plan attribute)":[[13,"O365.planner.Plan.title",false]],"title (o365.planner.task attribute)":[[13,"O365.planner.Task.title",false]],"to (o365.message.message property)":[[11,"O365.message.Message.to",false]],"to (o365.message.recipienttype attribute)":[[11,"O365.message.RecipientType.TO",false]],"to_api_case() (o365.connection.protocol static method)":[[5,"O365.connection.Protocol.to_api_case",false]],"to_api_data() (o365.address_book.contact method)":[[2,"O365.address_book.Contact.to_api_data",false]],"to_api_data() (o365.calendar.attendees method)":[[3,"O365.calendar.Attendees.to_api_data",false]],"to_api_data() (o365.calendar.event method)":[[3,"O365.calendar.Event.to_api_data",false]],"to_api_data() (o365.calendar.eventrecurrence method)":[[3,"O365.calendar.EventRecurrence.to_api_data",false]],"to_api_data() (o365.excel.range method)":[[7,"O365.excel.Range.to_api_data",false]],"to_api_data() (o365.excel.rangeformat method)":[[7,"O365.excel.RangeFormat.to_api_data",false]],"to_api_data() (o365.excel.rangeformatfont method)":[[7,"O365.excel.RangeFormatFont.to_api_data",false]],"to_api_data() (o365.message.message method)":[[11,"O365.message.Message.to_api_data",false]],"to_api_data() (o365.message.messageflag method)":[[11,"O365.message.MessageFlag.to_api_data",false]],"to_api_data() (o365.tasks.checklistitem method)":[[16,"O365.tasks.ChecklistItem.to_api_data",false]],"to_api_data() (o365.tasks.task method)":[[16,"O365.tasks.Task.to_api_data",false]],"to_api_data() (o365.utils.attachment.attachablemixin method)":[[19,"O365.utils.attachment.AttachableMixin.to_api_data",false]],"to_api_data() (o365.utils.attachment.baseattachment method)":[[19,"O365.utils.attachment.BaseAttachment.to_api_data",false]],"to_api_data() (o365.utils.attachment.baseattachments method)":[[19,"O365.utils.attachment.BaseAttachments.to_api_data",false]],"to_api_data() (o365.utils.attachment.uploadsessionrequest method)":[[19,"O365.utils.attachment.UploadSessionRequest.to_api_data",false]],"todo (class in o365.tasks)":[[16,"O365.tasks.ToDo",false]],"token_backend (o365.connection.connection attribute)":[[5,"O365.connection.Connection.token_backend",false]],"token_env_name (o365.utils.token.envtokenbackend attribute)":[[21,"O365.utils.token.EnvTokenBackend.token_env_name",false]],"token_expiration_datetime() (o365.utils.token.basetokenbackend method)":[[21,"O365.utils.token.BaseTokenBackend.token_expiration_datetime",false]],"token_is_expired() (o365.utils.token.basetokenbackend method)":[[21,"O365.utils.token.BaseTokenBackend.token_is_expired",false]],"token_is_long_lived() (o365.utils.token.basetokenbackend method)":[[21,"O365.utils.token.BaseTokenBackend.token_is_long_lived",false]],"token_model (o365.utils.token.djangotokenbackend attribute)":[[21,"O365.utils.token.DjangoTokenBackend.token_model",false]],"token_path (o365.utils.token.filesystemtokenbackend attribute)":[[21,"O365.utils.token.FileSystemTokenBackend.token_path",false]],"tokenexpirederror":[[5,"O365.connection.TokenExpiredError",false]],"topic (o365.teams.chat attribute)":[[17,"O365.teams.Chat.topic",false]],"total_count (o365.utils.utils.pagination attribute)":[[22,"O365.utils.utils.Pagination.total_count",false]],"total_items_count (o365.mailbox.folder attribute)":[[10,"O365.mailbox.Folder.total_items_count",false]],"trackerset (class in o365.utils.utils)":[[22,"O365.utils.utils.TrackerSet",false]],"transaction_id (o365.calendar.event property)":[[3,"O365.calendar.Event.transaction_id",false]],"type (o365.directory.user attribute)":[[6,"O365.directory.User.type",false]],"type (o365.groups.group attribute)":[[9,"O365.groups.Group.type",false]],"underline (o365.excel.rangeformatfont property)":[[7,"O365.excel.RangeFormatFont.underline",false]],"unequal() (o365.utils.query.querybuilder method)":[[20,"O365.utils.query.QueryBuilder.unequal",false]],"unequal() (o365.utils.utils.query method)":[[22,"O365.utils.utils.Query.unequal",false]],"unique_body (o365.message.message property)":[[11,"O365.message.Message.unique_body",false]],"unknown (o365.calendar.eventshowas attribute)":[[3,"O365.calendar.EventShowAs.Unknown",false]],"unknown (o365.calendar.onlinemeetingprovidertype attribute)":[[3,"O365.calendar.OnlineMeetingProviderType.Unknown",false]],"unmerge() (o365.excel.range method)":[[7,"O365.excel.Range.unmerge",false]],"unread_items_count (o365.mailbox.folder attribute)":[[10,"O365.mailbox.Folder.unread_items_count",false]],"update() (o365.calendar.calendar method)":[[3,"O365.calendar.Calendar.update",false]],"update() (o365.drive.driveitem method)":[[12,"O365.drive.DriveItem.update",false]],"update() (o365.excel.namedrange method)":[[7,"O365.excel.NamedRange.update",false]],"update() (o365.excel.range method)":[[7,"O365.excel.Range.update",false]],"update() (o365.excel.rangeformat method)":[[7,"O365.excel.RangeFormat.update",false]],"update() (o365.excel.table method)":[[7,"O365.excel.Table.update",false]],"update() (o365.excel.tablecolumn method)":[[7,"O365.excel.TableColumn.update",false]],"update() (o365.excel.tablerow method)":[[7,"O365.excel.TableRow.update",false]],"update() (o365.excel.worksheet method)":[[7,"O365.excel.WorkSheet.update",false]],"update() (o365.planner.bucket method)":[[13,"O365.planner.Bucket.update",false]],"update() (o365.planner.plan method)":[[13,"O365.planner.Plan.update",false]],"update() (o365.planner.plandetails method)":[[13,"O365.planner.PlanDetails.update",false]],"update() (o365.planner.task method)":[[13,"O365.planner.Task.update",false]],"update() (o365.planner.taskdetails method)":[[13,"O365.planner.TaskDetails.update",false]],"update() (o365.tasks.folder method)":[[16,"O365.tasks.Folder.update",false]],"update_cells() (o365.excel.worksheet method)":[[7,"O365.excel.WorkSheet.update_cells",false]],"update_color() (o365.category.category method)":[[4,"O365.category.Category.update_color",false]],"update_fields() (o365.sharepoint.sharepointlistitem method)":[[14,"O365.sharepoint.SharepointListItem.update_fields",false]],"update_folder_name() (o365.address_book.contactfolder method)":[[2,"O365.address_book.ContactFolder.update_folder_name",false]],"update_folder_name() (o365.mailbox.folder method)":[[10,"O365.mailbox.Folder.update_folder_name",false]],"update_profile_photo() (o365.address_book.contact method)":[[2,"O365.address_book.Contact.update_profile_photo",false]],"update_profile_photo() (o365.directory.user method)":[[6,"O365.directory.User.update_profile_photo",false]],"update_roles() (o365.drive.driveitempermission method)":[[12,"O365.drive.DriveItemPermission.update_roles",false]],"update_session_auth_header() (o365.connection.connection method)":[[5,"O365.connection.Connection.update_session_auth_header",false]],"update_subscription() (o365.subscriptions.subscriptions method)":[[15,"O365.subscriptions.Subscriptions.update_subscription",false]],"updated_at (o365.mailbox.folder attribute)":[[10,"O365.mailbox.Folder.updated_at",false]],"upload_file() (o365.drive.folder method)":[[12,"O365.drive.Folder.upload_file",false]],"uploadsessionrequest (class in o365.utils.attachment)":[[19,"O365.utils.attachment.UploadSessionRequest",false]],"usage_location (o365.directory.user attribute)":[[6,"O365.directory.User.usage_location",false]],"use_default_casing (o365.connection.protocol attribute)":[[5,"O365.connection.Protocol.use_default_casing",false]],"user (class in o365.directory)":[[6,"O365.directory.User",false]],"user_principal_name (o365.directory.user attribute)":[[6,"O365.directory.User.user_principal_name",false]],"user_type (o365.directory.user attribute)":[[6,"O365.directory.User.user_type",false]],"username (o365.account.account property)":[[1,"O365.account.Account.username",false]],"username (o365.connection.connection property)":[[5,"O365.connection.Connection.username",false]],"value (o365.excel.namedrange attribute)":[[7,"O365.excel.NamedRange.value",false]],"value_types (o365.excel.range attribute)":[[7,"O365.excel.Range.value_types",false]],"values (o365.excel.range property)":[[7,"O365.excel.Range.values",false]],"values (o365.excel.tablecolumn attribute)":[[7,"O365.excel.TableColumn.values",false]],"values (o365.excel.tablerow attribute)":[[7,"O365.excel.TableRow.values",false]],"verify_ssl (o365.connection.connection attribute)":[[5,"O365.connection.Connection.verify_ssl",false]],"vertical_alignment (o365.excel.rangeformat property)":[[7,"O365.excel.RangeFormat.vertical_alignment",false]],"visibility (o365.excel.worksheet attribute)":[[7,"O365.excel.WorkSheet.visibility",false]],"visibility (o365.groups.group attribute)":[[9,"O365.groups.Group.visibility",false]],"visible (o365.excel.namedrange attribute)":[[7,"O365.excel.NamedRange.visible",false]],"web_link (o365.calendar.event attribute)":[[3,"O365.calendar.Event.web_link",false]],"web_link (o365.message.message attribute)":[[11,"O365.message.Message.web_link",false]],"web_url (o365.drive.driveitem attribute)":[[12,"O365.drive.DriveItem.web_url",false]],"web_url (o365.sharepoint.sharepointlist attribute)":[[14,"O365.sharepoint.SharepointList.web_url",false]],"web_url (o365.sharepoint.sharepointlistitem attribute)":[[14,"O365.sharepoint.SharepointListItem.web_url",false]],"web_url (o365.sharepoint.site attribute)":[[14,"O365.sharepoint.Site.web_url",false]],"web_url (o365.teams.chat attribute)":[[17,"O365.teams.Chat.web_url",false]],"web_url (o365.teams.chatmessage attribute)":[[17,"O365.teams.ChatMessage.web_url",false]],"web_url (o365.teams.team attribute)":[[17,"O365.teams.Team.web_url",false]],"width (o365.drive.image attribute)":[[12,"O365.drive.Image.width",false]],"workbook (class in o365.excel)":[[7,"O365.excel.WorkBook",false]],"workbook (o365.excel.worksheet attribute)":[[7,"O365.excel.WorkSheet.workbook",false]],"workbookapplication (class in o365.excel)":[[7,"O365.excel.WorkbookApplication",false]],"workbooksession (class in o365.excel)":[[7,"O365.excel.WorkbookSession",false]],"workingelsewhere (o365.calendar.eventshowas attribute)":[[3,"O365.calendar.EventShowAs.WorkingElsewhere",false]],"workinghours (o365.mailbox.mailboxsettings attribute)":[[10,"O365.mailbox.MailboxSettings.workinghours",false]],"worksheet (class in o365.excel)":[[7,"O365.excel.WorkSheet",false]],"wrap_text (o365.excel.rangeformat property)":[[7,"O365.excel.RangeFormat.wrap_text",false]],"yellow (o365.category.categorycolor attribute)":[[4,"O365.category.CategoryColor.YELLOW",false]]},"objects":{"O365":[[1,0,0,"-","account"],[2,0,0,"-","address_book"],[3,0,0,"-","calendar"],[4,0,0,"-","category"],[5,0,0,"-","connection"],[6,0,0,"-","directory"],[12,0,0,"-","drive"],[7,0,0,"-","excel"],[9,0,0,"-","groups"],[10,0,0,"-","mailbox"],[11,0,0,"-","message"],[13,0,0,"-","planner"],[14,0,0,"-","sharepoint"],[15,0,0,"-","subscriptions"],[16,0,0,"-","tasks"],[17,0,0,"-","teams"]],"O365.account":[[1,1,1,"","Account"]],"O365.account.Account":[[1,2,1,"","__init__"],[1,2,1,"","address_book"],[1,2,1,"","authenticate"],[1,3,1,"","connection"],[1,2,1,"","directory"],[1,2,1,"","get_authenticated_usernames"],[1,2,1,"","get_authorization_url"],[1,2,1,"","get_current_user_data"],[1,2,1,"","groups"],[1,3,1,"","is_authenticated"],[1,2,1,"","mailbox"],[1,4,1,"","main_resource"],[1,2,1,"","new_message"],[1,2,1,"","outlook_categories"],[1,2,1,"","planner"],[1,2,1,"","request_token"],[1,2,1,"","schedule"],[1,2,1,"","sharepoint"],[1,2,1,"","storage"],[1,2,1,"","subscriptions"],[1,2,1,"","tasks"],[1,2,1,"","teams"],[1,3,1,"","username"]],"O365.address_book":[[2,1,1,"","AddressBook"],[2,1,1,"","BaseContactFolder"],[2,1,1,"","Contact"],[2,1,1,"","ContactFolder"]],"O365.address_book.AddressBook":[[2,2,1,"","__init__"]],"O365.address_book.BaseContactFolder":[[2,2,1,"","__init__"],[2,4,1,"","folder_id"],[2,2,1,"","get_contact_by_email"],[2,2,1,"","get_contacts"],[2,4,1,"","name"],[2,4,1,"","parent_id"],[2,4,1,"","root"]],"O365.address_book.Contact":[[2,2,1,"","__init__"],[2,3,1,"","business_address"],[2,3,1,"","business_phones"],[2,3,1,"","categories"],[2,3,1,"","company_name"],[2,3,1,"","created"],[2,2,1,"","delete"],[2,3,1,"","department"],[2,3,1,"","display_name"],[2,3,1,"","emails"],[2,3,1,"","fileAs"],[2,3,1,"","folder_id"],[2,3,1,"","full_name"],[2,2,1,"","get_profile_photo"],[2,3,1,"","home_address"],[2,3,1,"","home_phones"],[2,3,1,"","job_title"],[2,3,1,"","main_email"],[2,3,1,"","mobile_phone"],[2,3,1,"","modified"],[2,3,1,"","name"],[2,2,1,"","new_message"],[2,4,1,"","object_id"],[2,3,1,"","office_location"],[2,3,1,"","other_address"],[2,3,1,"","personal_notes"],[2,3,1,"","preferred_language"],[2,2,1,"","save"],[2,3,1,"","surname"],[2,3,1,"","title"],[2,2,1,"","to_api_data"],[2,2,1,"","update_profile_photo"]],"O365.address_book.ContactFolder":[[2,2,1,"","create_child_folder"],[2,2,1,"","delete"],[2,2,1,"","get_folder"],[2,2,1,"","get_folders"],[2,2,1,"","move_folder"],[2,2,1,"","new_contact"],[2,2,1,"","new_message"],[2,2,1,"","update_folder_name"]],"O365.calendar":[[3,1,1,"","Attendee"],[3,1,1,"","AttendeeType"],[3,1,1,"","Attendees"],[3,1,1,"","Calendar"],[3,1,1,"","CalendarColor"],[3,1,1,"","DailyEventFrequency"],[3,1,1,"","Event"],[3,1,1,"","EventAttachment"],[3,1,1,"","EventAttachments"],[3,1,1,"","EventRecurrence"],[3,1,1,"","EventResponse"],[3,1,1,"","EventSensitivity"],[3,1,1,"","EventShowAs"],[3,1,1,"","EventType"],[3,1,1,"","OnlineMeetingProviderType"],[3,1,1,"","ResponseStatus"],[3,1,1,"","Schedule"]],"O365.calendar.Attendee":[[3,2,1,"","__init__"],[3,3,1,"","address"],[3,3,1,"","attendee_type"],[3,3,1,"","name"],[3,3,1,"","response_status"]],"O365.calendar.AttendeeType":[[3,4,1,"","Optional"],[3,4,1,"","Required"],[3,4,1,"","Resource"]],"O365.calendar.Attendees":[[3,2,1,"","__init__"],[3,2,1,"","add"],[3,2,1,"","clear"],[3,2,1,"","remove"],[3,2,1,"","to_api_data"]],"O365.calendar.Calendar":[[3,2,1,"","__init__"],[3,4,1,"","calendar_id"],[3,4,1,"","can_edit"],[3,4,1,"","can_share"],[3,4,1,"","can_view_private_items"],[3,4,1,"","color"],[3,2,1,"","delete"],[3,2,1,"","get_event"],[3,2,1,"","get_events"],[3,4,1,"","hex_color"],[3,4,1,"","name"],[3,2,1,"","new_event"],[3,3,1,"","owner"],[3,2,1,"","update"]],"O365.calendar.CalendarColor":[[3,4,1,"","Auto"],[3,4,1,"","LightBlue"],[3,4,1,"","LightBrown"],[3,4,1,"","LightGray"],[3,4,1,"","LightGreen"],[3,4,1,"","LightOrange"],[3,4,1,"","LightPink"],[3,4,1,"","LightRed"],[3,4,1,"","LightTeal"],[3,4,1,"","LightYellow"],[3,4,1,"","MaxColor"]],"O365.calendar.DailyEventFrequency":[[3,2,1,"","__init__"]],"O365.calendar.Event":[[3,2,1,"","__init__"],[3,2,1,"","accept_event"],[3,3,1,"","attachments"],[3,3,1,"","attendees"],[3,3,1,"","body"],[3,4,1,"","body_type"],[3,4,1,"","calendar_id"],[3,2,1,"","cancel_event"],[3,3,1,"","categories"],[3,3,1,"","created"],[3,2,1,"","decline_event"],[3,2,1,"","delete"],[3,3,1,"","end"],[3,3,1,"","event_type"],[3,2,1,"","get_body_soup"],[3,2,1,"","get_body_text"],[3,2,1,"","get_occurrences"],[3,4,1,"","has_attachments"],[3,4,1,"","ical_uid"],[3,3,1,"","importance"],[3,3,1,"","is_all_day"],[3,4,1,"","is_cancelled"],[3,3,1,"","is_online_meeting"],[3,4,1,"","is_organizer"],[3,3,1,"","is_reminder_on"],[3,3,1,"","location"],[3,4,1,"","locations"],[3,3,1,"","modified"],[3,3,1,"","no_forwarding"],[3,4,1,"","object_id"],[3,4,1,"","online_meeting"],[3,3,1,"","online_meeting_provider"],[3,4,1,"","online_meeting_url"],[3,3,1,"","organizer"],[3,3,1,"","recurrence"],[3,3,1,"","remind_before_minutes"],[3,3,1,"","response_requested"],[3,3,1,"","response_status"],[3,2,1,"","save"],[3,3,1,"","sensitivity"],[3,4,1,"","series_master_id"],[3,3,1,"","show_as"],[3,3,1,"","start"],[3,3,1,"","subject"],[3,2,1,"","to_api_data"],[3,3,1,"","transaction_id"],[3,4,1,"","web_link"]],"O365.calendar.EventRecurrence":[[3,2,1,"","__init__"],[3,3,1,"","day_of_month"],[3,3,1,"","days_of_week"],[3,3,1,"","end_date"],[3,3,1,"","first_day_of_week"],[3,3,1,"","index"],[3,3,1,"","interval"],[3,3,1,"","month"],[3,3,1,"","occurrences"],[3,3,1,"","recurrence_time_zone"],[3,3,1,"","recurrence_type"],[3,2,1,"","set_daily"],[3,2,1,"","set_monthly"],[3,2,1,"","set_range"],[3,2,1,"","set_weekly"],[3,2,1,"","set_yearly"],[3,3,1,"","start_date"],[3,2,1,"","to_api_data"]],"O365.calendar.EventResponse":[[3,4,1,"","Accepted"],[3,4,1,"","Declined"],[3,4,1,"","NotResponded"],[3,4,1,"","Organizer"],[3,4,1,"","TentativelyAccepted"]],"O365.calendar.EventSensitivity":[[3,4,1,"","Confidential"],[3,4,1,"","Normal"],[3,4,1,"","Personal"],[3,4,1,"","Private"]],"O365.calendar.EventShowAs":[[3,4,1,"","Busy"],[3,4,1,"","Free"],[3,4,1,"","Oof"],[3,4,1,"","Tentative"],[3,4,1,"","Unknown"],[3,4,1,"","WorkingElsewhere"]],"O365.calendar.EventType":[[3,4,1,"","Exception"],[3,4,1,"","Occurrence"],[3,4,1,"","SeriesMaster"],[3,4,1,"","SingleInstance"]],"O365.calendar.OnlineMeetingProviderType":[[3,4,1,"","SkypeForBusiness"],[3,4,1,"","SkypeForConsumer"],[3,4,1,"","TeamsForBusiness"],[3,4,1,"","Unknown"]],"O365.calendar.ResponseStatus":[[3,2,1,"","__init__"],[3,4,1,"","response_time"],[3,4,1,"","status"]],"O365.calendar.Schedule":[[3,2,1,"","__init__"],[3,2,1,"","get_availability"],[3,2,1,"","get_calendar"],[3,2,1,"","get_default_calendar"],[3,2,1,"","get_events"],[3,2,1,"","list_calendars"],[3,2,1,"","new_calendar"],[3,2,1,"","new_event"]],"O365.category":[[4,1,1,"","Categories"],[4,1,1,"","Category"],[4,1,1,"","CategoryColor"]],"O365.category.Categories":[[4,2,1,"","__init__"],[4,2,1,"","create_category"],[4,2,1,"","get_categories"],[4,2,1,"","get_category"]],"O365.category.Category":[[4,2,1,"","__init__"],[4,4,1,"","color"],[4,2,1,"","delete"],[4,4,1,"","name"],[4,4,1,"","object_id"],[4,2,1,"","update_color"]],"O365.category.CategoryColor":[[4,4,1,"","BLACK"],[4,4,1,"","BLUE"],[4,4,1,"","BROWN"],[4,4,1,"","CRANBERRY"],[4,4,1,"","DARKBLUE"],[4,4,1,"","DARKBROWN"],[4,4,1,"","DARKCRANBERRY"],[4,4,1,"","DARKGREEN"],[4,4,1,"","DARKGREY"],[4,4,1,"","DARKOLIVE"],[4,4,1,"","DARKORANGE"],[4,4,1,"","DARKPURPLE"],[4,4,1,"","DARKRED"],[4,4,1,"","DARKSTEEL"],[4,4,1,"","DARKTEAL"],[4,4,1,"","DARKYELLOW"],[4,4,1,"","GRAY"],[4,4,1,"","GREEN"],[4,4,1,"","OLIVE"],[4,4,1,"","ORANGE"],[4,4,1,"","PURPLE"],[4,4,1,"","RED"],[4,4,1,"","STEEL"],[4,4,1,"","TEAL"],[4,4,1,"","YELLOW"],[4,2,1,"","get"]],"O365.connection":[[5,1,1,"","Connection"],[5,1,1,"","MSBusinessCentral365Protocol"],[5,1,1,"","MSGraphProtocol"],[5,1,1,"","Protocol"],[5,5,1,"","TokenExpiredError"],[5,6,1,"","oauth_authentication_flow"]],"O365.connection.Connection":[[5,2,1,"","__init__"],[5,4,1,"","auth"],[5,3,1,"","auth_flow_type"],[5,4,1,"","default_headers"],[5,2,1,"","delete"],[5,2,1,"","get"],[5,2,1,"","get_authorization_url"],[5,2,1,"","get_naive_session"],[5,2,1,"","get_session"],[5,4,1,"","json_encoder"],[5,2,1,"","load_token_from_backend"],[5,3,1,"","msal_client"],[5,2,1,"","naive_request"],[5,4,1,"","naive_session"],[5,4,1,"","oauth_redirect_url"],[5,2,1,"","oauth_request"],[5,4,1,"","password"],[5,2,1,"","patch"],[5,2,1,"","post"],[5,4,1,"","proxy"],[5,2,1,"","put"],[5,4,1,"","raise_http_errors"],[5,2,1,"","refresh_token"],[5,4,1,"","request_retries"],[5,2,1,"","request_token"],[5,4,1,"","requests_delay"],[5,4,1,"","session"],[5,2,1,"","set_proxy"],[5,4,1,"","store_token_after_refresh"],[5,4,1,"","tenant_id"],[5,4,1,"","timeout"],[5,4,1,"","token_backend"],[5,2,1,"","update_session_auth_header"],[5,3,1,"","username"],[5,4,1,"","verify_ssl"]],"O365.connection.MSBusinessCentral365Protocol":[[5,2,1,"","__init__"],[5,4,1,"","max_top_value"],[5,3,1,"","timezone"]],"O365.connection.MSGraphProtocol":[[5,2,1,"","__init__"],[5,4,1,"","max_top_value"],[5,3,1,"","timezone"]],"O365.connection.Protocol":[[5,2,1,"","__init__"],[5,4,1,"","api_version"],[5,4,1,"","casing_function"],[5,2,1,"","convert_case"],[5,4,1,"","default_resource"],[5,2,1,"","get_scopes_for"],[5,2,1,"","get_service_keyword"],[5,4,1,"","keyword_data_store"],[5,4,1,"","max_top_value"],[5,2,1,"","prefix_scope"],[5,4,1,"","protocol_scope_prefix"],[5,4,1,"","protocol_url"],[5,4,1,"","service_url"],[5,3,1,"","timezone"],[5,2,1,"","to_api_case"],[5,4,1,"","use_default_casing"]],"O365.directory":[[6,1,1,"","Directory"],[6,1,1,"","User"]],"O365.directory.Directory":[[6,2,1,"","__init__"],[6,2,1,"","get_current_user"],[6,2,1,"","get_user"],[6,2,1,"","get_user_direct_reports"],[6,2,1,"","get_user_manager"],[6,2,1,"","get_users"],[6,2,1,"","invite_user"]],"O365.directory.User":[[6,2,1,"","__init__"],[6,4,1,"","about_me"],[6,4,1,"","account_enabled"],[6,4,1,"","age_group"],[6,4,1,"","assigned_licenses"],[6,4,1,"","assigned_plans"],[6,4,1,"","birthday"],[6,4,1,"","business_phones"],[6,4,1,"","city"],[6,4,1,"","company_name"],[6,4,1,"","consent_provided_for_minor"],[6,4,1,"","country"],[6,4,1,"","created"],[6,4,1,"","department"],[6,4,1,"","display_name"],[6,4,1,"","employee_id"],[6,4,1,"","fax_number"],[6,3,1,"","full_name"],[6,2,1,"","get_profile_photo"],[6,4,1,"","given_name"],[6,4,1,"","hire_date"],[6,4,1,"","im_addresses"],[6,4,1,"","interests"],[6,4,1,"","is_resource_account"],[6,4,1,"","job_title"],[6,4,1,"","last_password_change"],[6,4,1,"","legal_age_group_classification"],[6,4,1,"","license_assignment_states"],[6,4,1,"","mail"],[6,4,1,"","mail_nickname"],[6,4,1,"","mailbox_settings"],[6,4,1,"","mobile_phone"],[6,4,1,"","my_site"],[6,2,1,"","new_message"],[6,4,1,"","object_id"],[6,4,1,"","office_location"],[6,4,1,"","on_premises_sam_account_name"],[6,4,1,"","other_mails"],[6,4,1,"","password_policies"],[6,4,1,"","password_profile"],[6,4,1,"","past_projects"],[6,4,1,"","postal_code"],[6,4,1,"","preferred_data_location"],[6,4,1,"","preferred_language"],[6,4,1,"","preferred_name"],[6,4,1,"","provisioned_plans"],[6,4,1,"","proxy_addresses"],[6,4,1,"","responsibilities"],[6,4,1,"","schools"],[6,4,1,"","show_in_address_list"],[6,4,1,"","sign_in_sessions_valid_from"],[6,4,1,"","skills"],[6,4,1,"","state"],[6,4,1,"","street_address"],[6,4,1,"","surname"],[6,4,1,"","type"],[6,2,1,"","update_profile_photo"],[6,4,1,"","usage_location"],[6,4,1,"","user_principal_name"],[6,4,1,"","user_type"]],"O365.drive":[[12,1,1,"","CopyOperation"],[12,1,1,"","DownloadableMixin"],[12,1,1,"","Drive"],[12,1,1,"","DriveItem"],[12,1,1,"","DriveItemPermission"],[12,1,1,"","DriveItemVersion"],[12,1,1,"","File"],[12,1,1,"","Folder"],[12,1,1,"","Image"],[12,1,1,"","Photo"],[12,1,1,"","Storage"]],"O365.drive.CopyOperation":[[12,2,1,"","__init__"],[12,2,1,"","check_status"],[12,4,1,"","completion_percentage"],[12,2,1,"","get_item"],[12,4,1,"","item_id"],[12,4,1,"","monitor_url"],[12,4,1,"","parent"],[12,4,1,"","status"],[12,4,1,"","target"]],"O365.drive.DownloadableMixin":[[12,2,1,"","download"]],"O365.drive.Drive":[[12,2,1,"","__init__"],[12,2,1,"","get_child_folders"],[12,2,1,"","get_item"],[12,2,1,"","get_item_by_path"],[12,2,1,"","get_items"],[12,2,1,"","get_recent"],[12,2,1,"","get_root_folder"],[12,2,1,"","get_shared_with_me"],[12,2,1,"","get_special_folder"],[12,4,1,"","parent"],[12,2,1,"","refresh"],[12,2,1,"","search"]],"O365.drive.DriveItem":[[12,2,1,"","__init__"],[12,2,1,"","copy"],[12,4,1,"","created"],[12,4,1,"","created_by"],[12,2,1,"","delete"],[12,4,1,"","description"],[12,4,1,"","drive"],[12,4,1,"","drive_id"],[12,2,1,"","get_drive"],[12,2,1,"","get_parent"],[12,2,1,"","get_permissions"],[12,2,1,"","get_thumbnails"],[12,2,1,"","get_version"],[12,2,1,"","get_versions"],[12,3,1,"","is_file"],[12,3,1,"","is_folder"],[12,3,1,"","is_image"],[12,3,1,"","is_photo"],[12,4,1,"","modified"],[12,4,1,"","modified_by"],[12,2,1,"","move"],[12,4,1,"","name"],[12,4,1,"","object_id"],[12,4,1,"","parent_id"],[12,4,1,"","parent_path"],[12,4,1,"","remote_item"],[12,2,1,"","share_with_invite"],[12,2,1,"","share_with_link"],[12,4,1,"","shared"],[12,4,1,"","size"],[12,4,1,"","thumbnails"],[12,2,1,"","update"],[12,4,1,"","web_url"]],"O365.drive.DriveItemPermission":[[12,2,1,"","__init__"],[12,2,1,"","delete"],[12,4,1,"","driveitem_id"],[12,4,1,"","granted_to"],[12,4,1,"","inherited_from"],[12,4,1,"","invited_by"],[12,4,1,"","object_id"],[12,4,1,"","permission_type"],[12,4,1,"","require_sign_in"],[12,4,1,"","roles"],[12,4,1,"","share_email"],[12,4,1,"","share_id"],[12,4,1,"","share_link"],[12,4,1,"","share_scope"],[12,4,1,"","share_type"],[12,2,1,"","update_roles"]],"O365.drive.DriveItemVersion":[[12,2,1,"","__init__"],[12,2,1,"","download"],[12,4,1,"","driveitem_id"],[12,4,1,"","modified"],[12,4,1,"","modified_by"],[12,4,1,"","name"],[12,4,1,"","object_id"],[12,2,1,"","restore"],[12,4,1,"","size"]],"O365.drive.File":[[12,2,1,"","__init__"],[12,3,1,"","extension"],[12,4,1,"","hashes"],[12,4,1,"","mime_type"]],"O365.drive.Folder":[[12,2,1,"","__init__"],[12,4,1,"","child_count"],[12,2,1,"","create_child_folder"],[12,2,1,"","download_contents"],[12,2,1,"","get_child_folders"],[12,2,1,"","get_items"],[12,2,1,"","search"],[12,4,1,"","special_folder"],[12,2,1,"","upload_file"]],"O365.drive.Image":[[12,2,1,"","__init__"],[12,3,1,"","dimensions"],[12,4,1,"","height"],[12,4,1,"","width"]],"O365.drive.Photo":[[12,2,1,"","__init__"],[12,4,1,"","camera_make"],[12,4,1,"","camera_model"],[12,4,1,"","exposure_denominator"],[12,4,1,"","exposure_numerator"],[12,4,1,"","fnumber"],[12,4,1,"","focal_length"],[12,4,1,"","iso"],[12,4,1,"","taken_datetime"]],"O365.drive.Storage":[[12,2,1,"","__init__"],[12,2,1,"","get_default_drive"],[12,2,1,"","get_drive"],[12,2,1,"","get_drives"]],"O365.excel":[[7,5,1,"","FunctionException"],[7,1,1,"","NamedRange"],[7,1,1,"","Range"],[7,1,1,"","RangeFormat"],[7,1,1,"","RangeFormatFont"],[7,1,1,"","Table"],[7,1,1,"","TableColumn"],[7,1,1,"","TableRow"],[7,1,1,"","WorkBook"],[7,1,1,"","WorkSheet"],[7,1,1,"","WorkbookApplication"],[7,1,1,"","WorkbookSession"]],"O365.excel.NamedRange":[[7,2,1,"","__init__"],[7,4,1,"","comment"],[7,4,1,"","data_type"],[7,2,1,"","get_range"],[7,4,1,"","name"],[7,4,1,"","object_id"],[7,4,1,"","scope"],[7,2,1,"","update"],[7,4,1,"","value"],[7,4,1,"","visible"]],"O365.excel.Range":[[7,2,1,"","__init__"],[7,4,1,"","address"],[7,4,1,"","address_local"],[7,4,1,"","cell_count"],[7,2,1,"","clear"],[7,4,1,"","column_count"],[7,3,1,"","column_hidden"],[7,4,1,"","column_index"],[7,2,1,"","delete"],[7,3,1,"","formulas"],[7,3,1,"","formulas_local"],[7,3,1,"","formulas_r1_c1"],[7,2,1,"","get_bounding_rect"],[7,2,1,"","get_cell"],[7,2,1,"","get_column"],[7,2,1,"","get_columns_after"],[7,2,1,"","get_columns_before"],[7,2,1,"","get_entire_column"],[7,2,1,"","get_format"],[7,2,1,"","get_intersection"],[7,2,1,"","get_last_cell"],[7,2,1,"","get_last_column"],[7,2,1,"","get_last_row"],[7,2,1,"","get_offset_range"],[7,2,1,"","get_resized_range"],[7,2,1,"","get_row"],[7,2,1,"","get_rows_above"],[7,2,1,"","get_rows_below"],[7,2,1,"","get_used_range"],[7,2,1,"","get_worksheet"],[7,4,1,"","hidden"],[7,2,1,"","insert_range"],[7,2,1,"","merge"],[7,3,1,"","number_format"],[7,4,1,"","object_id"],[7,4,1,"","row_count"],[7,3,1,"","row_hidden"],[7,4,1,"","row_index"],[7,4,1,"","text"],[7,2,1,"","to_api_data"],[7,2,1,"","unmerge"],[7,2,1,"","update"],[7,4,1,"","value_types"],[7,3,1,"","values"]],"O365.excel.RangeFormat":[[7,2,1,"","__init__"],[7,2,1,"","auto_fit_columns"],[7,2,1,"","auto_fit_rows"],[7,3,1,"","background_color"],[7,3,1,"","column_width"],[7,3,1,"","font"],[7,3,1,"","horizontal_alignment"],[7,4,1,"","range"],[7,3,1,"","row_height"],[7,4,1,"","session"],[7,2,1,"","set_borders"],[7,2,1,"","to_api_data"],[7,2,1,"","update"],[7,3,1,"","vertical_alignment"],[7,3,1,"","wrap_text"]],"O365.excel.RangeFormatFont":[[7,2,1,"","__init__"],[7,3,1,"","bold"],[7,3,1,"","color"],[7,3,1,"","italic"],[7,3,1,"","name"],[7,4,1,"","parent"],[7,3,1,"","size"],[7,2,1,"","to_api_data"],[7,3,1,"","underline"]],"O365.excel.Table":[[7,2,1,"","__init__"],[7,2,1,"","add_column"],[7,2,1,"","add_rows"],[7,2,1,"","clear_filters"],[7,2,1,"","convert_to_range"],[7,2,1,"","delete"],[7,2,1,"","delete_column"],[7,2,1,"","delete_row"],[7,2,1,"","get_column"],[7,2,1,"","get_column_at_index"],[7,2,1,"","get_columns"],[7,2,1,"","get_data_body_range"],[7,2,1,"","get_header_row_range"],[7,2,1,"","get_range"],[7,2,1,"","get_row"],[7,2,1,"","get_row_at_index"],[7,2,1,"","get_rows"],[7,2,1,"","get_total_row_range"],[7,2,1,"","get_worksheet"],[7,4,1,"","highlight_first_column"],[7,4,1,"","highlight_last_column"],[7,4,1,"","legacy_id"],[7,4,1,"","name"],[7,4,1,"","object_id"],[7,4,1,"","parent"],[7,2,1,"","reapply_filters"],[7,4,1,"","session"],[7,4,1,"","show_banded_columns"],[7,4,1,"","show_banded_rows"],[7,4,1,"","show_filter_button"],[7,4,1,"","show_headers"],[7,4,1,"","show_totals"],[7,4,1,"","style"],[7,2,1,"","update"]],"O365.excel.TableColumn":[[7,2,1,"","__init__"],[7,2,1,"","apply_filter"],[7,2,1,"","clear_filter"],[7,2,1,"","delete"],[7,2,1,"","get_data_body_range"],[7,2,1,"","get_filter"],[7,2,1,"","get_header_row_range"],[7,2,1,"","get_range"],[7,2,1,"","get_total_row_range"],[7,4,1,"","index"],[7,4,1,"","name"],[7,4,1,"","object_id"],[7,4,1,"","session"],[7,4,1,"","table"],[7,2,1,"","update"],[7,4,1,"","values"]],"O365.excel.TableRow":[[7,2,1,"","__init__"],[7,2,1,"","delete"],[7,2,1,"","get_range"],[7,4,1,"","index"],[7,4,1,"","object_id"],[7,4,1,"","session"],[7,4,1,"","table"],[7,2,1,"","update"],[7,4,1,"","values"]],"O365.excel.WorkBook":[[7,2,1,"","__init__"],[7,2,1,"","add_named_range"],[7,2,1,"","add_worksheet"],[7,2,1,"","delete_worksheet"],[7,2,1,"","get_named_range"],[7,2,1,"","get_named_ranges"],[7,2,1,"","get_table"],[7,2,1,"","get_tables"],[7,2,1,"","get_workbookapplication"],[7,2,1,"","get_worksheet"],[7,2,1,"","get_worksheets"],[7,2,1,"","invoke_function"],[7,4,1,"","name"],[7,4,1,"","object_id"],[7,4,1,"","session"]],"O365.excel.WorkSheet":[[7,2,1,"","__init__"],[7,2,1,"","add_named_range"],[7,2,1,"","add_table"],[7,2,1,"","append_rows"],[7,2,1,"","delete"],[7,2,1,"","get_cell"],[7,2,1,"","get_named_range"],[7,2,1,"","get_range"],[7,2,1,"","get_table"],[7,2,1,"","get_tables"],[7,2,1,"","get_used_range"],[7,4,1,"","name"],[7,4,1,"","object_id"],[7,4,1,"","position"],[7,2,1,"","remove_sheet_name_from_address"],[7,4,1,"","session"],[7,2,1,"","update"],[7,2,1,"","update_cells"],[7,4,1,"","visibility"],[7,4,1,"","workbook"]],"O365.excel.WorkbookApplication":[[7,2,1,"","__init__"],[7,2,1,"","get_details"],[7,4,1,"","parent"],[7,2,1,"","run_calculations"]],"O365.excel.WorkbookSession":[[7,2,1,"","__init__"],[7,2,1,"","close_session"],[7,2,1,"","create_session"],[7,2,1,"","delete"],[7,2,1,"","get"],[7,4,1,"","inactivity_limit"],[7,4,1,"","last_activity"],[7,2,1,"","patch"],[7,4,1,"","persist"],[7,2,1,"","post"],[7,2,1,"","prepare_request"],[7,2,1,"","put"],[7,2,1,"","refresh_session"],[7,4,1,"","session_id"]],"O365.groups":[[9,1,1,"","Group"],[9,1,1,"","Groups"]],"O365.groups.Group":[[9,2,1,"","__init__"],[9,4,1,"","description"],[9,4,1,"","display_name"],[9,2,1,"","get_group_members"],[9,2,1,"","get_group_owners"],[9,4,1,"","mail"],[9,4,1,"","mail_nickname"],[9,4,1,"","object_id"],[9,4,1,"","type"],[9,4,1,"","visibility"]],"O365.groups.Groups":[[9,2,1,"","__init__"],[9,2,1,"","get_group_by_id"],[9,2,1,"","get_group_by_mail"],[9,2,1,"","get_user_groups"],[9,2,1,"","list_groups"]],"O365.mailbox":[[10,1,1,"","AutoReplyStatus"],[10,1,1,"","AutomaticRepliesSettings"],[10,1,1,"","ExternalAudience"],[10,1,1,"","Folder"],[10,1,1,"","MailBox"],[10,1,1,"","MailboxSettings"]],"O365.mailbox.AutoReplyStatus":[[10,4,1,"","ALWAYSENABLED"],[10,4,1,"","DISABLED"],[10,4,1,"","SCHEDULED"]],"O365.mailbox.AutomaticRepliesSettings":[[10,2,1,"","__init__"],[10,3,1,"","external_audience"],[10,4,1,"","external_reply_message"],[10,4,1,"","internal_reply_message"],[10,3,1,"","scheduled_enddatetime"],[10,3,1,"","scheduled_startdatetime"],[10,3,1,"","status"]],"O365.mailbox.ExternalAudience":[[10,4,1,"","ALL"],[10,4,1,"","CONTACTSONLY"],[10,4,1,"","NONE"]],"O365.mailbox.Folder":[[10,2,1,"","__init__"],[10,4,1,"","child_folders_count"],[10,2,1,"","copy_folder"],[10,2,1,"","create_child_folder"],[10,2,1,"","delete"],[10,2,1,"","delete_message"],[10,4,1,"","folder_id"],[10,2,1,"","get_folder"],[10,2,1,"","get_folders"],[10,2,1,"","get_message"],[10,2,1,"","get_messages"],[10,2,1,"","get_parent_folder"],[10,2,1,"","move_folder"],[10,4,1,"","name"],[10,2,1,"","new_message"],[10,4,1,"","parent"],[10,4,1,"","parent_id"],[10,2,1,"","refresh_folder"],[10,4,1,"","root"],[10,4,1,"","total_items_count"],[10,4,1,"","unread_items_count"],[10,2,1,"","update_folder_name"],[10,4,1,"","updated_at"]],"O365.mailbox.MailBox":[[10,2,1,"","__init__"],[10,2,1,"","archive_folder"],[10,2,1,"","clutter_folder"],[10,2,1,"","conflicts_folder"],[10,2,1,"","conversationhistory_folder"],[10,2,1,"","deleted_folder"],[10,2,1,"","drafts_folder"],[10,2,1,"","get_settings"],[10,2,1,"","inbox_folder"],[10,2,1,"","junk_folder"],[10,2,1,"","localfailures_folder"],[10,2,1,"","outbox_folder"],[10,2,1,"","recoverableitemsdeletions_folder"],[10,2,1,"","scheduled_folder"],[10,2,1,"","searchfolders_folder"],[10,2,1,"","sent_folder"],[10,2,1,"","serverfailures_folder"],[10,2,1,"","set_automatic_reply"],[10,2,1,"","set_disable_reply"],[10,2,1,"","syncissues_folder"]],"O365.mailbox.MailboxSettings":[[10,2,1,"","__init__"],[10,4,1,"","automaticrepliessettings"],[10,2,1,"","save"],[10,4,1,"","timezone"],[10,4,1,"","workinghours"]],"O365.message":[[11,1,1,"","Flag"],[11,1,1,"","MeetingMessageType"],[11,1,1,"","Message"],[11,1,1,"","MessageAttachment"],[11,1,1,"","MessageAttachments"],[11,1,1,"","MessageFlag"],[11,1,1,"","RecipientType"]],"O365.message.Flag":[[11,4,1,"","Complete"],[11,4,1,"","Flagged"],[11,4,1,"","NotFlagged"]],"O365.message.MeetingMessageType":[[11,4,1,"","MeetingAccepted"],[11,4,1,"","MeetingCancelled"],[11,4,1,"","MeetingDeclined"],[11,4,1,"","MeetingRequest"],[11,4,1,"","MeetingTentativelyAccepted"]],"O365.message.Message":[[11,2,1,"","__init__"],[11,2,1,"","add_category"],[11,2,1,"","add_message_header"],[11,3,1,"","attachments"],[11,3,1,"","bcc"],[11,3,1,"","body"],[11,3,1,"","body_preview"],[11,4,1,"","body_type"],[11,3,1,"","categories"],[11,3,1,"","cc"],[11,4,1,"","conversation_id"],[11,4,1,"","conversation_index"],[11,2,1,"","copy"],[11,3,1,"","created"],[11,2,1,"","delay_delivery"],[11,2,1,"","delete"],[11,3,1,"","flag"],[11,4,1,"","folder_id"],[11,2,1,"","forward"],[11,2,1,"","get_body_soup"],[11,2,1,"","get_body_text"],[11,2,1,"","get_event"],[11,2,1,"","get_mime_content"],[11,3,1,"","has_attachments"],[11,3,1,"","importance"],[11,3,1,"","inference_classification"],[11,4,1,"","internet_message_id"],[11,3,1,"","is_delivery_receipt_requested"],[11,3,1,"","is_draft"],[11,3,1,"","is_event_message"],[11,3,1,"","is_read"],[11,3,1,"","is_read_receipt_requested"],[11,2,1,"","mark_as_read"],[11,2,1,"","mark_as_unread"],[11,3,1,"","meeting_message_type"],[11,3,1,"","message_headers"],[11,3,1,"","modified"],[11,2,1,"","move"],[11,4,1,"","object_id"],[11,3,1,"","received"],[11,2,1,"","reply"],[11,3,1,"","reply_to"],[11,2,1,"","save_as_eml"],[11,2,1,"","save_draft"],[11,2,1,"","save_message"],[11,2,1,"","send"],[11,3,1,"","sender"],[11,3,1,"","sent"],[11,3,1,"","single_value_extended_properties"],[11,3,1,"","subject"],[11,3,1,"","to"],[11,2,1,"","to_api_data"],[11,3,1,"","unique_body"],[11,4,1,"","web_link"]],"O365.message.MessageAttachments":[[11,2,1,"","get_eml_as_object"],[11,2,1,"","get_mime_content"],[11,2,1,"","save_as_eml"]],"O365.message.MessageFlag":[[11,2,1,"","__init__"],[11,3,1,"","completition_date"],[11,2,1,"","delete_flag"],[11,3,1,"","due_date"],[11,3,1,"","is_completed"],[11,3,1,"","is_flagged"],[11,2,1,"","set_completed"],[11,2,1,"","set_flagged"],[11,3,1,"","start_date"],[11,3,1,"","status"],[11,2,1,"","to_api_data"]],"O365.message.RecipientType":[[11,4,1,"","BCC"],[11,4,1,"","CC"],[11,4,1,"","TO"]],"O365.planner":[[13,1,1,"","Bucket"],[13,1,1,"","Plan"],[13,1,1,"","PlanDetails"],[13,1,1,"","Planner"],[13,1,1,"","Task"],[13,1,1,"","TaskDetails"]],"O365.planner.Bucket":[[13,2,1,"","__init__"],[13,2,1,"","create_task"],[13,2,1,"","delete"],[13,2,1,"","list_tasks"],[13,4,1,"","name"],[13,4,1,"","object_id"],[13,4,1,"","order_hint"],[13,4,1,"","plan_id"],[13,2,1,"","update"]],"O365.planner.Plan":[[13,2,1,"","__init__"],[13,2,1,"","create_bucket"],[13,4,1,"","created_date_time"],[13,2,1,"","delete"],[13,2,1,"","get_details"],[13,4,1,"","group_id"],[13,2,1,"","list_buckets"],[13,2,1,"","list_tasks"],[13,4,1,"","object_id"],[13,4,1,"","title"],[13,2,1,"","update"]],"O365.planner.PlanDetails":[[13,2,1,"","__init__"],[13,4,1,"","category_descriptions"],[13,4,1,"","object_id"],[13,4,1,"","shared_with"],[13,2,1,"","update"]],"O365.planner.Planner":[[13,2,1,"","__init__"],[13,2,1,"","create_plan"],[13,2,1,"","get_bucket_by_id"],[13,2,1,"","get_my_tasks"],[13,2,1,"","get_plan_by_id"],[13,2,1,"","get_task_by_id"],[13,2,1,"","list_group_plans"],[13,2,1,"","list_user_tasks"]],"O365.planner.Task":[[13,2,1,"","__init__"],[13,4,1,"","active_checklist_item_count"],[13,4,1,"","applied_categories"],[13,4,1,"","assignee_priority"],[13,4,1,"","assignments"],[13,4,1,"","bucket_id"],[13,4,1,"","checklist_item_count"],[13,4,1,"","completed_date"],[13,4,1,"","conversation_thread_id"],[13,4,1,"","created_date"],[13,2,1,"","delete"],[13,4,1,"","due_date_time"],[13,2,1,"","get_details"],[13,4,1,"","has_description"],[13,4,1,"","object_id"],[13,4,1,"","order_hint"],[13,4,1,"","percent_complete"],[13,4,1,"","plan_id"],[13,4,1,"","preview_type"],[13,4,1,"","priority"],[13,4,1,"","reference_count"],[13,4,1,"","start_date_time"],[13,4,1,"","title"],[13,2,1,"","update"]],"O365.planner.TaskDetails":[[13,2,1,"","__init__"],[13,4,1,"","checklist"],[13,4,1,"","description"],[13,4,1,"","object_id"],[13,4,1,"","preview_type"],[13,4,1,"","references"],[13,2,1,"","update"]],"O365.sharepoint":[[14,1,1,"","Sharepoint"],[14,1,1,"","SharepointList"],[14,1,1,"","SharepointListColumn"],[14,1,1,"","SharepointListItem"],[14,1,1,"","Site"]],"O365.sharepoint.Sharepoint":[[14,2,1,"","__init__"],[14,2,1,"","get_root_site"],[14,2,1,"","get_site"],[14,2,1,"","search_site"]],"O365.sharepoint.SharepointList":[[14,2,1,"","__init__"],[14,2,1,"","build_field_filter"],[14,4,1,"","column_name_cw"],[14,4,1,"","content_types_enabled"],[14,2,1,"","create_list_item"],[14,4,1,"","created"],[14,4,1,"","created_by"],[14,2,1,"","delete_list_item"],[14,4,1,"","description"],[14,4,1,"","display_name"],[14,2,1,"","get_item_by_id"],[14,2,1,"","get_items"],[14,2,1,"","get_list_columns"],[14,4,1,"","hidden"],[14,4,1,"","modified"],[14,4,1,"","modified_by"],[14,4,1,"","name"],[14,4,1,"","object_id"],[14,4,1,"","template"],[14,4,1,"","web_url"]],"O365.sharepoint.SharepointListColumn":[[14,2,1,"","__init__"],[14,4,1,"","column_group"],[14,4,1,"","description"],[14,4,1,"","display_name"],[14,4,1,"","enforce_unique_values"],[14,4,1,"","field_type"],[14,4,1,"","hidden"],[14,4,1,"","indexed"],[14,4,1,"","internal_name"],[14,4,1,"","object_id"],[14,4,1,"","read_only"],[14,4,1,"","required"]],"O365.sharepoint.SharepointListItem":[[14,2,1,"","__init__"],[14,4,1,"","content_type_id"],[14,4,1,"","created"],[14,4,1,"","created_by"],[14,2,1,"","delete"],[14,4,1,"","fields"],[14,4,1,"","modified"],[14,4,1,"","modified_by"],[14,4,1,"","object_id"],[14,2,1,"","save_updates"],[14,2,1,"","update_fields"],[14,4,1,"","web_url"]],"O365.sharepoint.Site":[[14,2,1,"","__init__"],[14,2,1,"","create_list"],[14,4,1,"","created"],[14,4,1,"","description"],[14,4,1,"","display_name"],[14,2,1,"","get_default_document_library"],[14,2,1,"","get_document_library"],[14,2,1,"","get_list_by_name"],[14,2,1,"","get_lists"],[14,2,1,"","get_subsites"],[14,2,1,"","list_document_libraries"],[14,4,1,"","modified"],[14,4,1,"","name"],[14,4,1,"","object_id"],[14,4,1,"","root"],[14,4,1,"","site_storage"],[14,4,1,"","web_url"]],"O365.subscriptions":[[15,1,1,"","Subscriptions"]],"O365.subscriptions.Subscriptions":[[15,2,1,"","__init__"],[15,2,1,"","create_subscription"],[15,2,1,"","delete_subscription"],[15,2,1,"","get_subscription"],[15,2,1,"","list_subscriptions"],[15,2,1,"","renew_subscription"],[15,2,1,"","update_subscription"]],"O365.tasks":[[16,1,1,"","ChecklistItem"],[16,1,1,"","Folder"],[16,1,1,"","Task"],[16,1,1,"","ToDo"]],"O365.tasks.ChecklistItem":[[16,2,1,"","__init__"],[16,3,1,"","checked"],[16,3,1,"","created"],[16,2,1,"","delete"],[16,3,1,"","displayname"],[16,4,1,"","folder_id"],[16,3,1,"","is_checked"],[16,4,1,"","item_id"],[16,2,1,"","mark_checked"],[16,2,1,"","mark_unchecked"],[16,2,1,"","save"],[16,4,1,"","task_id"],[16,2,1,"","to_api_data"]],"O365.tasks.Folder":[[16,2,1,"","__init__"],[16,2,1,"","delete"],[16,4,1,"","folder_id"],[16,2,1,"","get_task"],[16,2,1,"","get_tasks"],[16,4,1,"","is_default"],[16,4,1,"","name"],[16,2,1,"","new_task"],[16,2,1,"","update"]],"O365.tasks.Task":[[16,2,1,"","__init__"],[16,3,1,"","body"],[16,4,1,"","body_type"],[16,3,1,"","checklist_items"],[16,3,1,"","completed"],[16,3,1,"","created"],[16,2,1,"","delete"],[16,3,1,"","due"],[16,4,1,"","folder_id"],[16,2,1,"","get_body_soup"],[16,2,1,"","get_body_text"],[16,2,1,"","get_checklist_item"],[16,2,1,"","get_checklist_items"],[16,3,1,"","importance"],[16,3,1,"","is_completed"],[16,3,1,"","is_reminder_on"],[16,3,1,"","is_starred"],[16,2,1,"","mark_completed"],[16,2,1,"","mark_uncompleted"],[16,3,1,"","modified"],[16,2,1,"","new_checklist_item"],[16,3,1,"","reminder"],[16,2,1,"","save"],[16,3,1,"","status"],[16,3,1,"","subject"],[16,4,1,"","task_id"],[16,2,1,"","to_api_data"]],"O365.tasks.ToDo":[[16,2,1,"","__init__"],[16,2,1,"","get_default_folder"],[16,2,1,"","get_folder"],[16,2,1,"","get_tasks"],[16,2,1,"","list_folders"],[16,2,1,"","list_folders_delta"],[16,2,1,"","new_folder"],[16,2,1,"","new_task"]],"O365.teams":[[17,1,1,"","Activity"],[17,1,1,"","App"],[17,1,1,"","Availability"],[17,1,1,"","Channel"],[17,1,1,"","ChannelMessage"],[17,1,1,"","Chat"],[17,1,1,"","ChatMessage"],[17,1,1,"","ConversationMember"],[17,1,1,"","PreferredActivity"],[17,1,1,"","PreferredAvailability"],[17,1,1,"","Presence"],[17,1,1,"","Team"],[17,1,1,"","Teams"]],"O365.teams.Activity":[[17,4,1,"","AVAILABLE"],[17,4,1,"","AWAY"],[17,4,1,"","INACALL"],[17,4,1,"","INACONFERENCECALL"],[17,4,1,"","PRESENTING"]],"O365.teams.App":[[17,2,1,"","__init__"],[17,4,1,"","app_definition"],[17,4,1,"","object_id"]],"O365.teams.Availability":[[17,4,1,"","AVAILABLE"],[17,4,1,"","AWAY"],[17,4,1,"","BUSY"],[17,4,1,"","DONOTDISTURB"]],"O365.teams.Channel":[[17,2,1,"","__init__"],[17,4,1,"","description"],[17,4,1,"","display_name"],[17,4,1,"","email"],[17,2,1,"","get_message"],[17,2,1,"","get_messages"],[17,4,1,"","object_id"],[17,2,1,"","send_message"]],"O365.teams.ChannelMessage":[[17,2,1,"","__init__"],[17,4,1,"","channel_id"],[17,2,1,"","get_replies"],[17,2,1,"","get_reply"],[17,2,1,"","send_reply"],[17,4,1,"","team_id"]],"O365.teams.Chat":[[17,2,1,"","__init__"],[17,4,1,"","chat_type"],[17,4,1,"","created_date"],[17,2,1,"","get_member"],[17,2,1,"","get_members"],[17,2,1,"","get_message"],[17,2,1,"","get_messages"],[17,4,1,"","last_update_date"],[17,4,1,"","object_id"],[17,2,1,"","send_message"],[17,4,1,"","topic"],[17,4,1,"","web_url"]],"O365.teams.ChatMessage":[[17,2,1,"","__init__"],[17,4,1,"","channel_identity"],[17,4,1,"","chat_id"],[17,4,1,"","content"],[17,4,1,"","content_type"],[17,4,1,"","created_date"],[17,4,1,"","deleted_date"],[17,4,1,"","from_display_name"],[17,4,1,"","from_id"],[17,4,1,"","from_type"],[17,4,1,"","importance"],[17,4,1,"","last_edited_date"],[17,4,1,"","last_modified_date"],[17,4,1,"","message_type"],[17,4,1,"","object_id"],[17,4,1,"","reply_to_id"],[17,4,1,"","subject"],[17,4,1,"","summary"],[17,4,1,"","web_url"]],"O365.teams.ConversationMember":[[17,2,1,"","__init__"]],"O365.teams.PreferredActivity":[[17,4,1,"","AVAILABLE"],[17,4,1,"","AWAY"],[17,4,1,"","BERIGHTBACK"],[17,4,1,"","BUSY"],[17,4,1,"","DONOTDISTURB"],[17,4,1,"","OFFWORK"]],"O365.teams.PreferredAvailability":[[17,4,1,"","AVAILABLE"],[17,4,1,"","AWAY"],[17,4,1,"","BERIGHTBACK"],[17,4,1,"","BUSY"],[17,4,1,"","DONOTDISTURB"],[17,4,1,"","OFFLINE"]],"O365.teams.Presence":[[17,2,1,"","__init__"],[17,4,1,"","activity"],[17,4,1,"","availability"],[17,4,1,"","object_id"]],"O365.teams.Team":[[17,2,1,"","__init__"],[17,4,1,"","description"],[17,4,1,"","display_name"],[17,2,1,"","get_channel"],[17,2,1,"","get_channels"],[17,4,1,"","is_archived"],[17,4,1,"","object_id"],[17,4,1,"","web_url"]],"O365.teams.Teams":[[17,2,1,"","__init__"],[17,2,1,"","create_channel"],[17,2,1,"","get_apps_in_team"],[17,2,1,"","get_channel"],[17,2,1,"","get_channels"],[17,2,1,"","get_my_chats"],[17,2,1,"","get_my_presence"],[17,2,1,"","get_my_teams"],[17,2,1,"","get_user_presence"],[17,2,1,"","set_my_presence"],[17,2,1,"","set_my_user_preferred_presence"]],"O365.utils":[[19,0,0,"-","attachment"],[20,0,0,"-","query"],[21,0,0,"-","token"],[22,0,0,"-","utils"]],"O365.utils.attachment":[[19,1,1,"","AttachableMixin"],[19,1,1,"","BaseAttachment"],[19,1,1,"","BaseAttachments"],[19,1,1,"","UploadSessionRequest"]],"O365.utils.attachment.AttachableMixin":[[19,2,1,"","__init__"],[19,3,1,"","attachment_name"],[19,3,1,"","attachment_type"],[19,2,1,"","to_api_data"]],"O365.utils.attachment.BaseAttachment":[[19,2,1,"","__init__"],[19,2,1,"","attach"],[19,4,1,"","attachment"],[19,4,1,"","attachment_id"],[19,4,1,"","attachment_type"],[19,4,1,"","content"],[19,4,1,"","content_id"],[19,4,1,"","is_inline"],[19,4,1,"","name"],[19,4,1,"","on_cloud"],[19,4,1,"","on_disk"],[19,2,1,"","save"],[19,2,1,"","to_api_data"]],"O365.utils.attachment.BaseAttachments":[[19,2,1,"","__init__"],[19,2,1,"","add"],[19,2,1,"","clear"],[19,2,1,"","download_attachments"],[19,2,1,"","remove"],[19,2,1,"","to_api_data"]],"O365.utils.attachment.UploadSessionRequest":[[19,2,1,"","__init__"],[19,2,1,"","to_api_data"]],"O365.utils.query":[[20,1,1,"","ChainFilter"],[20,1,1,"","CompositeFilter"],[20,1,1,"","ContainerQueryFilter"],[20,1,1,"","ExpandFilter"],[20,1,1,"","FunctionFilter"],[20,1,1,"","GroupFilter"],[20,1,1,"","IterableFilter"],[20,1,1,"","LogicalFilter"],[20,1,1,"","ModifierQueryFilter"],[20,1,1,"","NegateFilter"],[20,1,1,"","OperationQueryFilter"],[20,1,1,"","OrderByFilter"],[20,1,1,"","QueryBase"],[20,1,1,"","QueryBuilder"],[20,1,1,"","QueryFilter"],[20,1,1,"","SearchFilter"],[20,1,1,"","SelectFilter"]],"O365.utils.query.ChainFilter":[[20,2,1,"","__init__"],[20,2,1,"","render"]],"O365.utils.query.CompositeFilter":[[20,2,1,"","__init__"],[20,2,1,"","as_params"],[20,2,1,"","clear_filters"],[20,4,1,"","expand"],[20,4,1,"","filters"],[20,3,1,"","has_expands"],[20,3,1,"","has_filters"],[20,3,1,"","has_only_filters"],[20,3,1,"","has_order_by"],[20,3,1,"","has_search"],[20,3,1,"","has_selects"],[20,4,1,"","order_by"],[20,2,1,"","render"],[20,4,1,"","search"],[20,4,1,"","select"]],"O365.utils.query.ContainerQueryFilter":[[20,2,1,"","__init__"],[20,2,1,"","append"],[20,2,1,"","as_params"],[20,2,1,"","render"]],"O365.utils.query.ExpandFilter":[[20,2,1,"","__init__"],[20,2,1,"","render"]],"O365.utils.query.FunctionFilter":[[20,2,1,"","render"]],"O365.utils.query.GroupFilter":[[20,2,1,"","render"]],"O365.utils.query.IterableFilter":[[20,2,1,"","__init__"],[20,2,1,"","render"]],"O365.utils.query.LogicalFilter":[[20,2,1,"","__init__"],[20,2,1,"","render"]],"O365.utils.query.ModifierQueryFilter":[[20,2,1,"","__init__"]],"O365.utils.query.NegateFilter":[[20,2,1,"","render"]],"O365.utils.query.OperationQueryFilter":[[20,2,1,"","__init__"]],"O365.utils.query.OrderByFilter":[[20,2,1,"","__init__"],[20,2,1,"","add"],[20,2,1,"","as_params"],[20,2,1,"","render"]],"O365.utils.query.QueryBase":[[20,2,1,"","as_params"],[20,2,1,"","get_filter_by_attribute"],[20,2,1,"","render"]],"O365.utils.query.QueryBuilder":[[20,2,1,"","__init__"],[20,2,1,"","all"],[20,2,1,"","any"],[20,2,1,"","chain_and"],[20,2,1,"","chain_or"],[20,2,1,"","contains"],[20,2,1,"","endswith"],[20,2,1,"","equals"],[20,2,1,"","expand"],[20,2,1,"","function_operation"],[20,2,1,"","greater"],[20,2,1,"","greater_equal"],[20,2,1,"","group"],[20,2,1,"","iterable_operation"],[20,2,1,"","less"],[20,2,1,"","less_equal"],[20,2,1,"","logical_operation"],[20,2,1,"","negate"],[20,2,1,"","orderby"],[20,2,1,"","search"],[20,2,1,"","select"],[20,2,1,"","startswith"],[20,2,1,"","unequal"]],"O365.utils.query.QueryFilter":[[20,2,1,"","as_params"],[20,2,1,"","render"]],"O365.utils.query.SearchFilter":[[20,2,1,"","__init__"],[20,2,1,"","as_params"],[20,2,1,"","render"]],"O365.utils.query.SelectFilter":[[20,2,1,"","__init__"]],"O365.utils.token":[[21,1,1,"","AWSS3Backend"],[21,1,1,"","AWSSecretsBackend"],[21,1,1,"","BaseTokenBackend"],[21,1,1,"","BitwardenSecretsManagerBackend"],[21,1,1,"","CryptographyManagerType"],[21,1,1,"","DjangoTokenBackend"],[21,1,1,"","EnvTokenBackend"],[21,1,1,"","FileSystemTokenBackend"],[21,1,1,"","FirestoreBackend"],[21,1,1,"","MemoryTokenBackend"]],"O365.utils.token.AWSS3Backend":[[21,2,1,"","__init__"],[21,4,1,"","bucket_name"],[21,2,1,"","check_token"],[21,2,1,"","delete_token"],[21,4,1,"","filename"],[21,2,1,"","load_token"],[21,2,1,"","save_token"]],"O365.utils.token.AWSSecretsBackend":[[21,2,1,"","__init__"],[21,2,1,"","check_token"],[21,2,1,"","delete_token"],[21,2,1,"","load_token"],[21,4,1,"","region_name"],[21,2,1,"","save_token"],[21,4,1,"","secret_name"]],"O365.utils.token.BaseTokenBackend":[[21,2,1,"","__init__"],[21,2,1,"","add"],[21,2,1,"","check_token"],[21,4,1,"","cryptography_manager"],[21,2,1,"","delete_token"],[21,2,1,"","deserialize"],[21,2,1,"","get_access_token"],[21,2,1,"","get_account"],[21,2,1,"","get_all_accounts"],[21,2,1,"","get_id_token"],[21,2,1,"","get_refresh_token"],[21,2,1,"","get_token_scopes"],[21,3,1,"","has_data"],[21,2,1,"","load_token"],[21,2,1,"","modify"],[21,2,1,"","remove_data"],[21,2,1,"","save_token"],[21,2,1,"","serialize"],[21,4,1,"","serializer"],[21,2,1,"","should_refresh_token"],[21,2,1,"","token_expiration_datetime"],[21,2,1,"","token_is_expired"],[21,2,1,"","token_is_long_lived"]],"O365.utils.token.BitwardenSecretsManagerBackend":[[21,2,1,"","__init__"],[21,4,1,"","client"],[21,2,1,"","load_token"],[21,2,1,"","save_token"],[21,4,1,"","secret"],[21,4,1,"","secret_id"]],"O365.utils.token.CryptographyManagerType":[[21,2,1,"","__init__"],[21,2,1,"","decrypt"],[21,2,1,"","encrypt"]],"O365.utils.token.DjangoTokenBackend":[[21,2,1,"","__init__"],[21,2,1,"","check_token"],[21,2,1,"","delete_token"],[21,2,1,"","load_token"],[21,2,1,"","save_token"],[21,4,1,"","token_model"]],"O365.utils.token.EnvTokenBackend":[[21,2,1,"","__init__"],[21,2,1,"","check_token"],[21,2,1,"","delete_token"],[21,2,1,"","load_token"],[21,2,1,"","save_token"],[21,4,1,"","token_env_name"]],"O365.utils.token.FileSystemTokenBackend":[[21,2,1,"","__init__"],[21,2,1,"","check_token"],[21,2,1,"","delete_token"],[21,2,1,"","load_token"],[21,2,1,"","save_token"],[21,4,1,"","token_path"]],"O365.utils.token.FirestoreBackend":[[21,2,1,"","__init__"],[21,2,1,"","check_token"],[21,4,1,"","client"],[21,4,1,"","collection"],[21,2,1,"","delete_token"],[21,4,1,"","doc_id"],[21,4,1,"","doc_ref"],[21,4,1,"","field_name"],[21,2,1,"","load_token"],[21,2,1,"","save_token"]],"O365.utils.token.MemoryTokenBackend":[[21,2,1,"","load_token"],[21,2,1,"","save_token"]],"O365.utils.utils":[[22,1,1,"","ApiComponent"],[22,1,1,"","CaseEnum"],[22,1,1,"","ChainOperator"],[22,1,1,"","HandleRecipientsMixin"],[22,1,1,"","ImportanceLevel"],[22,1,1,"","OneDriveWellKnowFolderNames"],[22,1,1,"","OutlookWellKnowFolderNames"],[22,1,1,"","Pagination"],[22,1,1,"","Query"],[22,1,1,"","Recipient"],[22,1,1,"","Recipients"],[22,1,1,"","TrackerSet"]],"O365.utils.utils.ApiComponent":[[22,2,1,"","__init__"],[22,2,1,"","build_base_url"],[22,2,1,"","build_url"],[22,4,1,"","main_resource"],[22,2,1,"","new_query"],[22,2,1,"","q"],[22,2,1,"","set_base_url"]],"O365.utils.utils.CaseEnum":[[22,2,1,"","from_value"]],"O365.utils.utils.ChainOperator":[[22,4,1,"","AND"],[22,4,1,"","OR"]],"O365.utils.utils.ImportanceLevel":[[22,4,1,"","High"],[22,4,1,"","Low"],[22,4,1,"","Normal"]],"O365.utils.utils.OneDriveWellKnowFolderNames":[[22,4,1,"","APP_ROOT"],[22,4,1,"","ATTACHMENTS"],[22,4,1,"","CAMERA_ROLL"],[22,4,1,"","DOCUMENTS"],[22,4,1,"","MUSIC"],[22,4,1,"","PHOTOS"]],"O365.utils.utils.OutlookWellKnowFolderNames":[[22,4,1,"","ARCHIVE"],[22,4,1,"","CLUTTER"],[22,4,1,"","CONFLICTS"],[22,4,1,"","CONVERSATIONHISTORY"],[22,4,1,"","DELETED"],[22,4,1,"","DRAFTS"],[22,4,1,"","INBOX"],[22,4,1,"","JUNK"],[22,4,1,"","LOCALFAILURES"],[22,4,1,"","OUTBOX"],[22,4,1,"","RECOVERABLEITEMSDELETIONS"],[22,4,1,"","SCHEDULED"],[22,4,1,"","SEARCHFOLDERS"],[22,4,1,"","SENT"],[22,4,1,"","SERVERFAILURES"],[22,4,1,"","SYNCISSUES"]],"O365.utils.utils.Pagination":[[22,2,1,"","__init__"],[22,4,1,"","constructor"],[22,4,1,"","data_count"],[22,4,1,"","extra_args"],[22,4,1,"","limit"],[22,4,1,"","next_link"],[22,4,1,"","parent"],[22,4,1,"","state"],[22,4,1,"","total_count"]],"O365.utils.utils.Query":[[22,2,1,"","__init__"],[22,2,1,"","all"],[22,2,1,"","any"],[22,2,1,"","as_params"],[22,2,1,"","chain"],[22,2,1,"","clear"],[22,2,1,"","clear_filters"],[22,2,1,"","clear_order"],[22,2,1,"","close_group"],[22,2,1,"","contains"],[22,2,1,"","endswith"],[22,2,1,"","equals"],[22,2,1,"","expand"],[22,2,1,"","function"],[22,2,1,"","get_expands"],[22,2,1,"","get_filter_by_attribute"],[22,2,1,"","get_filters"],[22,2,1,"","get_order"],[22,2,1,"","get_selects"],[22,2,1,"","greater"],[22,2,1,"","greater_equal"],[22,3,1,"","has_expands"],[22,3,1,"","has_filters"],[22,3,1,"","has_order"],[22,3,1,"","has_selects"],[22,2,1,"","iterable"],[22,2,1,"","less"],[22,2,1,"","less_equal"],[22,2,1,"","logical_operator"],[22,2,1,"","negate"],[22,2,1,"","new"],[22,2,1,"","on_attribute"],[22,2,1,"","on_list_field"],[22,2,1,"","open_group"],[22,2,1,"","order_by"],[22,4,1,"","protocol"],[22,2,1,"","remove_filter"],[22,2,1,"","search"],[22,2,1,"","select"],[22,2,1,"","startswith"],[22,2,1,"","unequal"]],"O365.utils.utils.Recipient":[[22,2,1,"","__init__"],[22,3,1,"","address"],[22,3,1,"","name"]],"O365.utils.utils.Recipients":[[22,2,1,"","__init__"],[22,2,1,"","add"],[22,2,1,"","clear"],[22,2,1,"","get_first_recipient_with_address"],[22,2,1,"","remove"]],"O365.utils.utils.TrackerSet":[[22,2,1,"","__init__"],[22,2,1,"","add"],[22,2,1,"","remove"]]},"objnames":{"0":["py","module","Python module"],"1":["py","class","Python class"],"2":["py","method","Python method"],"3":["py","property","Python property"],"4":["py","attribute","Python attribute"],"5":["py","exception","Python exception"],"6":["py","function","Python function"]},"objtypes":{"0":"py:module","1":"py:class","2":"py:method","3":"py:property","4":"py:attribute","5":"py:exception","6":"py:function"},"terms":{"00z":[42,44],"07t11":38,"0ea462f4eb47":13,"120x120":[2,6],"1234567890ab":38,"19bb370949d8":13,"1h":40,"1st":23,"21t00":[42,44],"240x240":[2,6],"2f":38,"2fwebhook":38,"300x400":12,"301594z":38,"305776z":38,"360x360":[2,6],"3a":38,"3ad02b2dfdb1":38,"3ad02b2dfdb12":38,"432x432":[2,6],"46da":38,"47ae":38,"48x48":[2,6],"4c34":38,"4e98f8f1":13,"4mb":[12,35],"4xx":[5,44],"504x504":[2,6],"548355f8":38,"5xx":[5,44],"648x648":[2,6],"64x64":[2,6],"94d6":38,"96x96":[2,6],"9f6b":13,"A":[2,3,4,5,6,7,9,10,11,12,13,14,15,16,17,19,20,21,22,23,25,27,29,31,35,37,39,42,44],"ALL":[10,34],"AND":22,"After":32,"All":[6,7,9,13,23,27,28,31,33,35,36,37,40],"An":[3,7,9,11,12,13,14,17,21,34,38],"And":[23,40],"Are":25,"As":[2,12,42,44],"At":[7,21,23],"Both":35,"But":[27,30,35],"By":[21,23,28,30],"Do":16,"Each":30,"For":[6,7,12,14,20,22,23,27,29,30,38,42,43,44],"From":23,"He":29,"Here":[25,44],"How":[5,12,13],"I":[7,17,25,28,29,30,34],"If":[1,2,3,4,5,6,7,11,12,13,14,17,20,21,22,23,27,28,30,31,37,42,44],"In":[9,13,16,23,34,44],"Is":[3,7,11,12,16],"It":[1,3,4,14,20,22,27,29,39,44],"ME":30,"NOT":[21,23,29],"No":[3,23,29],"Not":[2,5,6,10,11,22,23,37],"OR":22,"On":23,"Or":[23,33],"Other":[2,23],"Over":[2,3,6,9,10,12,14,16],"Same":23,"Should":5,"So":[23,29],"Some":[23,32],"TO":[2,6,11],"That":27,"The":[1,2,3,4,5,6,7,9,10,11,12,13,14,16,17,19,20,21,22,23,25,27,29,30,31,32,34,35,37,38,39,42,43,44],"Then":[21,22,23,36,40,44],"There":[7,21,23,29,34,43],"These":[23,28,29,31,32,33,34,35,36,37,39,40],"This":[1,2,3,5,6,7,10,12,13,19,20,21,22,23,25,27,30,31,32,43,44],"Those":23,"To":[2,3,6,12,16,21,23,28,29,31,32,33,34,35,36,37,39,40,42,43,44],"Too":5,"Under":23,"We":[25,27,38],"When":[5,6,13,17,20,22,23,27,30,32,35,43,44],"Which":3,"With":[23,25,38],"You":[12,20,22,23,27,28,30,32,34,35,42,43,44],"Your":[3,12],"__init__":[1,2,3,4,5,6,7,9,10,11,12,13,14,15,16,17,19,20,21,22,23,27],"__name__":38,"__str__":21,"_endpoint":27,"_extern":23,"_oauth_scope_prefix":5,"_protocol_url":5,"a1":[7,32],"a5c7":38,"aac7":38,"abc":20,"abc123":[23,38],"abcd":38,"abil":38,"abl":23,"abort":38,"about_m":6,"abov":[7,31,38],"absolut":[12,23],"abstract":[21,23,44],"abstractmethod":20,"accept":[3,14,29],"accept_ev":3,"access":[1,5,6,12,16,21,22,23,25,27,28,30,32,35,37],"access_token":[5,21],"accomplish":23,"account":[0,2,3,4,5,6,9,10,11,12,13,14,16,17,19,21,23,24,25,26,28,29,30,31,33,34,35,36,37,38,39,40,43,44],"account_en":6,"acct":37,"accuraci":38,"achiev":[7,21,23,25],"ack":38,"across":[3,5,7,12],"act":[12,38],"action":[6,7],"activ":[0,1,6,7,17,24,31,40],"active_checklist_item_count":13,"ad":[6,9,13,23],"add":[2,3,5,6,7,10,11,16,17,19,20,21,22,23,25,27,28,30,34,36],"add_categori":11,"add_column":7,"add_message_head":11,"add_named_rang":7,"add_row":7,"add_tabl":7,"add_worksheet":7,"addit":[7,23],"additional_data":15,"address":[0,1,3,5,6,7,9,11,17,20,22,24,26,31,34],"address_book":[1,2,23,27,28],"address_book_al":[23,27,28],"address_book_all_shar":[23,27,28],"address_book_shar":[23,27,28],"address_loc":7,"addressbook":[0,1,2,24,28],"admin":[23,28,37],"administr":[6,23,28,40],"advanc":23,"affect":23,"afterward":23,"age":6,"age_group":6,"agegroup":6,"aim":25,"alcohol":34,"alejca":25,"alia":[1,6,9,13],"align":7,"allow":[1,2,3,5,6,9,10,12,14,16,19,22,23,29,31,35],"allow_extern":12,"allowed_pdf_extens":12,"almost":25,"along":[21,22],"alreadi":[1,5,21,22,23,27,30],"also":[3,5,6,12,21,23,25,27,28,29,30,31,32,34,35,39,42,43,44],"altern":34,"alway":[21,23,25],"alwaysen":[10,34],"among":12,"amount":22,"ancestor":12,"ani":[1,2,3,5,6,7,10,11,12,13,14,17,19,20,21,22,23,28,30,32,34,37,40],"announc":14,"anonym":[12,34],"anoth":[7,10,12,21,28,29,34,37,40],"answer":[21,23],"anyon":12,"anyth":23,"anyway":[29,30],"api":[2,5,7,9,11,12,13,14,15,16,19,21,22,23,24,25,26,28,30,35,38,42,43,44],"api_object":19,"api_vers":[5,22,30],"apicompon":[2,3,4,6,9,10,11,12,13,14,15,16,17,18,19,20,22,25,27,42,44],"app":[0,1,5,6,12,13,15,17,23,24,31,38],"app_definit":17,"app_id":[33,36,37,40],"app_pw":[33,36,37,40],"app_root":22,"appear":[12,14,17],"append":[7,20],"append_row":7,"appli":[2,3,6,7,10,12,13,14,16,20,21,22,23,30],"applic":[6,7,12,17,21,23,27,38],"applicationid":38,"applied_categori":13,"apply_filt":7,"apply_to":7,"approach":23,"approot":22,"appropi":23,"appropri":7,"approri":38,"approv":[1,5,27],"ar":34,"archer":25,"archiv":[10,22,34],"archive_fold":10,"arg":[1,5,7,12,13,14,20,21,22,38],"argument":[22,25],"around":[3,27,35],"array":[7,32],"as_param":[20,22],"ascend":[20,22],"ask":23,"asleep":[25,30],"aspect":[27,30],"assign":[2,3,4,6,7,11,13,36],"assigne":13,"assigned_licens":6,"assigned_plan":6,"assignedlicens":6,"assignedplan":6,"assignee_prior":13,"associ":[2,6,7,13,34],"assum":[23,33,36,37,38,40],"assumpt":23,"async":35,"asynchron":12,"att":34,"attach":[0,3,5,10,11,18,20,22,24,29,30,34,35],"attachablemixin":[2,3,11,18,19],"attachment_id":19,"attachment_nam":19,"attachment_name_properti":19,"attachment_typ":[19,34],"attachments_fold":35,"attempt":[7,23],"attend":[3,6,29],"attende":[0,3,24],"attendee_typ":3,"attendeetyp":[0,3,24],"attribut":[6,10,12,20,22,23,28,29],"audienc":10,"auth":[1,5,23,27],"auth_complet":23,"auth_flow_typ":[5,23,30],"auth_step_on":23,"auth_step_two_callback":23,"authent":[1,5,24,25,26,30,33,36,37,38,40,43],"author":[1,5,23,30,31],"authorization_url":[1,5],"auto":[3,4,10,12,34],"auto_fit_column":[7,32],"auto_fit_row":7,"auto_now":21,"auto_now_add":21,"autofit":32,"automat":[4,5,10,13,20,23,25,27,29,32,39,42,44],"automaticrepliesset":[0,10,24,34],"automaticrepliessettingss":10,"autoreplystatus":[0,10,24,34],"avail":[0,1,3,6,7,11,12,17,23,24,26,27,30,40,43],"availableidl":17,"avoid":[21,23],"aw":[21,23],"awar":[25,29,39],"away":[7,17],"awss3backend":[18,21,23],"awssecretsbackend":[18,21,23],"ax":3,"azur":[6,23],"b":[7,22],"b2":[7,32],"b25090a1ed66":38,"b4":7,"b8e0":13,"back":[3,17,21,22,23,38],"backend":[1,5,7,21,23,27,43],"background":7,"background_color":7,"backoff":21,"backward":28,"bad":44,"bad9":38,"band":7,"bar":23,"base":[1,2,3,4,5,6,7,9,10,11,12,13,14,15,16,17,19,20,21,22,23,29],"baseattach":[3,11,18,19],"basecontactfold":[0,2,24],"basetokenbackend":[5,18,21,23],"basic":[24,27,28,31,40],"batch":[2,3,6,9,10,12,14,16,17,22,44],"bb03":13,"bcc":11,"beat":28,"beatifulsoup4":23,"beautifulsoup":[3,11,16],"beautifulsoup4":[3,11,16],"becaus":[21,23],"becom":[7,29,32,42,44],"bed":28,"befor":[3,5,6,23,27],"beginn":25,"behalf":[3,23,31],"behavior":7,"behaviour":30,"belong":[11,13,14],"berightback":17,"best":[7,20,22,23,25,28,29,30,34,35,39,42,44],"beta":[11,30],"bewar":7,"big":12,"bigger":[12,35],"bin":12,"binari":12,"bird":34,"birthday":[6,29],"bitwarden":[21,23],"bitwardencli":21,"bitwardensecretsmanagerbackend":[18,21,23],"black":4,"blahblah":27,"blank":[7,22,30,37],"blob":[23,38],"blue":[3,4],"bob":6,"bodi":[3,7,11,12,16,17,25,28,30,34,42,44],"body_preview":11,"body_typ":[3,11,16],"bodytyp":[3,11,17],"bold":[7,32],"bonus":28,"book":[0,1,6,24,26],"bool":[1,2,3,4,5,6,7,9,10,11,12,13,14,15,16,17,19,20,21,22],"boolean":[7,13],"booz":34,"border":7,"boss":[42,44],"bottom":7,"bound":7,"boundari":22,"br":7,"broad":12,"broaden":12,"brown":4,"browser":[7,12,14],"bs4":[3,11,16],"bucket":[0,13,21,23,24,36],"bucket_id":13,"bucket_nam":21,"bufferediobas":12,"build":[20,22,25],"build_base_url":22,"build_doc":25,"build_field_filt":14,"build_url":[22,27],"builder":[26,28,29,34,41,44],"built":[25,27],"busi":[2,3,5,6,7,17,40],"business_address":2,"business_phon":[2,6],"businesscentr":5,"busyidl":17,"byte":[2,6,12,21],"bytesio":12,"c10":32,"c2c0":38,"c300x400":12,"c300x400_crop":12,"c5":7,"ca2a1df2":13,"cach":[7,21],"calculation_typ":7,"calend":[3,16],"calendar":[0,1,23,24,25,26,27,39],"calendar_al":[23,27,29],"calendar_id":3,"calendar_nam":[3,29],"calendar_shar":[23,27,29],"calendar_shared_al":[23,27,29],"calendarcolor":[0,3,24],"call":[5,6,7,11,12,14,19,21,23,27,30,38,44],"callabl":[1,5],"callback":23,"caller":21,"camelcas":5,"camera":12,"camera_mak":12,"camera_model":[12,35],"camera_rol":22,"camerarol":22,"can":[2,3,4,5,6,7,10,11,12,13,14,16,20,21,22,23,25,27,28,29,30,31,32,34,35,37,38,39,42,43,44],"can_edit":3,"can_shar":3,"can_view_private_item":3,"canada":34,"cancel":3,"cancel_ev":3,"capabl":[6,20],"capplic":17,"captur":44,"car":34,"care":[23,34,35],"carri":[42,44],"case":[5,21,22,23,30,42,44],"caseenum":[3,11,18,22],"casing_funct":5,"catalog":17,"categor":34,"categori":[0,1,2,3,11,13,24,26],"category1":13,"category2":13,"category25":13,"category3":13,"category5":13,"category_descript":13,"category_id":4,"categorycolor":[0,4,24,34],"caution":12,"cc":11,"celebr":29,"cell":[7,32],"cell_count":7,"cella1":32,"cellular":6,"center":[7,23],"centeracrossselect":7,"central":5,"cert":[5,23],"certain":[7,23,44],"certif":[5,23],"cest":29,"chain":[20,22],"chain_and":20,"chain_or":[20,34,42,44],"chainfilt":[18,20],"chainoper":[18,22],"chang":[2,3,6,7,10,11,16,17,21,22,23,25,27,30,32,34,37,38],"change_typ":[15,38],"changetyp":38,"channel":[0,17,24,40],"channel_id":17,"channel_ident":17,"channelident":17,"channelmessag":[0,17,24,40],"charact":23,"character":4,"chart":32,"chat":[0,17,24,26],"chat_id":17,"chat_typ":[17,40],"chatmessag":[0,17,24],"chatmessagetyp":17,"chattyp":17,"check":[1,2,3,6,7,11,12,16,20,21,22,23,31,34,35],"check_status":[12,35],"check_token":[21,23],"checklist":[13,16],"checklist_item":16,"checklist_item_count":13,"checklistitem":[0,16,24],"child":[2,10,12,28,34],"child_count":12,"child_fold":[28,34],"child_folders_count":10,"children":12,"choic":28,"choos":[1,4,23,24],"chosen":[13,21,23],"chunk":[12,35],"chunk_siz":12,"cid":34,"citi":6,"class":[1,2,3,4,5,6,7,9,10,11,12,13,14,15,16,17,19,20,21,22,23,24,26,28,29,30,31,32,33,34,35,36,37,39,40],"classifi":6,"classmethod":[4,22],"claus":[16,22],"clear":[3,7,19,22],"clear_filt":[7,20,22],"clear_ord":22,"click":23,"client":[1,5,7,10,17,21,23,38],"client_id":[1,5,21,23,25,27,30,40],"client_secret":[1,5,23,25,27,30],"client_stat":[15,38],"clientstat":38,"close":[7,22],"close_group":22,"close_sess":7,"cloud":[2,3,5,10,11,14,19,22,23,28,34,37],"clutter":[10,22],"clutter_fold":10,"code":[3,5,6,7,23,25],"col":37,"col1":37,"col2":37,"col_nam":14,"col_valu":14,"collect":[3,4,7,12,13,14,19,20,21,22,23,42,44],"color":[3,4,7,34],"column":[7,14,32,37],"column_count":7,"column_group":14,"column_hidden":7,"column_index":7,"column_nam":37,"column_name_cw":[14,37],"column_offset":7,"column_width":7,"com":[5,6,7,9,11,12,13,14,20,22,23,25,27,28,30,33,34,35,37,38,40],"combin":[20,22],"come":29,"comma":14,"command":[22,25,36,37,40],"comment":[3,7],"commmon":37,"common":[5,22,23,25,36,37,40],"communic":[3,5,7,14,15,16,19,22,27,30,40],"compani":[2,6],"company_nam":[2,6],"compar":[5,20,22],"compat":28,"complet":[2,6,11,12,13,16,23,28,35,36],"completed_d":13,"completion_d":11,"completion_percentag":12,"completition_d":11,"compon":[2,3,22],"compositefilt":[18,20],"con":[1,2,3,4,6,7,9,10,11,12,13,14,15,16,17,21,22,23,27],"concept":28,"condit":[2,3,6,10,12,14,16,21,23],"confidenti":3,"confidentialclientappl":5,"configur":[1,3,5,10,23],"conflict":[10,12,22],"conflict_handl":12,"conflicts_fold":10,"conform":22,"conjunct":[1,4,5],"connect":[0,1,2,3,4,6,7,9,10,11,12,13,14,15,16,17,19,21,22,23,24,26,30,37,43,44],"connection_constructor":1,"consent":[1,6,23,27,28,30,40],"consent_input_token":1,"consent_provided_for_minor":6,"consentprovidedforminor":6,"consid":[3,7,13,44],"consist":37,"consol":[1,23,27],"constant":[4,5,7],"constructor":[22,30],"consum":[7,35,44],"contact":[0,2,4,6,12,14,23,24,25,26,27,34],"contactfold":[0,2,24],"contactson":[10,34],"contain":[1,2,6,7,10,11,12,13,16,20,21,22,31,34,37,42,44],"containerqueryfilt":[18,20],"content":[3,7,9,10,11,12,14,16,17,19,21,38,40,42,44],"content_id":[19,34],"content_typ":[17,40],"content_type_id":14,"content_types_en":14,"context":[15,38],"contoso":[6,9,14],"contract":[7,39],"conveni":7,"convent":[2,6,16],"convers":[5,10,11,13,17,25],"conversation_id":11,"conversation_index":11,"conversation_thread_id":13,"conversationhistori":22,"conversationhistory_fold":10,"conversationmemb":[0,17,24],"convert":[1,5,7,11,12,22,23,29,39,42,44],"convert_cas":5,"convert_to_pdf":12,"convert_to_rang":7,"cooki":6,"cookiebackend":23,"copi":[10,11,12,23,25,27,35],"copied_item":35,"copy_fold":10,"copyoper":[0,12,24,35],"core":24,"corner":7,"correct":[23,38],"count":22,"countri":6,"cover":12,"cqaaabyacccoirerlbinrjdcfymjq4khbbnh4n7a":38,"cranberri":4,"creat":[1,2,3,4,5,6,7,10,11,12,13,14,15,16,17,19,21,22,23,24,25,26,28,29,30,31,32,33,34,36,37,39,42,44],"create_bucket":[13,36],"create_categori":[4,34],"create_channel":[17,40],"create_child_fold":[2,10,12,28,34],"create_list":14,"create_list_item":[14,37],"create_plan":[13,36],"create_sess":7,"create_subscript":[15,38],"create_task":[13,36],"created_at":21,"created_bi":[12,14],"created_d":[13,17],"created_date_tim":[13,42,44],"createddatetim":[42,44],"creation":[3,12,29,36],"creator":[12,14],"creatorid":38,"credenti":[1,5,21,23,25,27,30,35,43],"credential_typ":21,"criteria":7,"criterion1":7,"criterion2":7,"crop":12,"cryptographi":[21,43],"cryptography_manag":[21,23,43],"cryptographymanagertyp":[18,21],"cryptomanag":43,"current":[1,3,6,7,10,11,12,15,16,20,21,22,23,25,27,35,39,42,44],"current_permis":35,"custom":[1,11,12,19,22,25,27],"custom_class":27,"custom_nam":19,"customclass":27,"customendpoint":27,"d":28,"d10":7,"d4":7,"da":[42,44],"dailyeventfrequ":[0,3,24],"darkblu":4,"darkbrown":4,"darkcranberri":4,"darkgreen":[4,34],"darkgrey":4,"darkol":4,"darkorang":4,"darkpurpl":4,"darkr":4,"darksteel":4,"darkteal":4,"darkyellow":4,"data":[1,2,3,5,6,7,10,11,12,14,16,17,19,21,22,23,27,29,30,31,32,34,37,42,44],"data_count":22,"data_typ":7,"databas":21,"datastor":23,"datatyp":5,"date":[3,6,11,12,13,14,17,20,29,34],"datetim":[2,3,6,7,10,11,12,13,14,15,16,17,21,25,29,39,42,44],"datetimefield":21,"dateutil":23,"datttim":14,"day":[3,10,23],"day_of_month":3,"days_of_week":3,"dd":12,"deal":19,"declin":[3,29],"decline_ev":3,"decrypt":[21,23,43],"def":[21,23,25,27,38],"default":[1,3,5,7,10,12,13,14,16,19,20,21,23,29,30,31,32,34,35,39,42,44],"default_expiration_minut":38,"default_head":5,"default_resourc":[5,30],"defaultlist":16,"defend":39,"defin":[7,10,14,19,23,27,30],"definit":11,"delay":[5,12],"delay_deliveri":11,"delay_seconds_or_absolute_datetim":11,"deleg":[3,9,13,16,23],"delet":[2,3,4,5,7,10,11,12,13,14,15,16,17,21,22,24,26,28,31,34,35,36,37],"delete_column":7,"delete_flag":11,"delete_list_item":[14,37],"delete_messag":10,"delete_row":7,"delete_subscript":[15,38],"delete_token":[21,23],"delete_worksheet":7,"deleted_d":17,"deleted_fold":10,"deleteditem":[10,22],"deliveri":[11,38],"delt":14,"delta":16,"denomin":12,"depart":[2,6],"depend":23,"deprec":[23,44],"descend":22,"describ":[6,7,38],"descript":[9,12,13,14,17,23,27,28,29,31,33,34,35,36,37,38,39,40],"deseri":[21,23],"desir":[23,31,37],"destin":10,"detail":[3,12,13,17,24,38,43],"detect":[29,39],"determin":[6,7,34],"dev":5,"develop":[17,24],"devic":12,"dict":[1,2,3,5,6,7,11,12,13,14,15,16,17,19,20,21,22,23,34],"dictionari":[2,5,14,19,22,23,37],"die":29,"differ":[3,7,11,12,17,20,21,24,25,26,30,35],"difficult":[28,34],"dimens":[7,12,35],"dimension":[7,32],"direct":[6,7,35],"directori":[0,1,24,26,28,38,40],"disabl":[3,10,34],"disk":19,"display":[2,6,7,9,10,12,13,14,16,17,31,37],"display_nam":[2,6,9,14,17,28,37],"displaynam":[10,16],"distinguish":3,"distribut":[7,28],"distributionmethod":17,"django":[21,23],"djangotokenbackend":[18,21,23],"do_some_stuff":27,"doc":[2,3,5,6,7,12,13,16,20,22,24,42,44],"doc_id":[21,23],"doc_ref":21,"document":[7,14,15,21,22,23,27,32,35],"document_id":23,"documentlibrari":14,"documents_fold":35,"doe":[5,16,21,23,29,31,33,40],"doesn":[1,21,31],"domain":[23,27,30],"don":[1,5,6,7,23,27,29,30],"done":[5,23,30,37],"donotdisturb":17,"doubl":7,"download":[3,10,11,12,19,35],"download_attach":[3,10,11,19],"download_cont":12,"downloadablemixin":[0,12,24],"draft":[2,6,10,11,22,28,34],"drafts_fold":10,"drill":9,"drink":[25,30],"drive":[0,1,7,14,24,35],"drive_id":[12,14],"drive_item":35,"driveitem":[0,12,24,35],"driveitem_id":12,"driveitempermiss":[0,12,24],"driveitemvers":[0,12,24],"dst":29,"dsttzinfo":29,"dt":[13,21,29,39],"due":[11,13,16,39],"due_dat":11,"due_date_tim":13,"dure":[3,5,38],"dynam":5,"dynamiccriteria":7,"dynamics365":5,"e":[12,13,20,22],"e15":7,"e16":7,"e36b":13,"eas":25,"easi":[25,27,28,34,42,44],"easier":[7,27],"easili":[23,42,44],"east":21,"echo":38,"edit":[11,12,17,35],"effect":22,"effici":[7,32],"eg":[7,22],"either":[10,22,23],"ej":[12,14],"element":[12,19,22,35],"elif":35,"els":[23,27,29,34,35],"email":[2,3,5,6,10,11,12,17,22,24,25,26,28,31,38],"email_address":[20,22],"emailaddress":[20,22],"emb":12,"embed":12,"eml":[11,34],"employe":6,"employee_id":6,"empti":[1,6,7,17,22],"en":[5,7,11,13,20,22,38],"enabl":[3,6,14,33,34,36,40],"encompass":7,"encrypt":[21,23,43],"encryption_certif":15,"encryption_certificate_id":15,"encryptioncertif":38,"encryptioncertificateid":38,"end":[3,7,10,29,34],"end_dat":3,"end_q":29,"end_recur":[3,29],"endpoint":[6,7,12,16,22,27,30],"endswith":[20,22],"enforce_unique_valu":14,"england":29,"english":7,"enough":7,"ensur":23,"enterpris":6,"entir":7,"entiti":38,"entra":[5,6,23],"entri":6,"enum":[4,10,11,17,22,25],"enumer":[6,14],"environ":[5,21,23],"environment":21,"envtokenbackend":[18,21,23],"eq":[10,20,22],"equal":[20,22],"errata03":[2,3,6,16],"error":[5,7,26,37,41],"etag":38,"etc":[1,2,7,20,23,25,29,34,44],"europ":29,"even":[7,21,32],"event":[0,1,2,3,4,11,20,21,22,24,29,34],"event_id":3,"event_typ":3,"eventattach":[0,3,24],"eventmessag":[11,20,22],"eventrecurr":[0,3,24],"eventrespons":[0,3,24],"eventsensit":[0,3,24],"eventshowa":[0,3,24],"eventtyp":[0,3,24],"everi":[1,3,5,27,30,35,42,43,44],"everyth":[22,23,28],"ex":5,"exact":[23,38,44],"exampl":[6,7,9,12,20,21,22,24,27,28,30,32,34,38,42,43,44],"example1":34,"example2":34,"exceed":23,"excel":[0,13,24,26],"excel_fil":32,"except":[3,5,7,44],"execut":[21,23],"exhaust":22,"exist":[2,3,10,13,14,15,16,19,21,22],"exit":22,"expand":[7,20,22,42,44],"expand_field":14,"expandfilt":[18,20],"expect":23,"experi":14,"experimentalqueri":44,"expir":[1,5,7,12,15,21,23,32,38],"expiration_datetim":15,"expiration_dur":17,"expiration_minut":[15,38],"expirationdatetim":38,"explicit":5,"expos":22,"exposur":12,"exposure_denomin":12,"exposure_numer":12,"express":3,"extend":21,"extens":[12,31],"extern":[10,12,13,34],"external_audi":[10,34],"external_reply_messag":[10,34],"external_text":10,"externalaudi":[0,10,24,34],"externalid":17,"extra":[1,3,5,13,22],"extra_arg":22,"f":[12,21,23,35],"f9a1388522f8":38,"fabrikam":6,"face":14,"fact":34,"factor":21,"factori":25,"fail":[7,12,23],"failur":[1,2,3,5,7,10,11,12,13,16,19,21],"fall":17,"fallback":5,"fals":[1,3,5,6,7,9,10,12,13,14,19,20,21,22,23,29,32,44],"falsi":44,"famili":6,"fast":34,"fax":6,"fax_numb":6,"featur":[1,10,23],"feed":20,"feel":25,"fetch":10,"ff":3,"field":[6,9,12,14,15,21,22],"field_nam":21,"field_path":21,"field_typ":14,"file":[0,1,2,7,11,12,19,21,23,24,25,27,32,35,43],"file_created_date_tim":12,"file_item":7,"file_last_modified_date_tim":12,"filea":2,"filenam":[12,21,30],"filesystem":[21,23],"filesystemtokenbackend":[18,21,23,26,41],"fill":[7,37],"filter":[2,3,6,7,10,12,14,20,22,25,28,29,34,42,44],"filter_attr":22,"filter_inst":20,"filtered_messag":[42,44],"filteron":7,"final":[21,22,23,35],"fire":21,"firestor":[21,23],"firestorebackend":[18,21,23],"firestoretokenbackend":23,"first":[2,5,6,7,20,21,22,23,27,29,31,32,34,35,37,44],"first_day_of_week":3,"fit":7,"flag":[0,5,11,17,21,23,24],"flag_data":11,"flask":[23,38],"flavour":23,"float":[5,7,12,20],"flow":[1,5,23,25,27],"flow_as_str":23,"fluent":22,"fmt":32,"fnumber":12,"focal":12,"focal_length":12,"focus":11,"folder":[0,2,3,10,11,12,14,16,21,23,24,26,35,39],"folder_id":[2,10,11,16],"folder_nam":[2,10,16,28,34,39],"follow":[9,13,16,23,25,27,30,32,38],"followup":11,"font":[7,32],"footbal":[28,29],"forc":[5,7,12,21,27],"forget":23,"format":[2,6,7,11,12,13,32,42,44],"former":[12,23],"formula":7,"formulas_loc":7,"formulas_r1_c1":7,"forward":[11,25,39],"found":[4,5,17,20,22,27,38],"four":28,"fraction":12,"frame":3,"free":3,"freeform":6,"frm":3,"from_display_nam":17,"from_id":17,"from_typ":17,"from_valu":22,"full":[2,5,6,14,25,27,28,31],"full_nam":[2,6],"func":22,"function":[1,2,5,7,12,14,19,20,22,23,25,27,28,29,32,34,35,37,39],"function_nam":[7,22],"function_oper":20,"function_param":7,"functionexcept":[0,7,24],"functionfilt":[18,20],"futur":[6,7,25,38],"g":[12,13,20,22],"gal":28,"gave":34,"geethanadh":25,"general":7,"generat":[5,12,17,23,35],"genericlist":14,"georg":[20,22,25,28,29,30,34,35,39,42,44],"george_best":35,"george_best_quot":[34,35],"german":7,"get":[1,2,3,4,5,6,7,9,10,11,12,14,16,17,19,21,22,24,25,27,28,31,32,34,35,38],"get_access_token":21,"get_account":21,"get_all_account":21,"get_apps_in_channel":40,"get_apps_in_team":17,"get_authenticated_usernam":1,"get_authorization_url":[1,5,23],"get_avail":3,"get_body_soup":[3,11,16],"get_body_text":[3,11,16],"get_bounding_rect":7,"get_bucket_by_id":[13,36],"get_calendar":[3,29],"get_categori":[4,34],"get_cel":7,"get_channel":[17,40],"get_checklist_item":16,"get_child_fold":12,"get_column":7,"get_column_at_index":[7,32],"get_columns_aft":7,"get_columns_befor":7,"get_contact":[2,28],"get_contact_by_email":2,"get_current_us":6,"get_current_user_data":1,"get_data_body_rang":7,"get_default_calendar":[3,29],"get_default_document_librari":14,"get_default_dr":[12,35],"get_default_fold":[16,39],"get_detail":[7,13,36],"get_document_librari":[14,37],"get_driv":[12,35],"get_eml_as_object":11,"get_entire_column":7,"get_ev":[3,11,29],"get_expand":22,"get_filt":[7,22],"get_filter_by_attribut":[20,22],"get_first_recipient_with_address":22,"get_flow":23,"get_fold":[2,10,16,28,34,39],"get_format":[7,32],"get_group_by_id":[9,33],"get_group_by_mail":[9,33],"get_group_memb":[9,33],"get_group_own":[9,33],"get_header_row_rang":7,"get_id_token":21,"get_intersect":7,"get_item":[12,14,35,37],"get_item_by_id":[14,37],"get_item_by_path":12,"get_json":38,"get_last_cel":7,"get_last_column":7,"get_last_row":7,"get_list":[14,37],"get_list_by_nam":[14,37],"get_list_column":[14,37],"get_memb":[17,40],"get_messag":[10,17,30,34,40,42,44],"get_mime_cont":11,"get_my_chat":[17,40],"get_my_pres":[17,40],"get_my_task":[13,36],"get_my_team":[17,40],"get_naive_sess":5,"get_named_rang":7,"get_occurr":3,"get_offset_rang":7,"get_ord":22,"get_par":12,"get_parent_fold":10,"get_permiss":[12,35],"get_plan_by_id":13,"get_profile_photo":[2,6],"get_rang":[7,32],"get_rec":12,"get_refresh_token":21,"get_repli":[17,40],"get_resized_rang":7,"get_root_fold":[12,35],"get_root_sit":14,"get_row":7,"get_row_at_index":7,"get_rows_abov":7,"get_rows_below":7,"get_scopes_for":[5,23,27],"get_select":22,"get_service_keyword":5,"get_sess":[5,23],"get_set":[10,34],"get_shared_with_m":12,"get_sit":[14,37],"get_special_fold":[12,35],"get_subscript":15,"get_subsit":[14,37],"get_tabl":[7,32],"get_task":[16,39],"get_task_by_id":13,"get_thumbnail":12,"get_token_scop":21,"get_total_row_rang":7,"get_us":[6,28,31,40],"get_used_rang":7,"get_user_direct_report":6,"get_user_group":[9,33],"get_user_manag":6,"get_user_pres":[17,40],"get_vers":[12,35],"get_workbookappl":7,"get_worksheet":[7,32],"getboundingrect":7,"getter":[2,3,7,10,11,12,16,19,22],"git":23,"github":[12,24,37,38],"give":[5,7,23,27],"given":[1,3,5,6,7,9,10,11,13,16,17,21,22,23,28,30,32,33],"given_nam":6,"global":[6,17,23,24,26,31],"global_address_list":28,"globaladdresslist":1,"go":[7,23,28,34],"goal":28,"goe":[7,17],"googl":[21,23],"grant":[5,12,23,37],"granted_to":[12,35],"graph":[1,5,6,7,11,13,15,16,20,22,23,25,28,29,30,36,38,42,44],"gray":4,"greater":[20,22,42,44],"greater_equ":[20,22,29],"green":[3,4],"greet":35,"grid":7,"group":[0,1,2,4,6,12,13,14,17,20,22,23,24,26,28,29,30,31,34,36,39],"group_id":[9,13,33],"group_list":33,"group_mail":[9,33],"group_nam":9,"group_object_id":36,"groupfilt":[18,20],"gt":[42,44],"guest":6,"guid":13,"h":[7,13],"hallo":28,"handl":[1,5,12,20,22,23,24,25,26,30,35,38,41,42],"handle_cons":1,"handler":[22,23],"handlerecipientsmixin":[3,11,18,22],"happen":7,"has_attach":[3,11],"has_data":21,"has_descript":13,"has_expand":[20,22],"has_filt":[20,22],"has_head":7,"has_only_filt":20,"has_ord":22,"has_order_bi":20,"has_search":20,"has_select":[20,22],"hash":12,"hassl":27,"haven":27,"header":[5,7,11,34],"height":[7,12],"held":3,"hello":40,"hellofold":10,"help":[25,30],"helper":[1,5,22,23,25,26,28,29,31,33,34,35,36,37,39,40,41],"hex":3,"hex_color":3,"hexadecim":3,"hidden":[7,14],"high":[16,22],"highlight":7,"highlight_first_column":7,"highlight_last_column":7,"hint":13,"hire_d":6,"histori":10,"hold":[1,5,12,20,21,27],"home":[2,27],"home_account_id":21,"home_address":2,"home_phon":2,"horizont":7,"horizontal_align":7,"host":[14,21,23,38],"host_nam":14,"hostedtoolcach":21,"hour":10,"howev":[5,7,12,23,28,30,32],"html":[2,3,6,11,16,17,23,24,34],"http":[2,5,6,7,38,44],"httperror":[5,44],"https":[3,5,7,11,12,13,16,20,22,23,27,37,38],"huge":27,"hyperlink":17,"ical_uid":3,"icon":7,"id":[2,3,4,5,6,7,9,10,11,12,13,14,15,16,17,19,21,23,30,35,37,38],"id_or_nam":7,"ident":[12,14,17,23,30],"identifi":[2,3,4,6,7,9,10,11,12,13,14,16,17,33,34],"identityset":12,"idiom":25,"idtyp":5,"ignor":[7,12],"im":10,"im_address":6,"imag":[0,12,24,34,35],"img":34,"immedi":[10,12],"immutableid":5,"implement":[5,21,22,23,27,30,34,44],"import":[3,11,13,16,17,20,21,22,23,25,27,29,30,32,33,34,36,37,38,39,40,43],"importancelevel":[3,11,18,22],"inacal":[17,40],"inaconferencecal":17,"inact":[7,17,32],"inactivity_limit":7,"inameet":17,"inbox":[10,22,34,38],"inbox_fold":[10,34],"includ":[3,6,7,12,13,14,17,23,27,28,29,31,33,34,35,36,37,39,40],"include_recur":[3,29],"include_resource_data":15,"includeresourcedata":38,"incom":10,"incomplet":13,"increment":21,"independ":11,"index":[3,7,12,14,24],"indic":[2,5,6,7,11,12,14,19,44],"individu":29,"infer":30,"inference_classif":11,"infinit":25,"info":[3,7,16,17,27,28],"inform":[1,3,6,12,17,22,23,28,31,35,40],"inherit":[6,12,19,27,30,35],"inherited_from":12,"init":[21,23],"initi":[1,5,6,7,14,15,17,19,21,22,23,43],"initialis":16,"inlin":[19,34],"input":[1,23],"insert":[5,7,34],"insert_rang":7,"insid":[12,22],"inspect":38,"instal":[24,25],"instanc":[1,2,3,5,6,7,10,11,12,14,15,16,19,20,21,22,23,24,26,28,29,30,31,32,33,35,36,37,39,44],"instant":6,"instanti":[1,23,27,30],"instead":[2,3,4,6,7,9,10,11,12,13,14,16,17,19,27,30],"int":[2,3,5,6,7,9,10,12,13,14,15,16,17,20,22,38],"integ":7,"intend":[21,23],"interact":[7,10,22,23,25,32],"interest":6,"interfac":[14,24],"intern":[5,10,34,37,44],"internal_nam":14,"internal_reply_messag":[10,34],"internal_text":10,"internet":6,"internet_message_head":34,"internet_message_id":11,"internetmessagehead":11,"intersect":7,"interv":3,"invalid":[1,6,23],"invit":[6,12,35],"invite_us":6,"invited_bi":12,"invitedus":6,"inviteredeemkey":6,"invok":7,"invoke_funct":7,"io":[10,12],"ip":6,"is_all_day":3,"is_archiv":17,"is_authent":[1,23],"is_cancel":3,"is_check":16,"is_complet":[11,16],"is_default":16,"is_delivery_receipt_request":11,"is_draft":11,"is_event_messag":11,"is_fil":[12,35],"is_flag":11,"is_fold":[12,35],"is_formula":7,"is_imag":[12,35],"is_inlin":[19,34],"is_online_meet":3,"is_organ":3,"is_photo":[12,35],"is_read":11,"is_read_receipt_request":11,"is_reminder_on":[3,16],"is_resource_account":6,"is_star":16,"is_xxxx":35,"ischeck":13,"isdeliveryreceiptrequest":11,"isn":[14,23],"iso":[6,12],"iso8601":[42,44],"isreadreceiptrequest":11,"isreminderon":16,"issu":[6,7,10,12,27,29,37],"ital":7,"item":[2,3,4,6,9,10,11,12,13,14,16,17,20,22,25,26,34,35,44],"item_id":[12,14,16,37],"item_nam":[12,20],"item_path":12,"itemrefer":12,"iter":[15,20,22,25,35,44],"iterable_nam":22,"iterable_oper":20,"iterablefilt":[18,20],"itpro":5,"jeff":6,"job":[2,6],"job_titl":[2,6,28],"john":[33,40],"join":[3,9],"json":[5,7,17,21,23,38],"json_encod":5,"jsonencod":5,"jsonfield":21,"jsonifi":38,"junk":[10,22],"junk_fold":10,"junkemail":22,"just":[1,3,5,11,23,25,27,29,34,35,37,38,42,44],"justifi":7,"keep":[1,5,23],"kept":23,"key":[2,3,5,6,7,11,13,16,21,22,23,37,43],"keyerror":22,"keyword":[5,14,20,22],"keyword_data_stor":5,"kingdom":34,"know":[7,20,22,23,29,34],"known":[11,29,37],"kwarg":[1,2,3,4,5,6,7,9,10,11,12,13,14,15,16,17,19,21,22,27],"l34":23,"label":13,"languag":[2,6,7],"last":[2,3,6,7,10,11,12,14,16,17,23,31],"last_act":7,"last_edited_d":17,"last_modified_d":17,"last_password_chang":6,"last_update_d":17,"latenc":44,"later":[23,39,44],"latest":[6,21,24],"latest_supported_tls_vers":15,"latestsupportedtlsvers":38,"latter":23,"lead":14,"leader":40,"learn":[5,11,25,38],"left":[7,23],"legaci":7,"legacy_id":7,"legal":6,"legal_age_group_classif":6,"legalagegroupclassif":6,"length":12,"less":[20,22,29,37],"less_equ":[20,22,29],"let":[23,25],"letter":6,"level":[11,16,30],"lib":21,"librari":[1,5,14,23,25,27,30,32,35,44],"licens":6,"license_assignment_st":6,"licenseassignmentst":6,"life":34,"lifecycle_notification_url":15,"lifecyclenotificationurl":38,"light_blu":3,"light_brown":3,"light_gray":3,"light_green":3,"light_orang":3,"light_pink":3,"light_r":3,"light_teal":3,"light_yellow":3,"lightblu":3,"lightbrown":3,"lightgray":3,"lightgreen":3,"lightorang":3,"lightpink":3,"lightr":3,"lightteal":3,"lightyellow":3,"like":[20,22,23,27,30,40],"limit":[2,3,6,7,9,10,12,14,15,16,17,20,22,23,28,34,35,38,40,44],"link":[1,5,12,17,22,23,35,44],"list":[1,2,3,4,5,6,7,9,10,11,12,13,14,15,16,17,19,20,21,22,24,25,26,27,29,31,33,34,35,39,44],"list_bucket":[13,36],"list_calendar":3,"list_data":14,"list_document_librari":14,"list_fold":16,"list_folders_delta":16,"list_group":[9,33],"list_group_plan":[13,36],"list_group_task":36,"list_nam":37,"list_subscript":[15,38],"list_task":[13,36],"list_user_task":13,"listitem":14,"littl":[23,28],"liverpool":28,"ll":29,"load":[1,5,21,23],"load_token":[5,21,23],"load_token_from_backend":5,"local":[7,10,12,19,25,29,39,42,43,44],"localfailur":22,"localfailures_fold":10,"locat":[2,3,6,12,19,29],"lock":21,"log":[6,10,12,23,38,40,44],"logic":[20,22],"logical_oper":[20,22],"logicalfilt":[18,20],"login":[23,30],"long":[5,23,30],"long_guid":38,"look":[3,27],"loop":[12,35,44],"lot":34,"low":[10,13,16,22],"lowercamelcas":5,"luckili":28,"m":[13,25,29,30,34],"maco":23,"made":[5,17,25],"magic":34,"mail":[6,9,10,23,27,34],"mail_nicknam":[6,9],"mailbox":[0,1,4,6,11,16,23,24,25,26,27,28,29,30,42,44],"mailbox_set":[6,23,27,34],"mailbox_shar":[23,27,34],"mailboxset":[0,6,10,23,24,27,34],"mailfold":[10,11,38],"main":22,"main_email":2,"main_resourc":[1,2,3,4,6,7,9,10,11,12,13,14,15,16,17,19,22,27,30],"maintain":[25,36],"mainten":36,"make":[5,6,7,11,12,14,21,23,25,27,32,37],"manag":[1,2,6,11,21,23,27,31,38,40,43],"mani":[5,21,30],"manifest":17,"manual":[5,27,31],"manufactur":12,"map":[4,15],"mark":[3,10,11,16],"mark_as_read":[11,34],"mark_as_unread":11,"mark_check":16,"mark_complet":[16,39],"mark_uncheck":16,"mark_uncomplet":16,"master":[3,23,38],"match":[7,10,12,17,20,22,23],"matter":23,"max":[2,3,5,6,9,10,12,14,16,20,22],"max_color":3,"max_top_valu":5,"maxcolor":3,"maximum":38,"may":[12,14,23,29,35,43],"mayb":[7,21,27],"mb":[34,35],"meanwhil":21,"mechan":[21,43],"medium":13,"meet":[3,11,17],"meeting_accept":11,"meeting_cancel":11,"meeting_declin":11,"meeting_message_typ":11,"meeting_request":11,"meeting_tentatively_accept":11,"meetingaccept":11,"meetingcancel":11,"meetingdeclin":11,"meetingmessagetyp":[0,11,24],"meetingrequest":11,"meetingtentativelyaccept":11,"member":[9,12,17,22,33,40],"memberlist":40,"membership":[6,9],"membership_id":17,"memori":[12,19,21,23,44],"memorytokenbackend":[18,21,23],"men":28,"merg":[7,27],"messag":[0,1,2,4,6,10,12,17,19,20,22,23,24,26,27,28,29,30,35,38,40,42,44],"message2":27,"message_al":[23,27,34],"message_all_shar":[23,27,34],"message_head":[11,34],"message_id":[10,17],"message_send":[23,27,34],"message_send_shar":[23,27,34],"message_to_all_contats_in_fold":28,"message_typ":17,"messageattach":[0,11,24],"messageflag":[0,11,24],"messages_with_selected_properti":[42,44],"metadata":[12,35,38],"method":[1,2,5,6,7,12,16,19,21,22,23,25,27,30,34,35,43,44],"microsoft":[1,2,5,6,7,9,11,13,15,16,17,20,22,23,25,27,28,29,30,36,38],"microsoftonlin":23,"might":[7,34],"millisecond":5,"mime":[11,12,35],"mime_typ":[12,35],"minor":6,"minut":[3,23,34],"miss":[28,34],"mix":23,"mm":12,"mobile_phon":[2,6],"mode":17,"model":[12,21,23],"modifi":[2,3,11,12,14,16,17,21],"modified_bi":[12,14],"modifierqueryfilt":[18,20],"modul":[21,22,24],"modular":[24,25,26],"moment":[7,12],"money":34,"monitor":12,"monitor_url":12,"month":[3,21],"move":[2,10,11,12],"move_fold":[2,10],"mr":2,"ms":[1,2,16,28,38,42,44],"msal":[5,23],"msal_client":5,"msbusinesscentral365protocol":[0,5,24],"msg":34,"msg_attach":34,"msgraph":25,"msgraphprotocol":[0,5,23,24,27,30],"much":35,"multi":[20,22,24,26],"multipl":[5,7,12,14,21,22,23,35],"music":22,"must":[1,2,4,5,10,12,19,21,22,23,29,37,38,43],"my_categori":34,"my_client_id":[23,27,43],"my_client_secret":[23,27,43],"my_credenti":[30,35],"my_db":23,"my_domain":30,"my_driv":35,"my_file_inst":32,"my_fold":23,"my_imag":34,"my_param":27,"my_project_fold":23,"my_rang":32,"my_required_scop":23,"my_saved_email":34,"my_saved_flow":23,"my_saved_flow_str":23,"my_scop":23,"my_shared_account":34,"my_sit":6,"my_tabl":32,"my_team":40,"my_tenant_id":30,"my_token":23,"my_url_key":27,"my_worksheet":32,"mycryptomanag":43,"myrang":32,"myserv":27,"n":7,"naiv":[5,29,39,42,44],"naive_request":5,"naive_sess":5,"name":[2,3,4,6,7,9,10,11,12,13,14,16,17,19,20,21,22,23,27,28,29,31,34,35,36,37,39,43],"namedrang":[0,7,24,32],"nativecli":23,"navig":[12,23],"nbsp":[1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,19,20,21,22],"need":[5,12,19,21,23,27,28,29,30,31,32,33,34,35,36,37,38,39,40,43,44],"negat":[7,20,22],"negatefilt":[18,20],"network":44,"new":[1,2,3,5,6,7,10,11,12,13,14,16,19,20,22,23,25,27,28,29,32,35,37,38,39],"new_calendar":3,"new_checklist_item":16,"new_class_nam":22,"new_contact":[2,28],"new_data":[14,37],"new_ev":[3,29],"new_fold":[16,34,39],"new_item":37,"new_key_value_pair":21,"new_messag":[1,2,6,10,25,27,28,30,34],"new_queri":[22,28,29,34,42,44],"new_sp_sit":37,"new_task":[16,39],"newli":[2,6,10,12,13],"newvalu":14,"next":[22,23,44],"next_link":22,"no_forward":3,"non":22,"none":[1,2,3,4,5,6,7,9,10,11,12,13,14,15,16,17,19,20,21,22,23,25,27,28,34,37,44],"nonempti":13,"nonpersist":32,"nopreview":13,"normal":[3,5,7,14,16,22,44],"not_flag":11,"not_respond":3,"notat":7,"note":[7,12,20,22,23,27,29,34,37,42,44],"notflag":11,"noth":[5,21,23,27],"notic":34,"notif":[15,17,38],"notifi":[10,38],"notification_url":[15,38],"notificationqueryopt":38,"notificationurl":38,"notificationurlappid":38,"notrespond":3,"novemb":23,"now":[7,23,27,30,32,44],"null":[3,7,13,17,38],"number":[2,3,5,6,7,10,12,13,17,44],"number_format":7,"number_fromat":7,"numer":[12,32],"o365":[1,2,3,4,5,6,7,9,10,11,12,13,14,15,16,17,19,20,21,22,23,27,30,32,33,34,36,37,38,40,43],"o365token":21,"oasi":[2,3,6,16],"oauth":[1,5,24,25],"oauth2":[5,23],"oauth_authentication_flow":[0,5,24],"oauth_redirect_url":5,"oauth_request":5,"object":[1,3,4,5,6,7,9,10,11,12,13,14,15,16,17,19,20,21,22,23,24,26,27,28,29,31,34,37,39,42,44],"object_id":[2,3,4,6,7,9,10,11,12,13,14,17,33,37,40],"objetc":44,"observ":7,"obtain":[6,21],"oc":34,"occasion":7,"occur":37,"occurr":3,"odata":[2,3,6,7,12,13,16,20,22,25,36,38],"odd":7,"offic":[2,6,27,28],"office_loc":[2,6],"offlin":[17,23,40],"offline_access":23,"offset":7,"offwork":[17,40],"old":[23,25,27,32],"old_entri":21,"older":7,"oliv":4,"on_attribut":22,"on_cloud":19,"on_disk":19,"on_list_field":22,"on_premises_sam_account_nam":6,"onc":[7,21,23,37],"one":[0,2,3,4,5,7,10,16,20,21,22,23,24,27,30,32,34,35,40,44],"onedr":[1,7,12,23,24,25,26,27,32],"onedrive_al":[23,27,35],"onedrivewellknowfoldernam":[18,22],"oneonon":17,"ongo":43,"onli":[3,5,6,7,11,12,16,17,20,21,22,23,25,27,28,29,30,34,35,36,37,39,40,42,44],"onlin":[3,32],"online_meet":3,"online_meeting_provid":3,"online_meeting_url":3,"onlinemeetinginfo":3,"onlinemeetingprovidertyp":[0,3,24],"only_valu":7,"oof":3,"oop":[34,35],"open":[1,2,3,5,6,7,11,12,13,16,25,27,31],"open_group":22,"oper":[3,7,12,15,20,22,23,29,35],"operationqueryfilt":[18,20],"oppos":29,"opt":21,"optim":44,"option":[3,4,5,7,9,12,13,14,17,19,21,23,27,35,44],"orang":4,"ord":3,"order":[2,3,6,9,10,12,13,14,16,20,22,25],"order_bi":[2,3,6,10,12,14,16,20,22],"order_hint":13,"orderbi":[20,22,42,44],"orderbyfilt":[18,20],"orderhint":[13,36],"org":[2,3,6,16],"organiz":[3,6,9,10,12,25,28,29,31,40],"origin":[12,22],"orphan":19,"os":[2,3,6,16],"ot":7,"other_address":2,"other_mail":6,"other_param":25,"others":12,"otherwis":[1,3,6,11,13,19,21,34,37],"outbox":[10,22],"outbox_fold":10,"outlook":[1,3,4,5,6,10,11,24,26,27,28,38],"outlook_categori":[1,34],"outlookwellknowfoldernam":[11,18,22],"outofoffic":17,"output":12,"outsid":7,"overal":7,"overview":[23,24],"overwrit":12,"overwritten":30,"owa":27,"owner":[3,9,13,30,33,36],"p":34,"packag":[17,23,25],"page":[23,24,27],"pagin":[2,3,6,9,10,12,13,14,15,16,17,18,22,25,26,41],"paradigm":25,"parallel":[21,23],"param":[1,2,3,4,5,6,7,9,11,12,13,14,15,16,17,20,21,22,23,27],"param1":27,"paramet":[1,2,3,4,5,6,7,9,10,11,12,13,14,15,16,17,19,20,21,22,23,27,29,42,43,44],"parent":[1,2,3,4,6,7,9,10,11,12,13,14,15,16,17,19,22,27,28,30],"parent_id":[2,10,12,28,34],"parent_path":12,"pari":29,"pars":[3,11,16],"part":[3,22,42,44],"part2":[2,6,16],"pascalcas":5,"pass":[1,5,7,21,22,23,27,30,37,42,43,44],"password":[5,6,12,23,27,31],"password_polici":6,"password_profil":6,"passwordprofil":6,"past":6,"past_project":6,"paste":[23,27],"patch":[5,7],"path":[11,12,14,19,21,23,34,37,43],"path_to_my_local_fil":35,"path_to_sit":14,"pathlib":23,"pattern":[3,23],"payload":38,"pdf":[12,13],"per":[5,21,30,44],"percent_complet":[13,36],"percentag":[12,13],"percentcomplet":13,"perform":[1,5,6,20,22,23,27,32],"period":23,"permiss":[3,9,12,13,16,21,24,27,28,30,35,37],"permission_typ":12,"persist":[7,32],"person":[1,2,3,20,22,23,28,29,31,35,39,42,44],"personal_not":2,"photo":[0,2,6,12,22,24,31,35],"piec":[6,27],"pip":[23,25],"pixel":12,"place":[6,7,23],"plain":38,"plaintext":17,"plan":[0,6,13,23,24,33,36],"plan_id":13,"plandetail":[0,13,24],"planner":[0,1,24,26],"plannerappliedcategori":13,"plannerassign":[13,36],"platform":[7,23],"player":[28,29],"plug":23,"plus":40,"png":34,"point":[21,23,30],"pointer":35,"polici":[6,9],"pool":4,"port":5,"portal":23,"posit":[7,11],"possibl":[3,7,13,14,16,17,23,27,44],"post":[4,5,7,17,38],"postal":6,"postal_cod":6,"powerpoint":13,"pre":4,"preced":22,"predefin":4,"prefer":[2,5,6,17,40],"preferred_data_loc":6,"preferred_languag":[2,6],"preferred_nam":6,"preferredact":[0,17,24,40],"preferredavail":[0,17,24,40],"prefix":[5,30],"prefix_scop":5,"premis":6,"prepar":[7,11],"prepare_request":7,"prerequisit":24,"presenc":[0,17,23,24,26,27],"presence2":40,"presenceunknown":17,"present":[13,17,21,22,34],"preserv":7,"preset0":4,"preset1":4,"preset10":4,"preset11":4,"preset12":4,"preset13":4,"preset14":4,"preset15":4,"preset16":4,"preset17":4,"preset18":4,"preset19":4,"preset2":4,"preset20":4,"preset21":4,"preset22":4,"preset23":4,"preset24":4,"preset3":4,"preset4":4,"preset5":4,"preset6":4,"preset7":4,"preset8":4,"preset9":4,"press":10,"preview":[11,13],"preview_typ":13,"previewprior":13,"previous":[1,5,21,22,23,33,36],"primari":[2,6],"princip":6,"print":[23,27,28,30,31,34,35,37,38,39,40,42,44],"prioriti":[3,10,11,13],"privat":[3,23],"private_key":23,"private_key_fil":23,"process":[1,5,11],"procotol":23,"profil":[2,6,31],"programm":25,"progress":35,"project":[6,23,25,30],"proper":23,"properti":[1,2,3,5,6,7,10,11,12,13,16,19,20,21,22,23,27,28,31,34,35,40,42,44],"protcol":5,"protect":23,"protocol":[0,1,2,3,4,5,6,7,9,10,11,12,13,14,15,16,17,19,20,21,22,23,24,26,27,29,39,42,44],"protocol_graph":23,"protocol_scope_prefix":5,"protocol_url":5,"provid":[3,4,5,6,12,14,17,20,22,23,27,29,30,37,38,39],"provinc":6,"provis":6,"provisioned_plan":6,"provisionedplan":6,"proxi":[5,26],"proxy_address":6,"proxy_http_on":5,"proxy_password":[5,27],"proxy_port":[5,27],"proxy_serv":[5,27],"proxy_usernam":[5,27],"public":[5,23],"publicclientappl":5,"publish":17,"pull":[25,44],"purg":10,"purpl":4,"purpos":19,"push":[7,17],"put":[5,7],"px":12,"py":[21,23,38],"pypi":24,"python":[21,23,25,27,29,37,38],"python3":21,"q":[20,22,28,34,44],"qualnam":22,"queri":[0,2,3,6,10,12,14,16,18,22,23,24,25,26,28,29,34,41],"querybas":[18,20],"querybuild":[18,20,22],"queryfilt":[18,20],"question":[21,23],"quick":24,"quot":[25,28,30,34,35,42,44],"r1c1":7,"race":[21,23],"rais":[1,2,4,5,22,23,44],"raise_http_error":[5,44],"rang":[0,3,7,13,24,32],"rangeformat":[0,7,24,32],"rangeformatfont":[0,7,24],"raw":[1,5,7,28,29,31,33,34,35,36,37,39,40],"re":[10,23,43],"reach":[22,44],"reaction":17,"read":[1,3,5,6,7,9,11,12,13,16,17,23,27,28,29,31,33,34,35,36,37,38,39,40],"read_on":14,"readbas":[23,27,28,31,40],"readi":5,"readlin":23,"readwrit":[9,13,16,23,27,28,29,31,34,35,36,37,39,40],"realli":43,"realm":27,"reappear":10,"reappli":7,"reapply_filt":7,"reason":29,"rebuild":[23,24],"recalcul":7,"receipt":11,"receiv":[3,7,11,17,38],"recent":12,"recipi":[2,3,6,11,12,18,22,28,35],"recipient_typ":[2,6],"recipienttyp":[0,2,6,11,24],"recommend":5,"recov":10,"recover":10,"recoverableitemsdelet":22,"recoverableitemsdeletions_fold":10,"recruit":29,"rect":7,"rectangular":7,"recur":3,"recurr":[3,29],"recurrence_time_zon":3,"recurrence_typ":3,"recurs":9,"recycl":12,"red":[3,4,34],"redirect":[1,5,6,23,27],"redirect_uri":[1,5,23],"redirect_url":6,"rediret":23,"redisbackend":23,"refer":[5,7,12,13,21,23,37],"reference_count":13,"refresh":[1,5,6,7,10,12,21,23,25],"refresh_fold":10,"refresh_sess":7,"refresh_token":[5,23],"regard":29,"region":[6,7,21],"region_nam":21,"regist":[1,5,6,23],"registr":23,"regular":35,"relat":[1,7,11,21,32],"relationship":[20,22],"remain":[23,34],"remind":[3,10,16],"remind_before_minut":[3,29],"remot":12,"remote_item":12,"remoteitem":12,"remov":[3,7,17,19,20,21,22,43],"remove_data":21,"remove_filt":22,"remove_reserv":21,"remove_sheet_name_from_address":7,"renam":[12,17,39],"render":[17,20],"render_templ":23,"renew":[15,24,26],"renew_subscript":[15,38],"repeat":[3,7,29],"repetit":3,"replac":[1,5,12],"repli":[10,11,17,34,40],"reply_msg":34,"reply_to":11,"reply_to_id":17,"report":[6,31],"repres":[2,3,4,6,7,10,12,13,14,17,27,28,34],"represent":[1,2,3,7,10,11,12,14,16,17],"requeated_scop":23,"request":[1,2,3,5,6,7,10,11,12,14,20,22,23,25,26,27,28,34,35,37,38,41,42],"request_dr":[12,14],"request_kwarg":15,"request_retri":5,"request_token":[1,5,23,27],"requested_scop":[1,5,23,27],"requested_url":23,"requests_delay":[5,12],"requir":[3,5,6,9,11,12,13,14,15,16,23,25,27,28,29,30,38,40],"require_sign_in":12,"reserv":6,"reserved_scop":21,"reset":31,"resolv":7,"resourc":[1,2,3,4,5,6,7,9,10,11,12,13,14,15,16,17,19,20,22,23,24,25,26,35,38],"resourcedata":38,"respond":5,"respons":[3,5,6,7,27,29],"response_request":3,"response_status":3,"response_tim":3,"responsestatus":[0,3,24],"rest":[5,7,11,13,25,34,38],"restor":[12,35],"restrict":[2,3,7,11,16],"restrict_key":[2,3,7,11,16],"result":[1,2,3,6,7,10,12,14,16,20,22,23,27,29,35,42,44],"retri":5,"retriev":[2,3,6,7,9,10,11,12,14,15,16,17,20,21,22,23,27,28,31,32,33,34,35,40,42,43,44],"retriv":4,"return":[1,2,3,4,5,6,7,9,10,11,12,13,14,16,17,19,20,21,22,23,27,28,32,34,35,37,38,42,44],"revert":27,"rewritten":21,"rfc":6,"rfc2822":11,"rgb":3,"right":[7,25],"risk":23,"robert":6,"role":12,"root":[2,10,12,14,17,28,35,37],"root_fold":35,"rout":23,"row":7,"row_count":7,"row_height":7,"row_hidden":7,"row_index":7,"row_offset":7,"rtd":25,"rtype":[6,7,9,12,13,14,16,17],"run":[19,21,23,25],"run_calcul":7,"runtimeerror":[1,2,5,23],"s":[1,2,3,4,5,6,7,10,11,12,14,16,17,19,20,21,22,23,27,28,29,31,34,35,40,44],"s3":[21,23],"said":[42,44],"sale":6,"samaccountnam":6,"save":[1,2,3,5,10,11,14,16,19,21,23,27,28,29,34,35,37,39],"save_as_eml":[11,34],"save_draft":[11,34],"save_messag":[11,30],"save_to_sent_fold":11,"save_token":[5,21,23],"save_upd":[14,37],"schedul":[0,1,3,10,22,24,29,34],"scheduled_end_date_tim":[10,34],"scheduled_enddatetim":[10,34],"scheduled_fold":10,"scheduled_start_date_tim":[10,34],"scheduled_startdatetim":[10,34],"school":[6,9,13,16,31],"scope":[1,5,6,7,12,21,24,26,28,29,31,32,33,34,35,36,37,39,40],"scope_help":5,"scopes_graph":23,"script":[7,23,25],"search":[10,12,14,20,22,24,25,28,35,42,44],"search_sit":14,"search_text":12,"searchfilt":[18,20],"searchfold":22,"searchfolders_fold":10,"season":25,"second":[5,12,38],"secret":[5,21,23,43],"secret_id":21,"secret_nam":21,"section":[2,6,23,28],"secur":[5,23,37],"see":[7,11,15,21,23,25,29,34,37,44],"select":[7,11,20,22,23,25,31,34,35,37,42,44],"selectfilt":[18,20],"self":[1,5,10,21,23,27],"send":[2,3,5,6,10,11,12,17,23,25,27,28,29,30,34,38,39,40,42,44],"send_email":[12,35],"send_messag":[17,40],"send_repli":17,"send_respons":[3,29],"sender":[10,11,34],"sender_email":30,"sensit":[3,22],"sent":[1,11,17,22,38],"sent_fold":[10,34],"sentitem":[10,22],"separ":[7,14],"sequenc":22,"sequenti":12,"seri":3,"serial":[5,21,23],"series_mast":3,"series_master_id":3,"seriesmast":3,"server":[3,5,7,10,11,12,16,19,22,25,27,44],"serverfailur":22,"serverfailures_fold":10,"servic":[5,12,22,23],"service_url":[5,27],"serviceadmin":9,"session":[5,6,7,17,21,23,24,26],"session_id":[7,17],"set":[1,2,3,4,5,6,7,10,11,12,13,14,16,17,19,20,22,23,24,26,29,30,31,32,36,40],"set_automatic_repli":[10,34],"set_base_url":22,"set_bord":7,"set_complet":11,"set_daili":[3,29],"set_disable_repli":[10,34],"set_flag":11,"set_month":3,"set_my_pres":[17,40],"set_my_user_preferred_pres":[17,40],"set_proxi":5,"set_rang":3,"set_week":3,"set_year":3,"setter":[2,3,7,10,11,16,19,22],"setup":[15,24],"sever":[12,35],"sh":25,"share":[3,12,13,23,25,27,28,29,30,34,35],"share_email":12,"share_expiration_d":12,"share_id":12,"share_link":[12,35],"share_password":12,"share_scop":12,"share_typ":[12,35],"share_with_invit":[12,35],"share_with_link":[12,35],"shared_mail":27,"shared_mailbox":30,"shared_mailbox_messag":30,"shared_with":13,"sharepoint":[0,1,7,12,23,24,25,26,27,30,32,34,35],"sharepoint_al":27,"sharepoint_dl":[23,27,37],"sharepointlist":[0,14,24,37],"sharepointlistcolumn":[0,14,24],"sharepointlistitem":[0,14,24],"sheet":7,"sheet1":7,"sheet14":7,"shell":25,"shift":[7,10],"shoould":44,"short":27,"shortcut":10,"shorthand":[5,44],"should_refresh_token":[21,23],"show":[3,7,13,25],"show_a":3,"show_banded_column":7,"show_banded_row":7,"show_filter_button":7,"show_head":7,"show_in_address_list":6,"show_tot":7,"shown":43,"side":7,"side_styl":7,"sign":[6,10,12,23,31],"sign_in_sessions_valid_from":6,"signal":21,"silent":38,"similar":[7,43],"simpl":[25,29],"simpli":27,"sinc":7,"singl":[2,7,10,15,22,27,44],"single_inst":3,"single_value_extended_properti":11,"singleinst":3,"singlevalueextendedproperti":11,"sip":6,"site":[0,1,6,14,23,24,27,30,34,37],"site_collection_id":14,"site_id":14,"site_storag":14,"size":[2,3,6,7,9,10,12,14,16],"size_thershold":12,"skill":6,"skip":7,"skype":10,"skype_for_busi":3,"skype_for_consum":3,"skypeforbusi":3,"skypeforconsum":3,"slash":14,"small":12,"smallest":7,"smash":28,"smtp":[6,9],"snake":22,"snake_cas":[5,22],"soft":10,"sole":19,"solv":23,"someth":[7,19],"sometim":7,"somewher":[21,23],"soon":44,"sort":[14,20,42,44],"sourc":[1,2,3,4,5,6,7,9,10,11,12,13,14,15,16,17,19,20,21,22,34],"sp_list":37,"sp_list_item":37,"sp_site":37,"sp_site_list":37,"sp_site_subsit":37,"sp_sites_subsit":37,"space":[3,7],"spain":29,"special":[7,12,35],"special_fold":12,"specif":[2,3,5,6,7,10,16,17,27,37,42,44],"specifi":[1,2,3,4,5,6,7,9,10,11,12,13,14,16,17,19,21,22,23,28,42,44],"spent":34,"sphinx":25,"squander":34,"src":34,"ssl":5,"stabl":24,"stage":23,"standard":6,"star":16,"start":[3,10,11,13,17,20,22,24,28,29,34],"start_dat":[3,11],"start_date_tim":13,"start_q":29,"start_recur":[3,29],"startswith":[20,22,28,34,42,44],"state":[6,11,12,21,22,23,30,38],"statement":34,"static":[5,7,20],"status":[3,5,10,11,12,16,17,34,35,38,40],"stdout":44,"steel":4,"step":[5,21,23],"stepon":23,"steptwo":23,"still":7,"stop":[12,22,25,30],"storag":[0,1,12,14,21,24,35,43],"store":[1,3,5,7,10,11,12,16,17,19,21,23,28,32,43],"store_flow":23,"store_token":[1,5,23],"store_token_after_refresh":[5,23],"str":[1,2,3,4,5,6,7,9,10,11,12,13,14,15,16,17,19,20,21,22,38],"straight":25,"stream":12,"stream_siz":12,"street":6,"street_address":6,"striker":39,"string":[3,6,7,12,13,14,16,19,21,22,23,29],"strong":34,"structur":12,"style":[5,6,7],"subclass":[21,23],"subject":[3,11,16,17,25,28,29,30,34,39,42,44],"subscript":[0,1,24,26],"subscription_id":[15,38],"subscriptionexpirationdatetim":38,"subscriptionid":38,"subscriptions_exampl":38,"subsit":[14,37],"subsitename1":37,"subsitename2":37,"succeed":[1,23],"succes":23,"success":[1,2,3,5,7,10,11,12,13,16,19,21,23,37],"suffix":12,"sum":7,"summ":7,"summari":17,"super":[23,27,34],"supplement":17,"suppli":[5,22,34],"support":[1,6,7,12,25,28,43],"surnam":[2,6],"survey":14,"sync":10,"synchron":[6,7,10],"syncissu":22,"syncissues_fold":10,"system":[5,11,21,23,43],"sz":13,"t":[1,5,6,7,12,14,21,23,27,29,30,31],"tab":38,"tabl":[0,7,32],"tablecolumn":[0,7,24,32],"tableless":7,"tablerow":[0,7,24,32],"tag":34,"take":[23,35,43],"taken":[5,12],"taken_datetim":12,"target":[12,35],"target_fold":11,"task":[0,1,13,14,23,24,26,27,36],"task_detail":36,"task_id":[13,16],"task_list":39,"taskdetail":[0,13,24],"tasks_al":[23,27,39],"teal":4,"team":[0,1,6,9,24,26],"team_id":17,"teams_for_busi":3,"teamsappdefinit":17,"teamsforbusi":3,"technic":25,"telephon":6,"tell":44,"templat":14,"tenant":[5,6,12,23],"tenant_id":[5,23,30],"tenantid":38,"tentat":3,"tentatively_accept":3,"tentativelyaccept":3,"test":[25,30,36],"text":[3,6,7,11,12,14,16,17,20,22,23,38,40],"textual":17,"theme":[3,25],"themselv":6,"therefor":[11,19,21,23],"thesess":7,"think":34,"thirti":28,"thread":[13,17,21],"three":[3,23],"thrown":7,"thumbnail":12,"thumbprint":23,"time":[1,2,3,5,6,7,10,11,12,13,14,16,17,21,23,27,29,32,34,35,39,43,44],"timedelta":7,"timeout":5,"timestamp":17,"timezon":[3,5,10,20,25,29,39,42,44],"tip":23,"titl":[2,6,13,14,36],"tld":23,"to_al":11,"to_api_cas":5,"to_api_data":[2,3,7,11,16,19],"to_exampl":[25,30,34],"to_fold":[2,10,12],"to_path":[11,12,34,35],"to_recipi":[42,44],"toben":25,"todo":[0,1,16,24,39],"token":[0,1,5,6,12,18,24,25,26,27,38,41],"token_":23,"token_backend":[5,21,23,43],"token_cache_st":21,"token_env_nam":21,"token_expiration_datetim":21,"token_filenam":[21,23,43],"token_is_expir":21,"token_is_long_liv":21,"token_model":21,"token_path":[21,23,43],"tokenbackend":23,"tokencach":21,"tokenexpirederror":[0,5,24],"tokenmodel":21,"top":[5,7,13,23],"topic":[17,23],"tosit":37,"total":[7,22],"total_count":22,"total_items_count":10,"trackerset":[18,22],"transact":[3,21],"transaction_id":3,"transform":[5,20,22],"tri":[1,5,12,21,23,27,30],"trigger":[7,17],"true":[1,3,5,6,7,10,11,12,13,14,19,20,21,22,23,29,32,34,35,37,38,44],"tthe":7,"tunnel":38,"tupl":[1,3,5,12,20,22],"two":[6,7,14,23,32],"txt":[23,30,34],"type":[1,2,3,4,5,6,7,9,10,11,12,13,14,16,17,19,20,21,22,24,30,34,35,36,38],"tzdata":23,"tzinfo":29,"tzlocal":23,"ui":[3,17],"uk":6,"un":11,"unabl":10,"unassign":13,"unauthor":23,"uncheck":16,"uncomplet":16,"undefin":7,"underlin":7,"underlying":19,"understand":30,"unequ":[20,22],"unexpect":5,"uniqu":[2,3,4,6,7,9,10,11,12,13,14,16,17,21,23],"unique_bodi":11,"unit":34,"unknown":[3,7,21,29],"unknownfuturevalu":[17,40],"unless":[6,27],"unlik":29,"unlimit":23,"unmerg":7,"unpack":25,"unread":[10,11],"unread_items_count":10,"unsav":[3,16,29,39],"unsentsentinel":7,"unset":17,"unstabl":23,"unus":[1,4,5],"updat":[2,3,4,5,6,7,10,11,12,13,14,15,16,21,22,29,32,34,36,37,38,39,40],"update_cel":7,"update_color":[4,34],"update_field":[14,37],"update_folder_data":10,"update_folder_nam":[2,10],"update_parent_if_chang":10,"update_profile_photo":[2,6],"update_rol":12,"update_session_auth_head":5,"update_subscript":15,"updated_at":[10,21],"upload":[10,12,29,35],"upload_fil":[12,35],"upload_in_chunk":12,"uploaded_fil":35,"uploadsessionrequest":[18,19],"upn":6,"upon":38,"urgent":13,"urgentinterruptionson":17,"uri":23,"url":[1,2,3,5,6,11,12,13,14,15,16,17,22,23,27,38,44],"url_for":23,"us":[5,6,7,11,13,20,21,22,25,38],"usag":[15,21,24,43],"usage_loc":6,"use":[1,2,3,4,5,6,7,9,10,11,12,13,14,15,16,17,19,20,21,22,23,25,26,28,29,30,32,34,35,37,38,39,42,43,44],"use_default_cas":5,"use_sess":[7,32],"useful":32,"user":[0,1,3,4,5,6,7,9,10,12,13,14,16,17,19,23,24,25,26,28,29,30,33,34,35,36,38,39,40],"user1":27,"user2":27,"user_group":33,"user_id":[9,13,17,23,33],"user_object_id":36,"user_principal_nam":6,"user_provided_scop":5,"user_typ":6,"usernam":[1,5,21,27],"usual":[27,30,35],"utc":[5,42,44],"util":[0,19,20,21,23,24,26,27,34,37],"uv":23,"v1":[5,30,38],"v1_2":38,"v2":11,"v4":[2,3,6,16],"valid":[1,10,13,17,23,38],"validation_token":38,"validationtoken":38,"valu":[2,3,4,5,6,7,10,11,12,13,14,16,17,20,21,22,23,32,38,42,44],"value_typ":7,"valueerror":[1,4,5],"variabl":[21,23],"varieti":43,"vault":23,"ve":[25,30],"veri":[42,44],"verifi":5,"verify_ssl":5,"version":[5,12,17,24,25,27,30,35],"version_id":12,"vertic":7,"vertical_align":7,"veryhidden":7,"via":[12,16,40],"view":[7,11,12,13,17,23,33,38],"visibl":[7,9,10,12,14,15],"visit":23,"voic":6,"voip":6,"w":38,"wait":[5,12,21],"want":[7,12,20,22,23,27,31,40,44],"way":[7,14,23,25,29,32,34,37],"web":[3,5,10,11,23,38],"web_link":[3,11],"web_url":[12,14,17],"webhook":[15,24,26],"webhook_handl":38,"week":[3,10],"well":[11,22,31],"whatev":23,"whenev":[27,44],"whether":[1,3,5,6,7,10,11,14,17,21,22],"whi":[24,27],"whichev":6,"whithin":4,"whitin":7,"whole":3,"width":[7,12],"will":[1,2,4,5,6,7,10,11,12,13,14,17,19,20,21,22,23,25,27,29,30,31,32,34,35,37,38,39,42,44],"window":[23,25],"wish":43,"with_nam":25,"within":[7,11,12,14,16,17,21,22,23,28,34,35,36,38],"without":[5,7,23,28,31,42,44],"women":34,"word":[13,20,22],"work":[1,6,9,10,12,13,16,23,25,27,28,29,30,31,32,33,34,35,36,37,39,40],"work_contacts_fold":28,"workbook":[0,7,24,26],"workbookappl":[0,7,24],"workbookicon":7,"workbooksess":[0,7,24],"workbooktablecolumn":7,"workboook":7,"working_elsewher":3,"workingelsewher":3,"workinghour":10,"worksheet":[0,7,24,32],"worksheet_id":7,"world":[28,34],"worst":34,"worth":7,"wrap":7,"wrap_text":7,"wrapper":[3,11,27],"write":[3,12,27,31,32,34],"ws":32,"x":[3,12],"x64":21,"xlsx":32,"xxx":43,"xyz456":23,"y":13,"yard":28,"yellow":4,"yes":23,"yet":2,"yield":35,"yyyi":12,"zero":7,"zip":17,"zone":10,"zoneinfo":5},"titles":["O365 API","Account","Address Book","Calendar","Category","Connection","Directory","Excel","","Group","Mailbox","Message","One Drive","Planner","Sharepoint","Subscriptions","Tasks","Teams","Utils","Attachment","Query","Token","Utils","Getting Started","Welcome to O365\u2019s documentation!","Overview","Detailed Usage","Account","Address Book","Calendar","Protocols","Directory and Users","Excel","Group","Mailbox","OneDrive","Planner","Sharepoint","Subscriptions","Tasks","Teams","Utils","Query","Token","Utils"],"titleterms":{"account":[1,27],"address":[2,28],"api":[0,27],"attach":19,"authent":[23,27],"avail":32,"basic":23,"book":[2,28],"builder":42,"calendar":[3,29],"categori":[4,34],"chat":40,"choos":25,"class":27,"connect":[5,27],"contact":28,"content":[0,18,24,26,41],"core":25,"creat":38,"delet":38,"detail":26,"develop":[23,25],"differ":[23,27],"directori":[6,31],"doc":25,"document":24,"drive":12,"email":34,"error":44,"exampl":[23,25],"excel":[7,32],"filesystemtokenbackend":43,"folder":[28,34],"get":23,"github":23,"global":28,"group":[9,33],"handl":[27,44],"helper":44,"html":25,"indic":24,"instal":23,"instanc":27,"interfac":23,"item":37,"latest":23,"list":[28,37,38],"mailbox":[10,34],"messag":[11,34],"modular":27,"multi":27,"o365":[0,24,25],"oauth":23,"object":32,"one":12,"onedr":35,"outlook":34,"overview":25,"pagin":44,"permiss":23,"planner":[13,36],"prerequisit":23,"presenc":40,"protocol":30,"proxi":27,"pypi":23,"queri":[20,42,44],"quick":25,"rebuild":25,"renew":38,"request":44,"resourc":[27,30],"s":24,"scope":[23,27],"session":32,"set":[27,34],"setup":23,"sharepoint":[14,37],"stabl":23,"start":23,"storag":23,"subscript":[15,38],"tabl":24,"task":[16,39],"team":[17,40],"token":[21,23,43],"type":23,"usag":[23,26],"use":27,"user":[27,31],"util":[18,22,41,44],"version":23,"webhook":38,"welcom":24,"whi":25,"workbook":32}}) \ No newline at end of file diff --git a/docs/latest/usage.html b/docs/latest/usage.html new file mode 100644 index 00000000..35fa8093 --- /dev/null +++ b/docs/latest/usage.html @@ -0,0 +1,218 @@ + + + + + + + + + Detailed Usage — O365 documentation + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/latest/usage/account.html b/docs/latest/usage/account.html new file mode 100644 index 00000000..088476fd --- /dev/null +++ b/docs/latest/usage/account.html @@ -0,0 +1,417 @@ + + + + + + + + + Account — O365 documentation + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+ +
+
+
+
+ +
+

Account

+
+

Multi-user handling

+

A single Account object can hold more than one user being authenticated. You can authenticate different users and the token backend +will hold each authentication. When using the library you can use the account.username property to get or set the current user. +If username is not provided, the username will be set automatically to the first authentication found in the token backend. Also, +whenever you perform a new call to request_token (manually or through a call to account.authenticate), +the username will be set to the user performing the authentication.

+
account.username = 'user1@domain.com'
+#  issue some calls to retrieve data using the auth of the user1
+account.username = 'user2@domain.com'
+#  now every call will use the auth of the user2
+
+
+

This is only possible in version 2.1. Before 2.1 you had to instantiate one Account for each user. +Account class represents a specific account you would like to connect

+
+
+

Setting your Account Instance

+
+

Connecting to API Account

+
from O365 import Account
+
+account = Account(credentials=('my_client_id', 'my_client_secret'))
+
+
+
+
+

Setting Proxy

+
# Option 1
+account = Account(credentials=('my_client_id', 'my_client_secret'),
+                  proxy_server='myserver.com', proxy_port=8080,
+                  proxy_username='username', proxy_password='password)
+
+# Option 2
+account = Account(credentials=('my_client_id', 'my_client_secret'))
+account.connection.set('myserver.com',8080,'username', 'password')
+
+
+
+
+

Using Different Resource

+
from O365 import Account
+
+account = Account(credentials=('my_client_id', 'my_client_secret'), main_resource='shared_mail@example.com')
+
+
+
+
+

Setting Scopes

+
    +
  • You can set a list of scopes that your like to use, a huge list is available on Microsoft Documentation

  • +
  • We have built a custom list make this scopes easier

    +
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

    Short Scope Name

    Description

    Scopes Included

    basic

    Read User Info

    [‘User.Read’]

    mailbox

    Read your mail

    [‘Mail.Read’]

    mailbox_shared

    Read shared mailbox

    [‘Mail.Read.Shared’]

    mailbox_settings

    Manage mailbox settings

    [‘MailboxSettings.ReadWrite’]

    message_send

    Send from your mailbox

    [‘Mail.Send’]

    message_send_shared

    Send using shared mailbox

    [‘Mail.Send.Shared’]

    message_all

    Full access to your mailbox

    [‘Mail.ReadWrite’, ‘Mail.Send’]

    message_all_shared

    Full access to shared mailbox

    [‘Mail.ReadWrite.Shared’, ‘Mail.Send.Shared’]

    address_book

    Read your Contacts

    [‘Contacts.Read’]

    address_book_shared

    Read shared contacts

    [‘Contacts.Read.Shared’]

    address_book_all

    Read/Write your Contacts

    [‘Contacts.ReadWrite’]

    address_book_all_shared

    Read/Write your Contacts

    [‘Contacts.ReadWrite.Shared’]

    calendar

    Read your Calendars

    [‘Calendars.Read’]

    calendar_shared

    Read shared Calendars

    [‘Calendars.Read.Shared’]

    calendar_all

    Full access to your Calendars

    [‘Calendars.ReadWrite’]

    calendar_shared_all

    Full access to your shared Calendars

    [‘Calendars.ReadWrite.Shared’]

    users

    Read info of all users

    [‘User.ReadBasic.All’]

    onedrive

    Read access to OneDrive

    [‘Files.Read.All’]

    onedrive_all

    Full access to OneDrive

    [‘Files.ReadWrite.All’]

    sharepoint

    Read access to Sharepoint

    [‘Sites.Read.All’]

    sharepoint_all

    Full access to Sharepoint

    [‘Sites.ReadWrite.All’]

    tasks

    Read access to Tasks

    [‘Tasks.Read’]

    tasks_all

    Full access to Tasks

    [‘Tasks.ReadWrite’]

    presence

    Read access to Presence

    [‘Presence.Read’]

    +
    +
  • +
+
# Full permission to your mail
+account = Account(credentials=('my_client_id', 'my_client_secret'),
+                  requested_scopes=['message_all'])
+
+# Why change every time, add all at a time :)
+account = Account(credentials=('my_client_id', 'my_client_secret'),
+                  requested_scopes=['message_all', 'message_all_shared', 'address_book_all',
+                          'address_book_all_shared',
+                          'calendar', 'users', 'onedrive', 'sharepoint_dl'])
+
+
+
+
+
+

Authenticating your Account

+
account = Account(credentials=('my_client_id', 'my_client_secret'))
+account.authenticate()
+
+
+
+

Warning

+

The call to authenticate is only required when you haven’t authenticated before. If you already did the token file would have been saved

+
+

The authenticate() method forces an authentication flow, which prints out a url

+
    +
  1. Open the printed url

  2. +
  3. Give consent(approve) to the application

  4. +
  5. +
    You will be redirected out outlook home page, copy the resulting url
    +

    Note

    +

    If the url is simply https://outlook.office.com/owa/?realm=blahblah, and nothing else after that, then you are currently on new Outlook look, revert to old look and try the authentication flow again

    +
    +
    +
    +
  6. +
  7. Paste the resulting URL into the python console.

  8. +
  9. That’s it, you don’t need this hassle again unless you want to add more scopes than you approved for

  10. +
+
+
+
+

Account Class and Modularity

+

Usually you will only need to work with the Account Class. This is a wrapper around all functionality.

+

But you can also work only with the pieces you want.

+

For example, instead of:

+
from O365 import Account
+
+account = Account(('client_id', 'client_secret'))
+message = account.new_message()
+# ...
+mailbox = account.mailbox()
+# ...
+
+
+

You can work only with the required pieces:

+
from O365 import Connection, MSGraphProtocol
+from O365.message import Message
+from O365.mailbox import MailBox
+
+protocol = MSGraphProtocol()
+requested_scopes = ['...']
+con = Connection(('client_id', 'client_secret'), requested_scopes=requested_scopes)
+
+message = Message(con=con, protocol=protocol)
+# ...
+mailbox = MailBox(con=con, protocol=protocol)
+message2 = Message(parent=mailbox)  # message will inherit the connection and protocol from mailbox when using parent.
+# ...
+
+
+

It’s also easy to implement a custom Class. Just Inherit from ApiComponent, define the endpoints, and use the connection to make requests. If needed also inherit from Protocol to handle different communications aspects with the API server.

+
from O365.utils import ApiComponent
+
+class CustomClass(ApiComponent):
+    _endpoints = {'my_url_key': '/customendpoint'}
+
+    def __init__(self, *, parent=None, con=None, **kwargs):
+        # connection is only needed if you want to communicate with the api provider
+        self.con = parent.con if parent else con
+        protocol = parent.protocol if parent else kwargs.get('protocol')
+        main_resource = parent.main_resource
+
+        super().__init__(protocol=protocol, main_resource=main_resource)
+        # ...
+
+    def do_some_stuff(self):
+
+        # self.build_url just merges the protocol service_url with the endpoint passed as a parameter
+        # to change the service_url implement your own protocol inheriting from Protocol Class
+        url = self.build_url(self._endpoints.get('my_url_key'))
+
+        my_params = {'param1': 'param1'}
+
+        response = self.con.get(url, params=my_params)  # note the use of the connection here.
+
+        # handle response and return to the user...
+
+# the use it as follows:
+from O365 import Connection, MSGraphProtocol
+
+protocol = MSGraphProtocol()  # or maybe a user defined protocol
+con = Connection(('client_id', 'client_secret'), requested_scopes=protocol.get_scopes_for(['...']))
+custom_class = CustomClass(con=con, protocol=protocol)
+
+custom_class.do_some_stuff()
+
+
+
+ + +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/docs/latest/usage/addressbook.html b/docs/latest/usage/addressbook.html new file mode 100644 index 00000000..ef44eb67 --- /dev/null +++ b/docs/latest/usage/addressbook.html @@ -0,0 +1,253 @@ + + + + + + + + + Address Book — O365 documentation + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+ +
+
+
+
+ +
+

Address Book

+

AddressBook groups the functionality of both the Contact Folders and Contacts. Outlook Distribution Groups are not supported (By the Microsoft API’s).

+

These are the scopes needed to work with the AddressBook and Contact classes.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

Raw Scope

Included in Scope Helper

Description

Contacts.Read

address_book

To only read my personal contacts

Contacts.Read.Shared

address_book_shared

To only read another user / shared mailbox contacts

Contacts.ReadWrite

address_book_all

To read and save personal contacts

Contacts.ReadWrite.Shared

address_book_all_shared

To read and save contacts from another user / shared mailbox

User.ReadBasic.All

users

To only read basic properties from users of my organization (User.Read.All requires administrator consent).

+
+

Contact Folders

+

Represents a Folder within your Contacts Section in Office 365. AddressBook class represents the parent folder (it’s a folder itself).

+

You can get any folder in your address book by requesting child folders or filtering by name.

+
address_book = account.address_book()
+
+contacts = address_book.get_contacts(limit=None)  # get all the contacts in the Personal Contacts root folder
+
+work_contacts_folder = address_book.get_folder(folder_name='Work Contacts')  # get a folder with 'Work Contacts' name
+
+message_to_all_contats_in_folder = work_contacts_folder.new_message()  # creates a draft message with all the contacts as recipients
+
+message_to_all_contats_in_folder.subject = 'Hallo!'
+message_to_all_contats_in_folder.body = """
+George Best quote:
+
+If you'd given me the choice of going out and beating four men and smashing a goal in
+from thirty yards against Liverpool or going to bed with Miss World,
+it would have been a difficult choice. Luckily, I had both.
+"""
+message_to_all_contats_in_folder.send()
+
+# querying folders is easy:
+child_folders = address_book.get_folders(25) # get at most 25 child folders
+
+for folder in child_folders:
+    print(folder.name, folder.parent_id)
+
+# creating a contact folder:
+address_book.create_child_folder('new folder')
+
+
+
+
+

Global Address List

+

MS Graph API has no concept such as the Outlook Global Address List. +However you can use the Users API to access all the users within your organization.

+

Without admin consent you can only access a few properties of each user such as name and email and little more. You can search by name or retrieve a contact specifying the complete email.

+
    +
  • Basic Permission needed is Users.ReadBasic.All (limit info)

  • +
  • Full Permission is Users.Read.All but needs admin consent.

  • +
+

To search the Global Address List (Users API):

+
global_address_list = account.directory()
+
+# for backwards compatibility only this also works and returns a Directory object:
+# global_address_list = account.address_book(address_book='gal')
+
+# start a new query:
+builder = global_address_list.new_query()
+query = builder.startswith('display_name', 'George Best')
+
+for user in global_address_list.get_users(query=q):
+    print(user)
+
+
+

To retrieve a contact by their email:

+
contact = global_address_list.get_user('example@example.com')
+Contacts
+
+Everything returned from an AddressBook instance is a Contact instance. Contacts have all the information stored as attributes
+
+Creating a contact from an AddressBook:
+
+new_contact = address_book.new_contact()
+
+new_contact.name = 'George Best'
+new_contact.job_title = 'football player'
+new_contact.emails.add('george@best.com')
+
+new_contact.save()  # saved on the cloud
+
+message = new_contact.new_message()  #  Bonus: send a message to this contact
+
+# ...
+
+new_contact.delete()  # Bonus: deleted from the cloud
+
+
+
+
+ + +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/docs/latest/usage/calendar.html b/docs/latest/usage/calendar.html new file mode 100644 index 00000000..39530b30 --- /dev/null +++ b/docs/latest/usage/calendar.html @@ -0,0 +1,236 @@ + + + + + + + + + Calendar — O365 documentation + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+ +
+
+
+
+ +
+

Calendar

+

The calendar and events functionality is group in a Schedule object.

+

A Schedule instance can list and create calendars. It can also list or create events on the default user calendar. To use other calendars use a Calendar instance.

+

These are the scopes needed to work with the Schedule, Calendar and Event classes.

+ + + + + + + + + + + + + + + + + + + + + + + + + +

Raw Scope

Included in Scope Helper

Description

Calendars.Read

calendar

To only read my personal calendars

Calendars.Read.Shared

calendar_shared

To only read another user / shared mailbox calendars

Calendars.ReadWrite

calendar_all

To read and save personal calendars

Calendars.ReadWrite.Shared

calendar_shared_all

To read and save calendars from another user / shared mailbox

+

Working with the Schedule instance:

+
import datetime as dt
+
+# ...
+schedule = account.schedule()
+
+calendar = schedule.get_default_calendar()
+new_event = calendar.new_event()  # creates a new unsaved event
+new_event.subject = 'Recruit George Best!'
+new_event.location = 'England'
+
+# naive datetimes will automatically be converted to timezone aware datetime
+#  objects using the local timezone detected or the protocol provided timezone
+
+new_event.start = dt.datetime(2019, 9, 5, 19, 45)
+# so new_event.start becomes: datetime.datetime(2018, 9, 5, 19, 45, tzinfo=<DstTzInfo 'Europe/Paris' CEST+2:00:00 DST>)
+
+new_event.recurrence.set_daily(1, end=dt.datetime(2019, 9, 10))
+new_event.remind_before_minutes = 45
+
+new_event.save()
+
+
+

Working with Calendar instances:

+
calendar = schedule.get_calendar(calendar_name='Birthdays')
+
+builder = calendar.new_query()
+calendar.name = 'Football players birthdays'
+calendar.update()
+
+
+start_q = builder.greater_equal('start', dt.datetime(2018, 5, 20))
+end_q = builder.less_equal('start', dt.datetime(2018, 5, 24))
+
+birthdays = calendar.get_events(
+    include_recurring=True, # include_recurring=True will include repeated events on the result set.
+    start_recurring=start_q,
+    end_recurring=end_q,
+    )
+
+for event in birthdays:
+    if event.subject == 'George Best Birthday':
+        # He died in 2005... but we celebrate anyway!
+        event.accept("I'll attend!")  # send a response accepting
+    else:
+        event.decline("No way I'm coming, I'll be in Spain", send_response=False)  # decline the event but don't send a response to the organizer
+
+
+

Notes regarding Calendars and Events:

+
    +
  1. Include_recurring=True:

    +
    +

    It’s important to know that when querying events with include_recurring=True (which is the default), +it is required that you must provide start and end parameters, these may be simple date strings, python dates or individual queries. +Unlike when using include_recurring=False those attributes will NOT filter the data based on the operations you set on the query (greater_equal, less, etc.) +but just filter the events start datetime between the provided start and end datetimes.

    +
    +
  2. +
  3. Shared Calendars:

    +
    +

    There are some known issues when working with shared calendars in Microsoft Graph.

    +
    +
  4. +
  5. Event attachments:

    +
    +

    For some unknown reason, Microsoft does not allow to upload an attachment at the event creation time (as opposed with message attachments). +See this. So, to upload attachments to Events, first save the event, then attach the message and save again.

    +
    +
  6. +
+
+ + +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/docs/latest/usage/connection.html b/docs/latest/usage/connection.html new file mode 100644 index 00000000..d475cca2 --- /dev/null +++ b/docs/latest/usage/connection.html @@ -0,0 +1,214 @@ + + + + + + + + + Protocols — O365 documentation + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+ +
+
+
+
+ +
+

Protocols

+

Protocols handles the aspects of communications between different APIs. This project uses the Microsoft Graph APIs. But, you can use many other Microsoft APIs as long as you implement the protocol needed.

+

You can use:

+ +
from O365 import Account
+
+credentials = ('client_id', 'client_secret')
+
+account = Account(credentials, auth_flow_type='credentials', tenant_id='my_tenant_id')
+if account.authenticate():
+print('Authenticated!')
+mailbox = account.mailbox('sender_email@my_domain.com')
+m = mailbox.new_message()
+m.to.add('to_example@example.com')
+m.subject = 'Testing!'
+m.body = "George Best quote: I've stopped drinking, but only while I'm asleep."
+m.save_message()
+m.attachment.add = 'filename.txt'
+m.send()
+
+
+

The default protocol used by the Account Class is MSGraphProtocol.

+

You can implement your own protocols by inheriting from Protocol to communicate with other Microsoft APIs.

+

You can instantiate and use protocols like this:

+
from O365 import Account, MSGraphProtocol  # same as from O365.connection import MSGraphProtocol
+
+# ...
+
+# try the api version beta of the Microsoft Graph endpoint.
+protocol = MSGraphProtocol(api_version='beta')  # MSGraphProtocol defaults to v1.0 api version
+account = Account(credentials, protocol=protocol)
+
+
+
+
+

Resources

+

Each API endpoint requires a resource. This usually defines the owner of the data. Every protocol defaults to resource ‘ME’. ‘ME’ is the user which has given consent, but you can change this behaviour by providing a different default resource to the protocol constructor.

+
+

Note

+

When using the “with your own identity” authentication method the resource ‘ME’ is overwritten to be blank as the authentication method already states that you are login with your own identity.

+
+

For example when accessing a shared mailbox:

+
# ...
+account = Account(credentials=my_credentials, main_resource='shared_mailbox@example.com')
+# Any instance created using account will inherit the resource defined for account.
+
+
+

This can be done however at any point. For example at the protocol level:

+
# ...
+protocol = MSGraphProtocol(default_resource='shared_mailbox@example.com')
+
+account = Account(credentials=my_credentials, protocol=protocol)
+
+# now account is accessing the shared_mailbox@example.com in every api call.
+shared_mailbox_messages = account.mailbox().get_messages()
+
+
+

Instead of defining the resource used at the account or protocol level, you can provide it per use case as follows:

+
# ...
+account = Account(credentials=my_credentials)  # account defaults to 'ME' resource
+
+mailbox = account.mailbox('shared_mailbox@example.com')  # mailbox is using 'shared_mailbox@example.com' resource instead of 'ME'
+
+# or:
+
+message = Message(parent=account, main_resource='shared_mailbox@example.com')  # message is using 'shared_mailbox@example.com' resource
+
+
+

Usually you will work with the default ‘ME’ resource, but you can also use one of the following:

+
    +
  • ‘me’: the user which has given consent. The default for every protocol. Overwritten when using “with your own identity” authentication method (Only available on the authorization auth_flow_type).

  • +
  • ‘user:user@domain.com’: a shared mailbox or a user account for which you have permissions. If you don’t provide ‘user:’ it will be inferred anyway.

  • +
  • ‘site:sharepoint-site-id’: a Sharepoint site id.

  • +
  • ‘group:group-site-id’: an Microsoft 365 group id.

  • +
+

By setting the resource prefix (such as ‘user:’ or ‘group:’) you help the library understand the type of resource. You can also pass it like ‘users/example@exampl.com’. The same applies to the other resource prefixes.

+
+ + +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/docs/latest/usage/directory.html b/docs/latest/usage/directory.html new file mode 100644 index 00000000..ca5164d4 --- /dev/null +++ b/docs/latest/usage/directory.html @@ -0,0 +1,181 @@ + + + + + + + + + Directory and Users — O365 documentation + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+ +
+
+
+
+ +
+

Directory and Users

+

The Directory object can retrieve users.

+

A User instance contains by default the basic properties of the user. If you want to include more, you will have to select the desired properties manually.

+

Check Global Address List for further information.

+

These are the scopes needed to work with the Directory class.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

Raw Scope

Included in Scope Helper

Description

User.ReadBasic.All

users

To read a basic set of profile properties of other users in your organization on behalf of the signed-in user. This includes display name, first and last name, email address, open extensions and photo. Also allows the app to read the full profile of the signed-in user.

User.Read.All

To read the full set of profile properties, reports, and managers of other users in your organization, on behalf of the signed-in user.

User.ReadWrite.All

To read and write the full set of profile properties, reports, and managers of other users in your organization, on behalf of the signed-in user. Also allows the app to create and delete users as well as reset user passwords on behalf of the signed-in user.

Directory.Read.All

To read data in your organization’s directory, such as users, groups and apps, without a signed-in user.

Directory.ReadWrite.All

To read and write data in your organization’s directory, such as users, and groups, without a signed-in user. Does not allow user or group deletion.

+
+

Note

+

To get authorized with the above scopes you need a work or school account, it doesn’t work with personal account.

+
+

Working with the Directory instance to read the active directory users:

+
directory = account.directory()
+for user in directory.get_users():
+    print(user)
+
+
+
+ + +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/docs/latest/usage/excel.html b/docs/latest/usage/excel.html new file mode 100644 index 00000000..ddfcfc0d --- /dev/null +++ b/docs/latest/usage/excel.html @@ -0,0 +1,195 @@ + + + + + + + + + Excel — O365 documentation + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+ +
+
+
+
+ +
+

Excel

+

You can interact with new Excel files (.xlsx) stored in OneDrive or a SharePoint Document Library. You can retrieve workbooks, worksheets, tables, and even cell data. You can also write to any excel online.

+

To work with Excel files, first you have to retrieve a File instance using the OneDrive or SharePoint functionality.

+

The scopes needed to work with the WorkBook and Excel related classes are the same used by OneDrive.

+

This is how you update a cell value:

+
from O365.excel import WorkBook
+
+# given a File instance that is a xlsx file ...
+excel_file = WorkBook(my_file_instance)  # my_file_instance should be an instance of File.
+
+ws = excel_file.get_worksheet('my_worksheet')
+cella1 = ws.get_range('A1')
+cella1.values = 35
+cella1.update()
+
+
+
+

Workbook Sessions

+

When interacting with Excel, you can use a workbook session to efficiently make changes in a persistent or nonpersistent way. These sessions become usefull if you perform numerous changes to the Excel file.

+

The default is to use a session in a persistent way. Sessions expire after some time of inactivity. When working with persistent sessions, new sessions will automatically be created when old ones expire.

+

You can however change this when creating the Workbook instance:

+
excel_file = WorkBook(my_file_instance, use_session=False, persist=False)
+
+
+
+
+

Available Objects

+

After creating the WorkBook instance you will have access to the following objects:

+
    +
  • WorkSheet

  • +
  • Range and NamedRange

  • +
  • Table, TableColumn and TableRow

  • +
  • RangeFormat (to format ranges)

  • +
  • Charts (not available for now)

  • +
+

Some examples:

+

Set format for a given range

+
# ...
+my_range = ws.get_range('B2:C10')
+fmt = myrange.get_format()
+fmt.font.bold = True
+fmt.update()
+
+
+

Autofit Columns:

+
ws.get_range('B2:C10').get_format().auto_fit_columns()
+
+
+

Get values from Table:

+
table = ws.get_table('my_table')
+column = table.get_column_at_index(1)
+values = column.values[0]  # values returns a two-dimensional array.
+
+
+
+
+ + +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/docs/latest/usage/group.html b/docs/latest/usage/group.html new file mode 100644 index 00000000..7985df2c --- /dev/null +++ b/docs/latest/usage/group.html @@ -0,0 +1,175 @@ + + + + + + + + + Group — O365 documentation + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+ +
+
+
+
+ +
+

Group

+

Groups enables viewing of groups

+

These are the scopes needed to work with the Group classes.

+ + + + + + + + + + + + + +

Raw Scope

Included in Scope Helper

Description

Group.Read.All

To read groups

+

Assuming an authenticated account and a previously created group, create a Plan instance.

+
#Create a plan instance
+from O365 import Account
+account = Account(('app_id', 'app_pw'))
+groups = account.groups()
+
+# To retrieve the list of groups
+group_list = groups.list_groups()
+
+# Or to retrieve a list of groups for a given user
+user_groups = groups.get_user_groups(user_id="object_id")
+
+# To retrieve a group by an identifier
+group = groups.get_group_by_id(group_id="object_id")
+group = groups.get_group_by_mail(group_mail="john@doe.com")
+
+
+# To retrieve the owners and members of a group
+owners = group.get_group_owners()
+members = group.get_group_members()
+
+
+
+ + +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/docs/latest/usage/mailbox.html b/docs/latest/usage/mailbox.html new file mode 100644 index 00000000..3f325cd2 --- /dev/null +++ b/docs/latest/usage/mailbox.html @@ -0,0 +1,366 @@ + + + + + + + + + Mailbox — O365 documentation + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+ +
+
+
+
+ +
+

Mailbox

+

Mailbox groups the functionality of both the messages and the email folders.

+

These are the scopes needed to work with the MailBox and Message classes.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

Raw Scope

Included in Scope Helper

Description

Mail.Read

mailbox

To only read my mailbox

Mail.Read.Shared

mailbox_shared

To only read another user / shared mailboxes

Mail.Send

message_send, message_all

To only send message

Mail.Send.Shared

message_send_shared, message_all_shared

To only send message as another user / shared mailbox

Mail.ReadWrite

message_all

To read and save messages in my mailbox

MailboxSettings.ReadWrite

mailbox_settings

To read and write user mailbox settings

+
+

Mailbox and Messages

+
mailbox = account.mailbox()
+
+inbox = mailbox.inbox_folder()
+
+for message in inbox.get_messages():
+    print(message)
+
+sent_folder = mailbox.sent_folder()
+
+for message in sent_folder.get_messages():
+    print(message)
+
+m = mailbox.new_message()
+
+m.to.add('to_example@example.com')
+m.body = 'George Best quote: In 1969 I gave up women and alcohol - it was the worst 20 minutes of my life.'
+m.save_draft()
+
+
+
+
+

Email Folder

+

Represents a Folder within your email mailbox.

+

You can get any folder in your mailbox by requesting child folders or filtering by name.

+
mailbox = account.mailbox()
+
+archive = mailbox.get_folder(folder_name='archive')  # get a folder with 'archive' name
+
+child_folders = archive.get_folders(25) # get at most 25 child folders of 'archive' folder
+
+for folder in child_folders:
+    print(folder.name, folder.parent_id)
+
+new_folder = archive.create_child_folder('George Best Quotes')
+
+
+
+
+

Message

+

An email object with all its data and methods

+

Creating a draft message is as easy as this:

+
message = mailbox.new_message()
+message.to.add(['example1@example.com', 'example2@example.com'])
+message.sender.address = 'my_shared_account@example.com'  # changing the from address
+message.body = 'George Best quote: I might go to Alcoholics Anonymous, but I think it would be difficult for me to remain anonymous'
+message.attachments.add('george_best_quotes.txt')
+message.save_draft()  # save the message on the cloud as a draft in the drafts folder
+
+
+

Working with saved emails is also easy

+
builder = mailbox.new_query()
+query = builder.chain_or(builder.contains('subject', 'george best'), builder.startswith('subject', 'quotes')  # see Query object in Utils
+messages = mailbox.get_messages(limit=25, query=query)
+
+message = messages[0]  # get the first one
+
+message.mark_as_read()
+reply_msg = message.reply()
+
+if 'example@example.com' in reply_msg.to:  # magic methods implemented
+    reply_msg.body = 'George Best quote: I spent a lot of money on booze, birds and fast cars. The rest I just squandered.'
+else:
+    reply_msg.body = 'George Best quote: I used to go missing a lot... Miss Canada, Miss United Kingdom, Miss World.'
+
+reply_msg.send()
+
+
+

Sending Inline Images

+

You can send inline images by doing this:

+
# ...
+msg = account.new_message()
+msg.to.add('george@best.com')
+msg.attachments.add('my_image.png')
+att = msg.attachments[0]  # get the attachment object
+
+# this is super important for this to work.
+att.is_inline = True
+att.content_id = 'image.png'
+
+# notice we insert an image tag with source to: "cid:{content_id}"
+body = """
+    <html>
+        <body>
+            <strong>There should be an image here:</strong>
+            <p>
+                <img src="cid:image.png">
+            </p>
+        </body>
+    </html>
+    """
+msg.body = body
+msg.send()
+
+
+

Retrieving Message Headers

+

You can retrieve message headers by doing this:

+
# ...
+mb = account.mailbox()
+msg = mb.get_message(query=mb.q().select('internet_message_headers'))
+print(msg.message_headers)  # returns a list of dicts.
+
+
+

Note that only message headers and other properties added to the select statement will be present.

+

Saving as EML

+

Messages and attached messages can be saved as *.eml.

+

Save message as “eml”:

+
msg.save_as_eml(to_path=Path('my_saved_email.eml'))
+
+
+

Save attached message as “eml”

+

Careful: there’s no way to identify that an attachment is in fact a message. You can only check if the attachment.attachment_type == ‘item’. if is of type “item” then it can be a message (or an event, etc…). You will have to determine this yourself.

+
msg_attachment = msg.attachments[0]  # the first attachment is attachment.attachment_type == 'item' and I know it's a message.
+msg.attachments.save_as_eml(msg_attachment, to_path=Path('my_saved_email.eml'))
+
+
+
+
+

Mailbox Settings

+

The mailbox settings and associated methods.

+

Retrieve and update mailbox auto reply settings:

+
from O365.mailbox import AutoReplyStatus, ExternalAudience
+
+mailboxsettings = mailbox.get_settings()
+ars = mailboxsettings.automaticrepliessettings
+
+ars.scheduled_startdatetime = start # Sets the start date/time
+ars.scheduled_enddatetime = end # Sets the end date/time
+ars.status = AutoReplyStatus.SCHEDULED # DISABLED/SCHEDULED/ALWAYSENABLED - Uses start/end date/time if scheduled.
+ars.external_audience = ExternalAudience.NONE # NONE/CONTACTSONLY/ALL
+ars.internal_reply_message = "ARS Internal" # Internal message
+ars.external_reply_message = "ARS External" # External message
+mailboxsettings.save()
+Alternatively to enable and disable
+
+mailboxsettings.save()
+
+mailbox.set_automatic_reply(
+    "Internal",
+    "External",
+    scheduled_start_date_time=start, # Status will be 'scheduled' if start/end supplied, otherwise 'alwaysEnabled'
+    scheduled_end_date_time=end,
+    externalAudience=ExternalAudience.NONE, # Defaults to ALL
+)
+mailbox.set_disable_reply()
+
+
+
+
+

Outlook Categories

+

You can retrieve, update, create and delete outlook categories. These categories can be used to categorize Messages, Events and Contacts.

+

These are the scopes needed to work with the SharePoint and Site classes.

+ + + + + + + + + + + + + + + + + +

Raw Scope

Included in Scope Helper

Description

MailboxSettings.Read

To only read outlook settings

MailboxSettings.ReadWrite

mailbox_settings

To read and write outlook settings

+

Example:

+
from O365.category import CategoryColor
+
+oc = account.outlook_categories()
+categories = oc.get_categories()
+for category in categories:
+    print(category.name, category.color)
+
+my_category = oc.create_category('Important Category', color=CategoryColor.RED)
+my_category.update_color(CategoryColor.DARKGREEN)
+
+my_category.delete()  # oops!
+
+
+
+
+ + +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/docs/latest/usage/onedrive.html b/docs/latest/usage/onedrive.html new file mode 100644 index 00000000..d76a9beb --- /dev/null +++ b/docs/latest/usage/onedrive.html @@ -0,0 +1,250 @@ + + + + + + + + + OneDrive — O365 documentation + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+ +
+
+
+
+ +
+

OneDrive

+

The Storage class handles all functionality around One Drive and Document Library Storage in SharePoint.

+

The Storage instance allows retrieval of Drive instances which handles all the Files +and Folders from within the selected Storage. Usually you will only need to work with the +default drive. But the Storage instances can handle multiple drives.

+

A Drive will allow you to work with Folders and Files.

+

These are the scopes needed to work with the Storage, Drive and DriveItem classes.

+ + + + + + + + + + + + + + + + + + + + + + + + + +

Raw Scope

Included in Scope Helper

Description

Files.Read

To only read my files

Files.Read.All

onedrive

To only read all the files the user has access

Files.ReadWrite

To read and save my files

Files.ReadWrite.All

onedrive_all

To read and save all the files the user has access

+
account = Account(credentials=my_credentials)
+
+storage = account.storage()  # here we get the storage instance that handles all the storage options.
+
+# list all the drives:
+drives = storage.get_drives()
+
+# get the default drive
+my_drive = storage.get_default_drive()  # or get_drive('drive-id')
+
+# get some folders:
+root_folder = my_drive.get_root_folder()
+attachments_folder = my_drive.get_special_folder('attachments')
+
+# iterate over the first 25 items on the root folder
+for item in root_folder.get_items(limit=25):
+    if item.is_folder:
+        print(list(item.get_items(2)))  # print the first to element on this folder.
+    elif item.is_file:
+        if item.is_photo:
+            print(item.camera_model)  # print some metadata of this photo
+        elif item.is_image:
+            print(item.dimensions)  # print the image dimensions
+        else:
+            # regular file:
+            print(item.mime_type)  # print the mime type
+
+
+

Both Files and Folders are DriveItems. Both Image and Photo are Files, but Photo is also an Image. All have some different methods and properties. Take care when using ‘is_xxxx’.

+

When copying 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.

+
# copy a file to the documents special folder
+
+documents_folder = my_drive.get_special_folder('documents')
+
+files = my_drive.search('george best quotes', limit=1)
+
+if files:
+    george_best_quotes = files[0]
+    operation = george_best_quotes.copy(target=documents_folder)  # operation here is an instance of CopyOperation
+
+    # to check for the result just loop over check_status.
+    # check_status is a generator that will yield a new status and progress until the file is finally copied
+    for status, progress in operation.check_status():  # if it's an async operations, this will request to the api for the status in every loop
+        print(f"{status} - {progress}")  # prints 'in progress - 77.3' until finally completed: 'completed - 100.0'
+    copied_item = operation.get_item()  # the copy operation is completed so you can get the item.
+    if copied_item:
+        copied_item.delete()  # ... oops!
+
+
+

You can also work with share permissions:

+
current_permisions = file.get_permissions()  # get all the current permissions on this drive_item (some may be inherited)
+
+# share with link
+permission = file.share_with_link(share_type='edit')
+if permission:
+    print(permission.share_link)  # the link you can use to share this drive item
+# share with invite
+permission = file.share_with_invite(recipients='george_best@best.com', send_email=True, message='Greetings!!', share_type='edit')
+if permission:
+    print(permission.granted_to)  # the person you share this item with
+
+
+

You can also:

+
# download files:
+file.download(to_path='/quotes/')
+
+# upload files:
+
+# if the uploaded file is bigger than 4MB the file will be uploaded in chunks of 5 MB until completed.
+# this can take several requests and can be time consuming.
+uploaded_file = folder.upload_file(item='path_to_my_local_file')
+
+# restore versions:
+versions = file.get_versions()
+for version in versions:
+    if version.name == '2.0':
+        version.restore()  # restore the version 2.0 of this file
+
+# ... and much more ...
+
+
+
+ + +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/docs/latest/usage/planner.html b/docs/latest/usage/planner.html new file mode 100644 index 00000000..25977990 --- /dev/null +++ b/docs/latest/usage/planner.html @@ -0,0 +1,194 @@ + + + + + + + + + Planner — O365 documentation + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+ +
+
+
+
+ +
+

Planner

+

Planner enables the creation and maintenance of plans, buckets and tasks

+

These are the scopes needed to work with the Planner classes.

+ + + + + + + + + + + + + + + + + +

Raw Scope

Included in Scope Helper

Description

Group.Read.All

To only read plans

Group.ReadWrite.All

To create and maintain a plan

+

Assuming an authenticated account and a previously created group, create a Plan instance.

+
#Create a plan instance
+from O365 import Account
+account = Account(('app_id', 'app_pw'))
+planner = account.planner()
+plan = planner.create_plan(
+    owner="group_object_id", title="Test Plan"
+)
+
+
+
+
Common commands for planner include .create_plan(), .get_bucket_by_id(), .get_my_tasks(), .list_group_plans(), .list_group_tasks() and .delete().
+
Common commands for plan include .create_bucket(), .get_details(), .list_buckets(), .list_tasks() and .delete().
+
+

Then to create a bucket within a plan.

+
#Create a bucket instance in a plan
+bucket = plan.create_bucket(name="Test Bucket")
+
+
+

Common commands for bucket include .list_tasks() and .delete().

+

Then to create a task, assign it to a user, set it to 50% completed and add a description.

+
#Create a task in a bucket
+assignments = {
+    "user_object_id: {
+        "@odata.type": "microsoft.graph.plannerAssignment",
+        "orderHint": "1 !",
+    }
+}
+task = bucket.create_task(title="Test Task", assignments=assignments)
+
+task.update(percent_complete=50)
+
+task_details = task.get_details()
+task_details.update(description="Test Description")
+
+
+

Common commands for task include .get_details(), .update() and .delete().

+
+ + +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/docs/latest/usage/query.html b/docs/latest/usage/query.html new file mode 100644 index 00000000..ab89493d --- /dev/null +++ b/docs/latest/usage/query.html @@ -0,0 +1,127 @@ + + + + + + + + + Query — O365 documentation + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+ +
+
+
+
+ +
+

Query

+

Query class helps with creating filters for the results (It can be either filtering event or email messages or any function that accepts query attribute)

+
+ + +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/docs/latest/usage/sharepoint.html b/docs/latest/usage/sharepoint.html new file mode 100644 index 00000000..7f259051 --- /dev/null +++ b/docs/latest/usage/sharepoint.html @@ -0,0 +1,248 @@ + + + + + + + + + Sharepoint — O365 documentation + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+ +
+
+
+
+ +
+

Sharepoint

+

These are the scopes needed to work with the SharePoint and Site classes.

+ + + + + + + + + + + + + + + + + + + + + +

Raw Scope

Included in Scope Helper

Description

Sites.Selected

None

Sites a permission was granted by admins (see https://github.com/O365/python-o365/issues/1122)

Sites.Read.All

sharepoint

To only read sites, lists and items

Sites.ReadWrite.All

sharepoint_dl

To read and save sites, lists and items

+

Note that using the .All scopes is way less secure than granting permissions to specific sites and using +Sites.Selected scope.

+

Assuming an authenticated account, create a Sharepoint instance, and connect +to a Sharepoint site.

+
#Create Sharepoint instance and connect to a site
+from O365 import Account
+acct = Account(('app_id', 'app_pw'))
+sp_site = acct.sharepoint().get_site('root', 'path/tosite')
+
+
+

Common commands for sp_site include .display_name, +.get_document_library(), .get_subsites(), .get_lists(), +and .get_list_by_name('list_name').

+

Accessing Subsites

+

If a Sharepoint site contains subsites they can be returned as a list of +Sharepoint sites by the .get_subsites() function.

+
#Return a List of subsites
+sp_site_subsites = sp_site.get_subsites()
+print(sp_sites_subsites)
+[Site: subsitename1, Site: subsitename2]
+
+#Make another Site object from a desired subsite
+new_sp_site = sp_site_subsites[0] #return the first subsite
+
+
+
+

Sharepoint Lists

+

Sharepoint Lists are accessible from their Sharepoint site using .get_lists() which +returns a Python list of Sharepoint list objects. A known list can be accessed +by providing a list_name to .get_list_by_name('list_name') which will return +the requested list as a sharepointlist object.

+
#Return a list of sharepoint lists
+sp_site_lists = sp_site.get_lists()
+
+#Return a specific list by name
+sp_list = sp_site.get_list_by_name('list_name')
+
+
+

Commmon functions on a Sharepoint list include .get_list_columns(), +.get_items(), .get_item_by_id(), .create_list_item(), +.delete_list_item().

+
+

Sharepoint List Items

+

Accessing a list item from a Sharepoint list is done by utilizing .get_items(), +or .get_item_by_id(item_id).

+
#Return a list of sharepoint list Items
+sp_list_items = sp_list.get_items()
+
+#Return a specific sharepoint list item by its object ID
+sp_list_item = sp_list.get_item_by_id(item_id)
+
+
+

Creating & Deleting Sharepoint Items

+

A Sharepoint list item can be created by passing the new data in a dictionary +consisting of {'column_name': 'new_data'}. Not all columns in the Sharepoint list have to +be accounted for in the dictionary, any Sharepoint List column not in the dictionary +will be filled with a blank. The column_name must be the internal column name +of the sharepoint list. .column_name_cw of a sharepoint list will provide a +dictionary of {'Display Name': 'Internal Name'} if needed.

+
#Create a new sharepoint list item
+new_item = sp_list.create_list_item({'col1': 'New Data Col 1',
+                                     'col2': 'New Data Col 2'})
+
+#Delete the item just created
+sp_list.delete_list_item(new_item.object_id)  #Pass the item ID to be deleted
+
+
+

Updating a Sharepoint List Item

+

Sharepoint list items can be updated by passing a dictionary of +{'column_name': 'Updated Data'} to the .update_fields() function of a +Sharepoint list item. The column_name keys of the dictionary must again refer +to the internal column name, otherwise an error will occur.

+
#Update a Sharepoint List item
+new_item.update_fields({'col1': 'Updated Data Col1',
+                        'col2': 'Updated Data Col2'})
+
+#Once done updating a sharepoint item save changes to the cloud
+new_item.save_updates() #Returns True if successful
+
+
+
+
+
+ + +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/docs/latest/usage/subscriptions.html b/docs/latest/usage/subscriptions.html new file mode 100644 index 00000000..d102b3e1 --- /dev/null +++ b/docs/latest/usage/subscriptions.html @@ -0,0 +1,344 @@ + + + + + + + + + Subscriptions — O365 documentation + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+ +
+
+
+
+ +
+

Subscriptions

+

Subscriptions provides the ability to create and manage webhook subscriptions for change notifications against Microsoft Graph. Read here for more details on MS Graph subscriptions

+ +
+

Create a Subscription

+

Assuming a web host (example uses flask) and an authenticated account, create a subscription to be notified about new emails.

+
from flask import Flask, abort, jsonify, request
+
+RESOURCE = "/me/mailFolders('inbox')/messages"
+DEFAULT_EXPIRATION_MINUTES = 10069  # Maximum expiration is 10,070 in the future for Outlook message.
+
+app = Flask(__name__)
+
+@app.get("/subscriptions")
+def create_subscription():
+    """Create a subscription."""
+    notification_url = request.args.get("notification_url")
+    if not notification_url:
+        abort(400, description="notification_url is required")
+
+    expiration_minutes = int(request.args.get("expiration_minutes", DEFAULT_EXPIRATION_MINUTES))
+    client_state = request.args.get("client_state")
+    resource = request.args.get("resource", RESOURCE)
+
+    subscription = account.subscriptions().create_subscription(
+        notification_url=notification_url,
+        resource=resource,
+        change_type="created",
+        expiration_minutes=expiration_minutes,
+        client_state=client_state,
+    )
+    return jsonify(subscription), 201
+
+@app.post("/webhook")
+def webhook_handler():
+    """Handle Microsoft Graph webhook calls.
+
+    - During subscription validation, Graph sends POST with ?validationToken=... .
+    We must echo the token as plain text within 10 seconds.
+    - For change notifications, Graph posts JSON; we just log/ack.
+    """
+    validation_token = request.args.get("validationToken")
+    if validation_token:
+        # Echo back token exactly as plain text with HTTP 200.
+        return validation_token, 200, {"Content-Type": "text/plain"}
+
+    # Change notifications: inspect or log as needed.
+    payload = request.get_json(silent=True) or {}
+    print("Received notification payload:", payload)
+    return ("", 202)
+
+
+

Use this url:

+
+

https://<your-tunnel-host>/subscriptions?notification_url=https%3A%2F%2F<your-tunnel-host>%2Fwebhook&client_state=abc123

+
+

HTTP status 201 and the following should be returned:

+
{
+    "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#subscriptions/$entity",
+    "applicationId": "12345678-bad9-4c34-94d6-f9a1388522f8",
+    "changeType": "created",
+    "clientState": "abc123",
+    "creatorId": "12345678-a5c7-46da-8107-b25090a1ed66",
+    "encryptionCertificate": null,
+    "encryptionCertificateId": null,
+    "expirationDateTime": "2026-01-07T11:20:42.305776Z",
+    "id": "548355f8-c2c0-47ae-aac7-3ad02b2dfdb1",
+    "includeResourceData": null,
+    "latestSupportedTlsVersion": "v1_2",
+    "lifecycleNotificationUrl": null,
+    "notificationQueryOptions": null,
+    "notificationUrl": "https://<your-tunnel-host>/webhook",
+    "notificationUrlAppId": null,
+    "resource": "/me/mailFolders('inbox')/messages"
+}
+
+
+
+
+

List Subscriptions

+
@app.get("/subscriptions/list")
+def list_subscriptions():
+    """List all subscriptions."""
+    limit = int(request.args.get("limit"))
+    subscriptions = account.subscriptions().list_subscriptions(limit=limit)
+    return jsonify(list(subscriptions)), 200
+
+
+

Use this url:

+
+

https://<your-tunnel-host>/subscriptions/list

+
+

HTTP status 200 and the following should be returned:

+
[
+    {
+        "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#subscriptions/$entity",
+        "applicationId": "12345678-bad9-4c34-94d6-f9a1388522f8",
+        "changeType": "created",
+        "clientState": "abc123",
+        "creatorId": "12345678-a5c7-46da-8107-b25090a1ed66",
+        "encryptionCertificate": null,
+        "encryptionCertificateId": null,
+        "expirationDateTime": "2026-01-07T11:20:42.305776Z",
+        "id": "548355f8-c2c0-47ae-aac7-3ad02b2dfdb1",
+        "includeResourceData": null,
+        "latestSupportedTlsVersion": "v1_2",
+        "lifecycleNotificationUrl": null,
+        "notificationQueryOptions": null,
+        "notificationUrl": "https://<your-tunnel-host>/webhook",
+        "notificationUrlAppId": null,
+        "resource": "/me/mailFolders('inbox')/messages"
+    }
+]
+
+
+
+
+

Renew a Subscription

+
@app.get("/subscriptions/<subscription_id>/renew")
+def renew_subscription(subscription_id: str):
+    """Renew a subscription."""
+    expiration_minutes = int(request.args.get("expiration_minutes", DEFAULT_EXPIRATION_MINUTES))
+    updated = account.subscriptions().renew_subscription(
+        subscription_id,
+        expiration_minutes=expiration_minutes,
+    )
+    return jsonify(updated), 200
+
+
+

Use this url:

+
+

http://<your-tunnel-host>/subscriptions/548355f8-c2c0-47ae-aac7-3ad02b2dfdb1/renew?expiration_minutes=10069

+
+

HTTP status 200 and the following should be returned:

+
{
+    "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#subscriptions/$entity",
+    "applicationId": "12345678-bad9-4c34-94d6-f9a1388522f8",
+    "changeType": "created",
+    "clientState": "abc123",
+    "creatorId": "12345678-a5c7-46da-8107-b25090a1ed66",
+    "encryptionCertificate": null,
+    "encryptionCertificateId": null,
+    "expirationDateTime": "2026-01-07T11:35:40.301594Z",
+    "id": "548355f8-c2c0-47ae-aac7-3ad02b2dfdb1",
+    "includeResourceData": null,
+    "latestSupportedTlsVersion": "v1_2",
+    "lifecycleNotificationUrl": null,
+    "notificationQueryOptions": null,
+    "notificationUrl": "https://<your-tunnel-host>/webhook",
+    "notificationUrlAppId": null,
+    "resource": "/me/mailFolders('inbox')/messages"
+}
+
+
+
+
+

Delete a Subscription

+
@app.get("/subscriptions/<subscription_id>/delete")
+def delete_subscription(subscription_id: str):
+    """Delete a subscription."""
+    deleted = account.subscriptions().delete_subscription(subscription_id)
+    if not deleted:
+        abort(404, description="Subscription not found")
+    return ("", 204)
+
+
+

Use this url:

+
+

http://<your-tunnel-host>/subscriptions/548355f8-c2c0-47ae-aac7-3ad02b2dfdb1/delete

+
+

HTTP status 204 should be returned.

+
+
+

Webhook

+

With a subscription as described above and an email sent to the inbox, a webhook will be received as below:

+
{
+    'value': [
+        {
+            'subscriptionId': '548355f8-c2c0-47ae-aac7-3ad02b2dfdb12',
+            'subscriptionExpirationDateTime': '2026-01-07T11:35:40.301594+00:00',
+            'changeType': 'created',
+            'resource': 'Users/12345678-a5c7-46da-8107-b25090a1ed66/Messages/<long_guid>=',
+            'resourceData': {
+                '@odata.type': '#Microsoft.Graph.Message',
+                '@odata.id': 'Users/12345678-a5c7-46da-8107-b25090a1ed66/Messages/<long_guid>=',
+                '@odata.etag': 'W/"CQAAABYACCCoiRErLbiNRJDCFyMjq4khBBnH4N7A"',
+                'id': '<long_guid>='
+            },
+            'clientState': 'abc123',
+            'tenantId': '12345678-abcd-1234-abcd-1234567890ab'
+        }
+    ]
+}
+
+
+

The client state should be validated for accuracy and if correct, the message can be acted upon as approriate for the type of subscription.

+

An example application can be found in the examples directory here - https://github.com/O365/python-o365/blob/master/examples/subscriptions_example.py

+
+
+ + +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/docs/latest/usage/tasks.html b/docs/latest/usage/tasks.html new file mode 100644 index 00000000..be4933d1 --- /dev/null +++ b/docs/latest/usage/tasks.html @@ -0,0 +1,197 @@ + + + + + + + + + Tasks — O365 documentation + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+ +
+
+
+
+ +
+

Tasks

+

The tasks functionality is grouped in a ToDo object.

+

A ToDo instance can list and create task folders. It can also list or create tasks on the default user folder. To use other folders use a Folder instance.

+

These are the scopes needed to work with the ToDo, Folder and Task classes.

+ + + + + + + + + + + + + + + + + +

Raw Scope

Included in Scope Helper

Description

Tasks.Read

tasks

To only read my personal tasks

Tasks.ReadWrite

tasks_all

To read and save personal calendars

+

Working with the ToDo` instance:

+
import datetime as dt
+
+# ...
+todo = account.tasks()
+
+#list current tasks
+folder = todo.get_default_folder()
+new_task = folder.new_task()  # creates a new unsaved task
+new_task.subject = 'Send contract to George Best'
+new_task.due = dt.datetime(2020, 9, 25, 18, 30)
+new_task.save()
+
+#some time later....
+
+new_task.mark_completed()
+new_task.save()
+
+# naive datetimes will automatically be converted to timezone aware datetime
+#  objects using the local timezone detected or the protocol provided timezone
+#  as with the Calendar functionality
+
+
+

Working with Folder instances:

+
#create a new folder
+new_folder = todo.new_folder('Defenders')
+
+#rename a folder
+folder = todo.get_folder(folder_name='Strikers')
+folder.name = 'Forwards'
+folder.update()
+
+#list current tasks
+task_list = folder.get_tasks()
+for task in task_list:
+    print(task)
+    print('')
+
+
+
+ + +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/docs/latest/usage/teams.html b/docs/latest/usage/teams.html new file mode 100644 index 00000000..85e22eca --- /dev/null +++ b/docs/latest/usage/teams.html @@ -0,0 +1,277 @@ + + + + + + + + + Teams — O365 documentation + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+ +
+
+
+
+ +
+

Teams

+

Teams enables the communications via Teams Chat, plus Presence management

+

These are the scopes needed to work with the Teams classes.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

Raw Scope

Included in Scope Helper

Description

Channel.ReadBasic.All

To read basic channel information

ChannelMessage.Read.All

To read channel messages

ChannelMessage.Send

To send messages to a channel

Chat.Read

To read users chat

Chat.ReadWrite

To read users chat and send chat messages

Presence.Read

presence

To read users presence status

Presence.Read.All

To read any users presence status

Presence.ReadWrite

To update users presence status

Team.ReadBasic.All

To read only the basic properties for all my teams

User.ReadBasic.All

users

To only read basic properties from users of my organization (User.Read.All requires administrator consent)

+
+

Presence

+

Assuming an authenticated account.

+
# Retrieve logged-in user's presence
+from O365 import Account
+account = Account(('app_id', 'app_pw'))
+teams = account.teams()
+presence = teams.get_my_presence()
+
+# Retrieve another user's presence
+user = account.directory().get_user("john@doe.com")
+presence2 = teams.get_user_presence(user.object_id)
+
+
+

To set a users status or preferred status:

+
# Set user's presence
+from O365.teams import Activity, Availability, PreferredActivity, PreferredAvailability
+
+status = teams.set_my_presence(CLIENT_ID, Availability.BUSY, Activity.INACALL, "1H")
+
+# or set User's preferred presence (which is more likely the one you want)
+
+status = teams.set_my_user_preferred_presence(PreferredAvailability.OFFLINE, PreferredActivity.OFFWORK, "1H")
+
+
+
+
+

Chat

+

Assuming an authenticated account.

+
# Retrieve logged-in user's chats
+from O365 import Account
+account = Account(('app_id', 'app_pw'))
+teams = account.teams()
+chats = teams.get_my_chats()
+
+# Then to retrieve chat messages and chat members
+for chat in chats:
+    if chat.chat_type != "unknownFutureValue":
+        message = chat.get_messages(limit=10)
+        memberlist = chat.get_members()
+
+
+# And to send a chat message
+
+chat.send_message(content="Hello team!", content_type="text")
+
+
+
+
Common commands for Chat include .get_member() and .get_message()
+
+
+
+

Team

+

Assuming an authenticated account.

+
# Retrieve logged-in user's teams
+from O365 import Account
+account = Account(('app_id', 'app_pw'))
+teams = account.teams()
+my_teams = teams.get_my_teams()
+
+# Then to retrieve team channels and messages
+for team in my_teams:
+    channels = team.get_channels()
+    for channel in channels:
+        messages = channel.get_messages(limit=10)
+        for channelmessage in messages:
+            print(channelmessage)
+
+
+# To send a message to a team channel
+channel.send_message("Hello team")
+
+# To send a reply to a message
+channelmessage.send_message("Hello team leader")
+
+
+
+
Common commands for Teams include .create_channel(), .get_apps_in_channel() and .get_channel()
+
Common commands for Team include .get_channel()
+
Common commands for Channel include .get_message()
+
Common commands for ChannelMessage include .get_replies() and .get_reply()
+
+
+
+ + +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/docs/latest/usage/utils.html b/docs/latest/usage/utils.html new file mode 100644 index 00000000..2912c755 --- /dev/null +++ b/docs/latest/usage/utils.html @@ -0,0 +1,161 @@ + + + + + + + + + Utils — O365 documentation + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+ +
+
+
+
+ +
+

Utils

+ +
+ + +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/docs/latest/usage/utils/query.html b/docs/latest/usage/utils/query.html new file mode 100644 index 00000000..aaaf2cf4 --- /dev/null +++ b/docs/latest/usage/utils/query.html @@ -0,0 +1,186 @@ + + + + + + + + + Query — O365 documentation + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+ +
+
+
+
+ +
+

Query

+
+

Query Builder

+

A query can be created for every ApiComponent (such as MailBox). The Query can be used to handle the filtering, sorting, selecting, expanding and search very easily.

+

For example:

+
builder = mailbox.new_query()
+
+query = builder.chain_or(builder.contains('subject', 'george best'), builder.startswith('subject', 'quotes')
+
+# 'created_date_time' will automatically be converted to the protocol casing.
+# For example when using MS Graph this will become 'createdDateTime'.
+
+query = query & builder.greater('created_date_time', datetime(2018, 3, 21))
+
+print(query)
+
+# contains(subject, 'george best') or startswith(subject, 'quotes') and createdDateTime gt '2018-03-21T00:00:00Z'
+# note you can pass naive datetimes and those will be converted to you local timezone and then send to the api as UTC in iso8601 format
+
+# To use Query objects just pass it to the query parameter:
+filtered_messages = mailbox.get_messages(query=query)
+
+
+

You can also specify specific data to be retrieved with “select”:

+
# select only some properties for the retrieved messages:
+query = builder.select('subject', 'to_recipients', 'created_date_time)
+
+messages_with_selected_properties = mailbox.get_messages(query=query)
+
+
+

You can also search content. As said in the graph docs:

+
+

You can currently search only message and person collections. A $search request returns up to 250 results. You cannot use $filter or $orderby in a search request.

+

If you do a search on messages and specify only a value without specific message properties, the search is carried out on the default search properties of from, subject, and body.

+
# searching is the easy part ;)
+query = builder.search('george best is da boss')
+messages = mailbox.get_messages(query=query)
+
+
+
+
+
+ + +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/docs/latest/usage/utils/token.html b/docs/latest/usage/utils/token.html new file mode 100644 index 00000000..906daf65 --- /dev/null +++ b/docs/latest/usage/utils/token.html @@ -0,0 +1,172 @@ + + + + + + + + + Token — O365 documentation + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+ +
+
+
+
+ +
+

Token

+

When initiating the account connection you may wish to store the token for ongoing usage, removing the need to re-authenticate every time. There are a variety of storage mechanisms available which are shown in the detailed api.

+
+

FileSystemTokenBackend

+

To store the token in your local file system, you can use the FileSystemTokenBackend. This takes a path and a file name as parameters.

+

For example:

+
from O365 import Account, FileSystemTokenBackend
+
+token_backend = FileSystemTokenBackend(token_path=token_path, token_filename=token_filename)
+
+account = Account(credentials=('my_client_id', 'my_client_secret'), token_backend=token_backend)
+
+
+

The methods are similar for the other token backends.

+

You can also pass in a cryptography manager to the token backend so encrypt the token in the store, and to decrypt on retrieval. The cryptography manager must support the encrypt and decrypt methods.

+
from O365 import Account, FileSystemTokenBackend
+from xxx import CryptoManager
+
+key = "my really secret key"
+mycryptomanager = CryptoManager(key)
+
+token_backend = FileSystemTokenBackend(token_path=token_path, token_filename=token_filename, cryptography_manager=mycryptomanager)
+
+account = Account(credentials=('my_client_id', 'my_client_secret'), token_backend=token_backend)
+
+
+
+
+ + +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/docs/latest/usage/utils/utils.html b/docs/latest/usage/utils/utils.html new file mode 100644 index 00000000..b2e30350 --- /dev/null +++ b/docs/latest/usage/utils/utils.html @@ -0,0 +1,229 @@ + + + + + + + + + Utils — O365 documentation + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+ +
+
+
+
+ +
+

Utils

+
+

Pagination

+

When using certain methods, it is possible that you request more items than the api can return in a single api call. In this case the Api, returns a “next link” url where you can pull more data.

+

When this is the case, the methods in this library will return a Pagination object which abstracts all this into a single iterator. The pagination object will request “next links” as soon as they are needed.

+

For example:

+
mailbox = account.mailbox()
+
+messages = mailbox.get_messages(limit=1500)  # the MS Graph API have a 999 items limit returned per api call.
+
+# Here messages is a Pagination instance. It's an Iterator so you can iterate over.
+
+# The first 999 iterations will be normal list iterations, returning one item at a time.
+# When the iterator reaches the 1000 item, the Pagination instance will call the api again requesting exactly 500 items
+# or the items specified in the batch parameter (see later).
+
+for message in messages:
+    print(message.subject)
+
+
+

When using certain methods you will have the option to specify not only a limit option (the number of items to be returned) but a batch option. This option will indicate the method to request data to the api in batches until the limit is reached or the data consumed. This is useful when you want to optimize memory or network latency.

+

For example:

+
messages = mailbox.get_messages(limit=100, batch=25)
+
+# messages here is a Pagination instance
+# when iterating over it will call the api 4 times (each requesting 25 items).
+
+for message in messages:  # 100 loops with 4 requests to the api server
+    print(message.subject)
+
+
+
+
+

Query helper

+
+

Note

+

This method of creating queries is now deprecated, queries shoould now be created using the ExperimentalQuery methods - Query Builder

+
+

Every ApiComponent (such as MailBox) implements a new_query method that will return a Query instance. This Query instance can handle the filtering, sorting, selecting, expanding and search very easily.

+

For example:

+
builder = mailbox.new_query()  # you can use the shorthand: mailbox.q()
+
+query = builder.chain_or(builder.contains('subject', 'george best'), builder.startswith('subject', 'quotes')
+
+# 'created_date_time' will automatically be converted to the protocol casing.
+# For example when using MS Graph this will become 'createdDateTime'.
+
+query = query & builder.greater('created_date_time', datetime(2018, 3, 21))
+
+print(query)
+
+# contains(subject, 'george best') or startswith(subject, 'quotes') and createdDateTime gt '2018-03-21T00:00:00Z'
+# note you can pass naive datetimes and those will be converted to you local timezone and then send to the api as UTC in iso8601 format
+
+# To use Query objetcs just pass it to the query parameter:
+filtered_messages = mailbox.get_messages(query=query)
+
+
+

You can also specify specific data to be retrieved with “select”:

+
# select only some properties for the retrieved messages:
+query = mailbox.new_query().select('subject', 'to_recipients', 'created_date_time')
+
+messages_with_selected_properties = mailbox.get_messages(query=query)
+
+
+

You can also search content. As said in the graph docs:

+
+

You can currently search only message and person collections. A $search request returns up to 250 results. You cannot use $filter or $orderby in a search request.

+

If you do a search on messages and specify only a value without specific message properties, the search is carried out on the default search properties of from, subject, and body.

+
# searching is the easy part ;)
+query = mailbox.q().search('george best is da boss')
+messages = mailbox.get_messages(query=query)
+
+
+
+
+
+

Request Error Handling

+

Whenever a Request error raises, the connection object will raise an exception. Then the exception will be captured and logged it to the stdout with its message, and return Falsy (None, False, [], etc…)

+

HttpErrors 4xx (Bad Request) and 5xx (Internal Server Error) are considered exceptions and +raised also by the connection. You can tell the Connection to not raise http errors by passing raise_http_errors=False (defaults to True).

+
+
+ + +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 00000000..543c6b13 --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=source +set BUILDDIR=build + +if "%1" == "" goto help + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.http://sphinx-doc.org/ + exit /b 1 +) + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% + +:end +popd diff --git a/docs/source/_static/css/style.css b/docs/source/_static/css/style.css new file mode 100644 index 00000000..f4bf9abf --- /dev/null +++ b/docs/source/_static/css/style.css @@ -0,0 +1,15 @@ +.wy-nav-content { + max-width: none; +} +/* override table no-wrap */ +.wy-table-responsive table td, .wy-table-responsive table th { + white-space: normal; +} + +/* +Fix for horizontal stacking weirdness in the RTD theme with Python properties: +https://github.com/readthedocs/sphinx_rtd_theme/issues/1301 +*/ +.py.property { + display: block !important; + } \ No newline at end of file diff --git a/docs/source/_templates/layout.html b/docs/source/_templates/layout.html new file mode 100644 index 00000000..d7831adb --- /dev/null +++ b/docs/source/_templates/layout.html @@ -0,0 +1,4 @@ +{% extends "!layout.html" %} +{% block extrahead %} + +{% endblock %} \ No newline at end of file diff --git a/docs/source/api.rst b/docs/source/api.rst new file mode 100644 index 00000000..d558897d --- /dev/null +++ b/docs/source/api.rst @@ -0,0 +1,25 @@ +======== +O365 API +======== + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + + api/account + api/address_book + api/calendar + api/category + api/connection + api/directory + api/excel + api/group + api/mailbox + api/message + api/onedrive + api/planner + api/sharepoint + api/subscriptions + api/tasks + api/teams + api/utils diff --git a/docs/source/api/account.rst b/docs/source/api/account.rst new file mode 100644 index 00000000..63e1aec4 --- /dev/null +++ b/docs/source/api/account.rst @@ -0,0 +1,10 @@ +Account +----------- + +.. include:: global.rst + +.. automodule:: O365.account + :members: + :undoc-members: + :show-inheritance: + :member-order: groupwise \ No newline at end of file diff --git a/docs/source/api/address_book.rst b/docs/source/api/address_book.rst new file mode 100644 index 00000000..86936b0e --- /dev/null +++ b/docs/source/api/address_book.rst @@ -0,0 +1,10 @@ +Address Book +------------ + +.. include:: global.rst + +.. automodule:: O365.address_book + :members: + :undoc-members: + :show-inheritance: + :member-order: groupwise \ No newline at end of file diff --git a/docs/source/api/calendar.rst b/docs/source/api/calendar.rst new file mode 100644 index 00000000..f090c51f --- /dev/null +++ b/docs/source/api/calendar.rst @@ -0,0 +1,10 @@ +Calendar +-------- + +.. include:: global.rst + +.. automodule:: O365.calendar + :members: + :undoc-members: + :show-inheritance: + :member-order: groupwise \ No newline at end of file diff --git a/docs/source/api/category.rst b/docs/source/api/category.rst new file mode 100644 index 00000000..7a6b7b66 --- /dev/null +++ b/docs/source/api/category.rst @@ -0,0 +1,10 @@ +Category +-------- + +.. include:: global.rst + +.. automodule:: O365.category + :members: + :undoc-members: + :show-inheritance: + :member-order: groupwise \ No newline at end of file diff --git a/docs/source/api/connection.rst b/docs/source/api/connection.rst new file mode 100644 index 00000000..0c06534e --- /dev/null +++ b/docs/source/api/connection.rst @@ -0,0 +1,10 @@ +Connection +---------- + +.. include:: global.rst + +.. automodule:: O365.connection + :members: + :undoc-members: + :show-inheritance: + :member-order: groupwise \ No newline at end of file diff --git a/docs/source/api/directory.rst b/docs/source/api/directory.rst new file mode 100644 index 00000000..16ba58f2 --- /dev/null +++ b/docs/source/api/directory.rst @@ -0,0 +1,10 @@ +Directory +--------- + +.. include:: global.rst + +.. automodule:: O365.directory + :members: + :undoc-members: + :show-inheritance: + :member-order: groupwise \ No newline at end of file diff --git a/docs/source/api/excel.rst b/docs/source/api/excel.rst new file mode 100644 index 00000000..8d9b9187 --- /dev/null +++ b/docs/source/api/excel.rst @@ -0,0 +1,9 @@ +Excel +----- + +.. include:: global.rst + +.. automodule:: O365.excel + :members: + :undoc-members: + :member-order: groupwise \ No newline at end of file diff --git a/docs/source/api/global.rst b/docs/source/api/global.rst new file mode 100644 index 00000000..9c7cad23 --- /dev/null +++ b/docs/source/api/global.rst @@ -0,0 +1,3 @@ +.. |br| raw:: html + +
   \ No newline at end of file diff --git a/docs/source/api/group.rst b/docs/source/api/group.rst new file mode 100644 index 00000000..5b603fbc --- /dev/null +++ b/docs/source/api/group.rst @@ -0,0 +1,10 @@ +Group +----- + +.. include:: global.rst + +.. automodule:: O365.groups + :members: + :undoc-members: + :show-inheritance: + :member-order: groupwise diff --git a/docs/source/api/mailbox.rst b/docs/source/api/mailbox.rst new file mode 100644 index 00000000..ae810da8 --- /dev/null +++ b/docs/source/api/mailbox.rst @@ -0,0 +1,10 @@ +Mailbox +------- + +.. include:: global.rst + +.. automodule:: O365.mailbox + :members: + :undoc-members: + :show-inheritance: + :member-order: groupwise \ No newline at end of file diff --git a/docs/source/api/message.rst b/docs/source/api/message.rst new file mode 100644 index 00000000..4d6279c1 --- /dev/null +++ b/docs/source/api/message.rst @@ -0,0 +1,10 @@ +Message +------- + +.. include:: global.rst + +.. automodule:: O365.message + :members: + :undoc-members: + :show-inheritance: + :member-order: groupwise \ No newline at end of file diff --git a/docs/source/api/onedrive.rst b/docs/source/api/onedrive.rst new file mode 100644 index 00000000..3451c866 --- /dev/null +++ b/docs/source/api/onedrive.rst @@ -0,0 +1,10 @@ +One Drive +--------- + +.. include:: global.rst + +.. automodule:: O365.drive + :members: + :undoc-members: + :show-inheritance: + :member-order: groupwise \ No newline at end of file diff --git a/docs/source/api/planner.rst b/docs/source/api/planner.rst new file mode 100644 index 00000000..efe2cad8 --- /dev/null +++ b/docs/source/api/planner.rst @@ -0,0 +1,10 @@ +Planner +------- + +.. include:: global.rst + +.. automodule:: O365.planner + :members: + :undoc-members: + :show-inheritance: + :member-order: groupwise \ No newline at end of file diff --git a/docs/source/api/sharepoint.rst b/docs/source/api/sharepoint.rst new file mode 100644 index 00000000..52f6273a --- /dev/null +++ b/docs/source/api/sharepoint.rst @@ -0,0 +1,10 @@ +Sharepoint +---------- + +.. include:: global.rst + +.. automodule:: O365.sharepoint + :members: + :undoc-members: + :show-inheritance: + :member-order: groupwise \ No newline at end of file diff --git a/docs/source/api/subscriptions.rst b/docs/source/api/subscriptions.rst new file mode 100644 index 00000000..c5e6eb73 --- /dev/null +++ b/docs/source/api/subscriptions.rst @@ -0,0 +1,10 @@ +Subscriptions +------------- + +.. include:: global.rst + +.. automodule:: O365.subscriptions + :members: + :undoc-members: + :show-inheritance: + :member-order: groupwise \ No newline at end of file diff --git a/docs/source/api/tasks.rst b/docs/source/api/tasks.rst new file mode 100644 index 00000000..39ccde00 --- /dev/null +++ b/docs/source/api/tasks.rst @@ -0,0 +1,10 @@ +Tasks +----- + +.. include:: global.rst + +.. automodule:: O365.tasks + :members: + :undoc-members: + :show-inheritance: + :member-order: groupwise \ No newline at end of file diff --git a/docs/source/api/teams.rst b/docs/source/api/teams.rst new file mode 100644 index 00000000..03c9ca71 --- /dev/null +++ b/docs/source/api/teams.rst @@ -0,0 +1,10 @@ +Teams +----- + +.. include:: global.rst + +.. automodule:: O365.teams + :members: + :undoc-members: + :show-inheritance: + :member-order: groupwise \ No newline at end of file diff --git a/docs/source/api/utils.rst b/docs/source/api/utils.rst new file mode 100644 index 00000000..dd3e5aa1 --- /dev/null +++ b/docs/source/api/utils.rst @@ -0,0 +1,12 @@ +===== +Utils +===== + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + + utils/attachment + utils/query + utils/token + utils/utils diff --git a/docs/source/api/utils/attachment.rst b/docs/source/api/utils/attachment.rst new file mode 100644 index 00000000..bf0ad331 --- /dev/null +++ b/docs/source/api/utils/attachment.rst @@ -0,0 +1,10 @@ +Attachment +---------- + +.. include:: ../global.rst + +.. automodule:: O365.utils.attachment + :members: + :undoc-members: + :show-inheritance: + :member-order: groupwise \ No newline at end of file diff --git a/docs/source/api/utils/query.rst b/docs/source/api/utils/query.rst new file mode 100644 index 00000000..e882a5b1 --- /dev/null +++ b/docs/source/api/utils/query.rst @@ -0,0 +1,10 @@ +Query +----- + +.. include:: ../global.rst + +.. automodule:: O365.utils.query + :members: + :undoc-members: + :show-inheritance: + :member-order: groupwise \ No newline at end of file diff --git a/docs/source/api/utils/token.rst b/docs/source/api/utils/token.rst new file mode 100644 index 00000000..875b78b3 --- /dev/null +++ b/docs/source/api/utils/token.rst @@ -0,0 +1,10 @@ +Token +----- + +.. include:: ../global.rst + +.. automodule:: O365.utils.token + :members: + :undoc-members: + :show-inheritance: + :member-order: groupwise \ No newline at end of file diff --git a/docs/source/api/utils/utils.rst b/docs/source/api/utils/utils.rst new file mode 100644 index 00000000..a26ef772 --- /dev/null +++ b/docs/source/api/utils/utils.rst @@ -0,0 +1,10 @@ +Utils +----- + +.. include:: ../global.rst + +.. automodule:: O365.utils.utils + :members: + :undoc-members: + :show-inheritance: + :member-order: groupwise \ No newline at end of file diff --git a/docs/source/conf.py b/docs/source/conf.py new file mode 100644 index 00000000..cc650469 --- /dev/null +++ b/docs/source/conf.py @@ -0,0 +1,205 @@ +# -*- coding: utf-8 -*- +# +# Configuration file for the Sphinx documentation builder. +# +# This file does only contain a selection of the most common options. For a +# full list see the documentation: +# http://www.sphinx-doc.org/en/master/config + +# -- Path setup -------------------------------------------------------------- + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +# +import os +import sys + +sys.path.insert(0, os.path.abspath("../..")) + + +# -- Project information ----------------------------------------------------- +project = "O365" +copyright = "2025, alejcas" +author = "alejcas" + +# The short X.Y version +version = "" +# The full version, including alpha/beta/rc tags +release = "" + +# -- General configuration --------------------------------------------------- + +# If your documentation needs a minimal Sphinx version, state it here. +# +# needs_sphinx = '1.0' + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. +extensions = [ + "sphinx.ext.autodoc", + "sphinx.ext.todo", + "sphinx.ext.coverage", + "sphinx.ext.mathjax", + "sphinx.ext.ifconfig", + "sphinx.ext.viewcode", + "sphinx.ext.githubpages", + "sphinx_rtd_theme", +] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ["_templates"] + +# The suffix(es) of source filenames. +# You can specify multiple suffix as a list of string: +# +# source_suffix = ['.rst', '.md'] +source_suffix = {".rst": "restructuredtext"} + +# The master toctree document. +master_doc = "index" + +# The language for content autogenerated by Sphinx. Refer to documentation +# for a list of supported languages. +# +# This is also used if you do content translation via gettext catalogs. +# Usually you set "language" from the command line for these cases. +language = "en" + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +# This pattern also affects html_static_path and html_extra_path. +exclude_patterns = [] + +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = None + +# -- Options for HTML output ------------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +# +html_theme = "sphinx_rtd_theme" +html_theme_path = [ + "_themes", +] + +# Theme options are theme-specific and customize the look and feel of a theme +# further. For a list of options available for each theme, see the +# documentation. +# +html_theme_options = { + "version_selector": True, + "prev_next_buttons_location": "both", + "style_external_links": False, + "logo_only": True, + # Toc options + "collapse_navigation": True, + "sticky_navigation": True, + "navigation_depth": 4, + "includehidden": True, + "titles_only": False, +} + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = ["_static"] + +# Custom sidebar templates, must be a dictionary that maps document names +# to template names. +# +# The default sidebars (for documents that don't match any pattern) are +# defined by theme itself. Builtin themes are using these templates by +# default: ``['localtoc.html', 'relations.html', 'sourcelink.html', +# 'searchbox.html']``. +# +# html_sidebars = {} + + +# -- Options for HTMLHelp output --------------------------------------------- + +# Output file base name for HTML help builder. +htmlhelp_basename = "O365doc" + +# -- Options for LaTeX output ------------------------------------------------ + +latex_elements = { + # The paper size ('letterpaper' or 'a4paper'). + # + # 'papersize': 'letterpaper', + # The font size ('10pt', '11pt' or '12pt'). + # + # 'pointsize': '10pt', + # Additional stuff for the LaTeX preamble. + # + # 'preamble': '', + # Latex figure (float) alignment + # + # 'figure_align': 'htbp', +} + +# Grouping the document tree into LaTeX files. List of tuples +# (source start file, target name, title, +# author, documentclass [howto, manual, or own class]). +latex_documents = [ + (master_doc, "O365.tex", "O365 Documentation", author, "manual"), +] + +# -- Options for manual page output ------------------------------------------ + +# One entry per manual page. List of tuples +# (source start file, name, description, authors, manual section). +man_pages = [(master_doc, "o365", "O365 Documentation", [author], 1)] + +# -- Options for Texinfo output ---------------------------------------------- + +# Grouping the document tree into Texinfo files. List of tuples +# (source start file, target name, title, author, +# dir menu entry, description, category) +texinfo_documents = [ + ( + master_doc, + "O365", + "O365 Documentation", + author, + "O365", + "One line description of project.", + "Miscellaneous", + ), +] + +# -- Options for Epub output ------------------------------------------------- + +# Bibliographic Dublin Core info. +epub_title = project + +# The unique identifier of the text. This can be a ISBN number +# or the project homepage. +# +# epub_identifier = '' + +# A unique identification for the text. +# +# epub_uid = '' + +# A list of files that should not be packed into the epub file. +epub_exclude_files = ["search.html"] + +# -- Extension configuration ------------------------------------------------- + +# -- Options for todo extension ---------------------------------------------- + +# If true, `todo` and `todoList` produce output, else they produce nothing. +todo_include_todos = True + + +def skip(app, what, name, obj, skip, options): + if name == "__init__": + return False + return skip + + +def setup(app): + app.connect("autodoc-skip-member", skip) diff --git a/docs/source/getting_started.rst b/docs/source/getting_started.rst new file mode 100644 index 00000000..28c3d69f --- /dev/null +++ b/docs/source/getting_started.rst @@ -0,0 +1,521 @@ +############### +Getting Started +############### + +Installation +============ +Stable Version (PyPI) +--------------------- +The latest stable package is hosted on `PyPI `_. + +To install using pip, run: + +.. code-block:: console + + pip install o365 + +or use uv: + +.. code-block:: console + + uv add o365 + +Requirements: >= Python 3.10 + +Project dependencies installed by pip: + +* requests +* msal +* beatifulsoup4 +* python-dateutil +* tzlocal +* tzdata + +Latest Development Version (GitHub) +----------------------------------- +The latest development version is available on `GitHub `_. +This version may include new features but could be unstable. **Use at your own risk**. + +Using pip, run: + +.. code-block:: console + + pip install git+https://github.com/O365/python-o365.git + +Or with uv, run: + +.. code-block:: console + + uv add "o365 @ git+https://github.com/O365/python-o365" + +Basic Usage +=========== + +The first step to be able to work with this library is to register an application and retrieve the auth token. See :ref:`authentication`. + +With the access token retrieved and stored you will be able to perform api calls to the service. + +A common pattern to check for authentication and use the library is this one: + +.. code-block:: python + + requested_scopes = ['my_required_scopes'] # you can use scope helpers here (see Permissions and Scopes section) + + account = Account(credentials) + + if not account.is_authenticated: # will check if there is a token and has not expired + # ask for a login using console based authentication. See Authentication for other flows + if account.authenticate(requested_scopes=requeated_scopes) is False: + raise RuntimeError('Authentication Failed') + + # now we are authenticated + # use the library from now on + + # ... + +.. _authentication: + +Authentication +============== +Types +----- +You can only authenticate using OAuth authentication because Microsoft deprecated basic auth on November 1st 2018. + +.. important:: + + With version 2.1 old access tokens will not work and the library will require a new authentication flow to get new access and refresh tokens. + +There are currently three authentication methods: + +* `Authenticate on behalf of a user `_: Any user will give consent to the app to access its resources. This OAuth flow is called authorization code grant flow. This is the default authentication method used by this library. + +* `Authenticate on behalf of a user (public) `_: Same as the former but for public apps where the client secret can't be secured. Client secret is not required. + +* `Authenticate with your own identity `_: This will use your own identity (the app identity). This OAuth flow is called client credentials grant flow. + +.. note:: + + 'Authenticate with your own identity' is not an allowed method for Microsoft Personal accounts. + +When to use one or the other and requirements: + + + ++----------------------------+---------------------------------------------------------+-----------------------------------------------------------+----------------------------------------------------------+ +| Topic | On behalf of a user *(auth_flow_type=='authorization')* | On behalf of a user (public) *(auth_flow_type=='public')* | With your own identity *(auth_flow_type=='credentials')* | ++============================+=========================================================+===========================================================+==========================================================+ +| **Register the App** | Required | Required | Required | ++----------------------------+---------------------------------------------------------+-----------------------------------------------------------+----------------------------------------------------------+ +| **Requires Admin Consent** | Only on certain advanced permissions | Only on certain advanced permissions | Yes, for everything | ++----------------------------+---------------------------------------------------------+-----------------------------------------------------------+----------------------------------------------------------+ +| **App Permission Type** | Delegated Permissions (on behalf of the user) | Delegated Permissions (on behalf of the user) | Application Permissions | ++----------------------------+---------------------------------------------------------+-----------------------------------------------------------+----------------------------------------------------------+ +| **Auth requirements** | Client Id, Client Secret, Authorization Code | Client Id, Authorization Code | Client Id, Client Secret | ++----------------------------+---------------------------------------------------------+-----------------------------------------------------------+----------------------------------------------------------+ +| **Authentication** | 2 step authentication with user consent | 2 step authentication with user consent | 1 step authentication | ++----------------------------+---------------------------------------------------------+-----------------------------------------------------------+----------------------------------------------------------+ +| **Auth Scopes** | Required | Required | None | ++----------------------------+---------------------------------------------------------+-----------------------------------------------------------+----------------------------------------------------------+ +| **Token Expiration** | 60 Minutes without refresh token or 90 days* | 60 Minutes without refresh token or 90 days* | 60 Minutes* | ++----------------------------+---------------------------------------------------------+-----------------------------------------------------------+----------------------------------------------------------+ +| **Login Expiration** | Unlimited if there is a refresh token and as long as a | Unlimited if there is a refresh token and as long as a | Unlimited | +| | refresh is done within the 90 days | refresh is done within the 90 days | | ++----------------------------+---------------------------------------------------------+-----------------------------------------------------------+----------------------------------------------------------+ +| **Resources** | Access the user resources, and any shared resources | Access the user resources, and any shared resources | All Azure AD users the app has access to | ++----------------------------+---------------------------------------------------------+-----------------------------------------------------------+----------------------------------------------------------+ +| **Microsoft Account Type** | Any | Any | Not Allowed for Personal Accounts | ++----------------------------+---------------------------------------------------------+-----------------------------------------------------------+----------------------------------------------------------+ +| **Tenant ID Required** | Defaults to "common" | Defaults to "common" | Required (can't be "common") | ++----------------------------+---------------------------------------------------------+-----------------------------------------------------------+----------------------------------------------------------+ + +*Note: *O365 will automatically refresh the token for you on either authentication method. The refresh token lasts 90 days, but it's refreshed on each connection so as long as you connect within 90 days you can have unlimited access.* + +The Connection Class handles the authentication. + +With auth_flow_type 'credentials' you can authenticate using a certificate based authentication by just passing the client_secret like so: + +.. code-block:: python + + client_secret = { + "thumbprint": , + "private_key": + } + credentials = client_id, client_secret + account = Account(credentials) + + +OAuth Setup (Prerequisite) +-------------------------- + +Before you can use python-o365, you must register your application in the +`Microsoft Entra Admin Center `_. Follow the steps below: + +1. **Log in to the Microsoft Entra Admin Center** + + - Visit https://entra.microsoft.com/ and sign in. + +2. **Create a new application and note its App (client) ID** + + - In the left navigation bar, select **Applications** > **App registrations**. + - Click **+ New registration**. + - Provide a **Name** for the application and keep all defaults. + - From the **Overview** of your new application, copy the (client_id) **Application (client) ID** for later reference. + +3. **Generate a new password (client_secret)** + + - In the **Overview** window, select **Certificates & secrets**. + - Click **New client secret**. + - In the **Add a client secret** window, provide a Description and Expiration, then click **Add**. + - Save the (client_secret) **Value** for later reference. + +4. **Add redirect URIs** + + - In the **Overview** window, click **Add a redirect URI**. + - Click **+ Add a platform**, then select **Web**. + - Add ``https://login.microsoftonline.com/common/oauth2/nativeclient`` as the redirect URI. + - Click **Save**. + +5. **Add required permissions** + + - In the left navigation bar, select **API permissions**. + - Click **+ Add a permission**. + - Under **Microsoft Graph**, select **Delegated permissions**. + - Add the delegated permissions you plan to use (for example): + + - Mail.Read + - Mail.ReadWrite + - Mail.Send + - User.Read + - User.ReadBasic.All + - offline_access + + - Click **Add permissions**. + +.. important:: + + The offline_access permission is required for the refresh token to work. + +Examples +-------- +Then you need to log in for the first time to get the access token that will grant access to the user resources. + +To authenticate (login) you can use :ref:`different_interfaces`. On the following examples we will be using the Console Based Interface, but you can use any of them. + +.. important:: + + In case you can't secure the client secret you can use the auth flow type 'public' which only requires the client id. + +* When authenticating on behalf of a user: + + 1. Instantiate an `Account` object with the credentials (client id and client secret). + 2. Call `account.authenticate` and pass the scopes you want (the ones you previously added on the app registration portal). + + > Note: when using the "on behalf of a user" authentication, you can pass the scopes to either the `Account` init or to the authenticate method. Either way is correct. + + You can pass "protocol scopes" (like: "https://graph.microsoft.com/Calendars.ReadWrite") to the method or use "[scope helpers](https://github.com/O365/python-o365/blob/master/O365/connection.py#L34)" like ("message_all"). + If you pass protocol scopes, then the `account` instance must be initialized with the same protocol used by the scopes. By using scope helpers you can abstract the protocol from the scopes and let this library work for you. + Finally, you can mix and match "protocol scopes" with "scope helpers". + Go to the [procotol section](#protocols) to know more about them. + + For Example (following the previous permissions added): + + .. code-block:: python + + from O365 import Account + credentials = ('my_client_id', 'my_client_secret') + + # the default protocol will be Microsoft Graph + # the default authentication method will be "on behalf of a user" + + account = Account(credentials) + if account.authenticate(requested_scopes=['basic', 'message_all']): + print('Authenticated!') + + # 'basic' adds: 'https://graph.microsoft.com/User.Read' + # 'message_all' adds: 'https://graph.microsoft.com/Mail.ReadWrite' and 'https://graph.microsoft.com/Mail.Send' + + When using the "on behalf of the user" authentication method, this method call will print an url that the user must visit to give consent to the app on the required permissions. + + The user must then visit this url and give consent to the application. When consent is given, the page will rediret to: "https://login.microsoftonline.com/common/oauth2/nativeclient" by default (you can change this) with an url query param called 'code'. + + Then the user must copy the resulting page url and paste it back on the console. + The method will then return True if the login attempt was succesful. + +* When authenticating with your own identity: + + 1. Instantiate an `Account` object with the credentials (client id and client secret), specifying the parameter `auth_flow_type` to *"credentials"*. You also need to provide a 'tenant_id'. You don't need to specify any scopes. + 2. Call `account.authenticate`. This call will request a token for you and store it in the backend. No user interaction is needed. The method will store the token in the backend and return True if the authentication succeeded. + + For Example: + + .. code-block:: python + + from O365 import Account + + credentials = ('my_client_id', 'my_client_secret') + + # the default protocol will be Microsoft Graph + + account = Account(credentials, auth_flow_type='credentials', tenant_id='my-tenant-id') + if account.authenticate(): + print('Authenticated!') + +At this point you will have an access token stored that will provide valid credentials when using the api. + +The access token only lasts **60 minutes**, but the app will automatically request new access tokens if you added the 'offline access' permission. + +When using the "on behalf of a user" authentication method this is accomplished through the refresh tokens (if and only if you added the "offline_access" permission), but note that a refresh token only lasts for 90 days. So you must use it before, or you will need to request a new access token again (no new consent needed by the user, just a login). If your application needs to work for more than 90 days without user interaction and without interacting with the API, then you must implement a periodic call to Connection.refresh_token before the 90 days have passed. + +.. important:: + + Take care: the access (and refresh) token must remain protected from unauthorized users. + +.. _different_interfaces: + +Different interfaces +-------------------- +To accomplish the authentication you can basically use different approaches. The following apply to the "on behalf of a user" authentication method as this is 2-step authentication flow. For the "with your own identity" authentication method, you can just use account.authenticate as it's not going to require a console input. + +1. Console based authentication interface: + + You can authenticate using a console. The best way to achieve this is by using the authenticate method of the Account class. + + account = Account(credentials) + account.authenticate(requested_scopes=['basic', 'message_all']) + The authenticate method will print into the console an url that you will have to visit to achieve authentication. Then after visiting the link and authenticate you will have to paste back the resulting url into the console. The method will return True and print a message if it was succesful. + + **Tip:** When using macOS the console is limited to 1024 characters. If your url has multiple scopes it can exceed this limit. To solve this. Just import readline at the top of your script. + +2. Web app based authentication interface: + + You can authenticate your users in a web environment by following these steps: + + i. First ensure you are using an appropiate TokenBackend to store the auth tokens (See Token storage below). + ii. From a handler redirect the user to the Microsoft login url. Provide a callback. Store the flow dictionary. + iii. From the callback handler complete the authentication with the flow dict and other data. + + The following example is done using Flask. + + .. code-block:: python + + from flask import request + from O365 import Account + + + @route('/stepone') + def auth_step_one(): + # callback = absolute url to auth_step_two_callback() page, https://domain.tld/steptwo + callback = url_for('auth_step_two_callback', _external=True) # Flask example + + account = Account(credentials) + url, flow = account.con.get_authorization_url(requested_scopes=my_scopes, + redirect_uri=callback) + + flow_as_string = serialize(flow) # convert the dict into a string using json for example + # the flow must be saved somewhere as it will be needed later + my_db.store_flow(flow_as_string) # example... + + return redirect(url) + + @route('/steptwo') + def auth_step_two_callback(): + account = Account(credentials) + + # retrieve the state saved in auth_step_one + my_saved_flow_str = my_db.get_flow() # example... + my_saved_flow = deserialize(my_saved_flow_str) # convert from a string to a dict using json for example. + + # rebuild the redirect_uri used in auth_step_one + callback = 'my absolute url to auth_step_two_callback' + + # get the request URL of the page which will include additional auth information + # Example request: /steptwo?code=abc123&state=xyz456 + requested_url = request.url # uses Flask's request() method + + result = account.con.request_token(requested_url, + flow=my_saved_flow) + # if result is True, then authentication was successful + # and the auth token is stored in the token backend + if result: + return render_template('auth_complete.html') + # else .... + +3. Other authentication interfaces: + + Finally, you can configure any other flow by using ``connection.get_authorization_url`` and ``connection.request_token`` as you want. + +Permissions & Scopes +==================== +Permissions +----------- +When using oauth, you create an application and allow some resources to be accessed and used by its users. These resources are managed with permissions. These can either be delegated (on behalf of a user) or application permissions. The former are used when the authentication method is "on behalf of a user". Some of these require administrator consent. The latter when using the "with your own identity" authentication method. All of these require administrator consent. + +Scopes +------ +The scopes only matter when using the "on behalf of a user" authentication method. + +.. note:: + You only need the scopes when login as those are kept stored within the token on the token backend. + +The user of this library can then request access to one or more of these resources by providing scopes to the OAuth provider. + +.. note:: + If you later on change the scopes requested, the current token will be invalid, and you will have to re-authenticate. The user that logins will be asked for consent. + +For example your application can have Calendar.Read, Mail.ReadWrite and Mail.Send permissions, but the application can request access only to the Mail.ReadWrite and Mail.Send permission. This is done by providing scopes to the Account instance or account.authenticate method like so: + +.. code-block:: python + + from O365 import Account + + credentials = ('client_id', 'client_secret') + + requested_scopes = ['Mail.ReadWrite', 'Mail.Send'] + + account = Account(credentials, requested_scopes=requested_scopes) + account.authenticate() + + # The latter is exactly the same as passing scopes to the authenticate method like so: + # account = Account(credentials) + # account.authenticate(requested_scopes=requested_scopes) + +Scope implementation depends on the protocol used. So by using protocol data you can automatically set the scopes needed. This is implemented by using 'scope helpers'. Those are little helpers that group scope functionality and abstract the protocol used. + +======================= =============== +Scope Helper Scopes included +======================= =============== +basic 'User.Read' +mailbox 'Mail.Read' +mailbox_shared 'Mail.Read.Shared' +mailbox_settings 'MailboxSettings.ReadWrite' +message_send 'Mail.Send' +message_send_shared 'Mail.Send.Shared' +message_all 'Mail.ReadWrite' and 'Mail.Send' +message_all_shared 'Mail.ReadWrite.Shared' and 'Mail.Send.Shared' +address_book 'Contacts.Read' +address_book_shared 'Contacts.Read.Shared' +address_book_all 'Contacts.ReadWrite' +address_book_all_shared 'Contacts.ReadWrite.Shared' +calendar 'Calendars.Read' +calendar_shared 'Calendars.Read.Shared' +calendar_all 'Calendars.ReadWrite' +calendar_shared_all 'Calendars.ReadWrite.Shared' +users 'User.ReadBasic.All' +onedrive 'Files.Read.All' +onedrive_all 'Files.ReadWrite.All' +sharepoint 'Sites.Read.All' +sharepoint_dl 'Sites.ReadWrite.All' +tasks 'Tasks.Read' +tasks_all 'Tasks.ReadWrite' +presence 'Presence.Read' +======================= =============== + +You can get the same scopes as before using protocols and scope helpers like this: + +.. code-block:: python + + protocol_graph = MSGraphProtocol() + + scopes_graph = protocol.get_scopes_for('message_all') + # scopes here are: ['https://graph.microsoft.com/Mail.ReadWrite', 'https://graph.microsoft.com/Mail.Send'] + + account = Account(credentials, requested_scopes=scopes_graph) + +.. note:: + + When passing scopes at the Account initialization or on the account.authenticate method, the scope helpers are automatically converted to the protocol flavour. Those are the only places where you can use scope helpers. Any other object using scopes (such as the Connection object) expects scopes that are already set for the protocol. + +Token Storage +============= + +When authenticating you will retrieve OAuth tokens. If you don't want a one time access you will have to store the token somewhere. O365 makes no assumptions on where to store the token and tries to abstract this from the library usage point of view. + +You can choose where and how to store tokens by using the proper Token Backend. + +.. caution:: + + **The access (and refresh) token must remain protected from unauthorized users.** You can plug in a "cryptography_manager" (object that can call encrypt and decrypt) into TokenBackends "cryptography_manager" attribute. + +The library will call (at different stages) the token backend methods to load and save the token. + +Methods that load tokens: + +* ``account.is_authenticated`` property will try to load the token if is not already loaded. +* ``connection.get_session``: this method is called when there isn't a request session set. + +Methods that stores tokens: + +* ``connection.request_token``: by default will store the token, but you can set store_token=False to avoid it. +* ``connection.refresh_token``: by default will store the token. To avoid it change ``connection.store_token_after_refresh`` to False. This however it's a global setting (that only affects the ``refresh_token`` method). If you only want the next refresh operation to not store the token you will have to set it back to True afterward. + +To store the token you will have to provide a properly configured TokenBackend. + +There are a few ``TokenBackend`` classes implemented (and you can easily implement more like a CookieBackend, RedisBackend, etc.): + +* ``FileSystemTokenBackend`` (Default backend): Stores and retrieves tokens from the file system. Tokens are stored as text files. +* ``MemoryTokenBackend``: Stores the tokens in memory. Basically load_token and save_token does nothing. +* ``EnvTokenBackend``: Stores and retrieves tokens from environment variables. +* ``FirestoreTokenBackend``: Stores and retrieves tokens from a Google Firestore Datastore. Tokens are stored as documents within a collection. +* ``AWSS3Backend``: Stores and retrieves tokens from an AWS S3 bucket. Tokens are stored as a file within a S3 bucket. +* ``AWSSecretsBackend``: Stores and retrieves tokens from an AWS Secrets Management vault. +* ``BitwardenSecretsManagerBackend``: Stores and retrieves tokens from Bitwarden Secrets Manager. +* ``DjangoTokenBackend``: Stores and retrieves tokens using a Django model. + +For example using the FileSystem Token Backend: + +.. code-block:: python + + from O365 import Account, FileSystemTokenBackend + + credentials = ('id', 'secret') + + # this will store the token under: "my_project_folder/my_folder/my_token.txt". + # you can pass strings to token_path or Path instances from pathlib + token_backend = FileSystemTokenBackend(token_path='my_folder', token_filename='my_token.txt') + account = Account(credentials, token_backend=token_backend) + + # This account instance tokens will be stored on the token_backend configured before. + # You don't have to do anything more + # ... + +And now using the same example using FirestoreTokenBackend: + +.. code-block:: python + + from O365 import Account + from O365.utils import FirestoreBackend + from google.cloud import firestore + + credentials = ('id', 'secret') + + # this will store the token on firestore under the tokens collection on the defined doc_id. + # you can pass strings to token_path or Path instances from pathlib + user_id = 'whatever the user id is' # used to create the token document id + document_id = f"token_{user_id}" # used to uniquely store this token + token_backend = FirestoreBackend(client=firestore.Client(), collection='tokens', doc_id=document_id) + account = Account(credentials, token_backend=token_backend) + + # This account instance tokens will be stored on the token_backend configured before. + # You don't have to do anything more + # ... + +To implement a new TokenBackend: + +1. Subclass ``BaseTokenBackend`` + +2. Implement the following methods: + + * ``__init__`` (don't forget to call ``super().__init__``) + * ``load_token``: this should load the token from the desired backend and return a ``Token`` instance or None + * ``save_token``: this should store the ``self.token`` in the desired backend. + * Optionally you can implement: ``check_token``, ``delete_token`` and ``should_refresh_token`` + +The ``should_refresh_token`` method is intended to be implemented for environments where multiple Connection instances are running on parallel. This method should check if it's time to refresh the token or not. The chosen backend can store a flag somewhere to answer this question. This can avoid race conditions between different instances trying to refresh the token at once, when only one should make the refresh. The method should return three possible values: + +* **True**: then the Connection will refresh the token. +* **False**: then the Connection will NOT refresh the token. +* None: then this method already executed the refresh and therefore the Connection does not have to. + +By default, this always returns True as it's assuming there is are no parallel connections running at once. + +There are two examples of this method in the examples folder `here `_. \ No newline at end of file diff --git a/docs/source/index.rst b/docs/source/index.rst new file mode 100644 index 00000000..74f7dd71 --- /dev/null +++ b/docs/source/index.rst @@ -0,0 +1,18 @@ +Welcome to O365's documentation! +================================ + +.. toctree:: + :maxdepth: 3 + :caption: Contents: + + overview + getting_started + usage + api + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` diff --git a/docs/source/overview.rst b/docs/source/overview.rst new file mode 100644 index 00000000..032461b0 --- /dev/null +++ b/docs/source/overview.rst @@ -0,0 +1,71 @@ +######## +Overview +######## + +**O365 - Microsoft Graph API made easy** + +.. important:: + + With version 2.1 old access tokens will not work, and the library will require a new authentication flow to get new access and refresh tokens. + +This project aims to make interacting with Microsoft Graph easy to do in a Pythonic way. Access to Email, Calendar, Contacts, OneDrive, etc. Are easy to do in a way that feel easy and straight forward to beginners and feels just right to seasoned python programmer. + +The project is currently developed and maintained by `alejcas `_. + +Core developers +--------------- +* `Alejcas `_ +* `Toben Archer `_ +* `Geethanadh `_ + +We are always open to new pull requests! + +Quick example +------------- +Here is a simple example showing how to send an email using python-o365. +Create a Python file and add the following code: + +.. code-block:: python + + from O365 import Account + + credentials = ('client_id', 'client_secret') + account = Account(credentials) + + m = account.new_message() + m.to.add('to_example@example.com') + m.subject = 'Testing!' + m.body = "George Best quote: I've stopped drinking, but only while I'm asleep." + m.send() + + +Why choose O365? +---------------- +* Almost Full Support for MsGraph Rest Api. +* Full OAuth support with automatic handling of refresh tokens. +* Automatic handling between local datetimes and server datetimes. Work with your local datetime and let this library do the rest. +* Change between different resource with ease: access shared mailboxes, other users resources, SharePoint resources, etc. +* Pagination support through a custom iterator that handles future requests automatically. Request Infinite items! +* A query helper to help you build custom OData queries (filter, order, select and search). +* Modular ApiComponents can be created and built to achieve further functionality. + +---- + +This project was also a learning resource for us. This is a list of not so common python idioms used in this project: + +* New unpacking technics: ``def method(argument, *, with_name=None, **other_params)``: +* Enums: from enum import Enum +* Factory paradigm +* Package organization +* Timezone conversion and timezone aware datetimes +* Etc. (see the code!) + +Rebuilding HTML Docs +-------------------- +* Install ``sphinx`` python library: + +.. code-block:: console + + pip install sphinx sphinx-rtd-theme + +* Run the shell script ``build_docs.sh``, or copy the command from the file when using on Windows diff --git a/docs/source/usage.rst b/docs/source/usage.rst new file mode 100644 index 00000000..aaba6b9d --- /dev/null +++ b/docs/source/usage.rst @@ -0,0 +1,23 @@ +============== +Detailed Usage +============== + +.. toctree:: + :maxdepth: 3 + :caption: Contents: + + usage/connection + usage/account + usage/addressbook + usage/calendar + usage/directory + usage/excel + usage/group + usage/mailbox + usage/onedrive + usage/planner + usage/sharepoint + usage/subscriptions + usage/tasks + usage/teams + usage/utils diff --git a/docs/source/usage/account.rst b/docs/source/usage/account.rst new file mode 100644 index 00000000..fb21c4f5 --- /dev/null +++ b/docs/source/usage/account.rst @@ -0,0 +1,248 @@ +Account +======= + +Multi-user handling +^^^^^^^^^^^^^^^^^^^ +A single ``Account`` object can hold more than one user being authenticated. You can authenticate different users and the token backend +will hold each authentication. When using the library you can use the ``account.username`` property to get or set the current user. +If username is not provided, the username will be set automatically to the first authentication found in the token backend. Also, +whenever you perform a new call to request_token (manually or through a call to ``account.authenticate``), +the username will be set to the user performing the authentication. + +.. code-block:: python + + account.username = 'user1@domain.com' + # issue some calls to retrieve data using the auth of the user1 + account.username = 'user2@domain.com' + # now every call will use the auth of the user2 + +This is only possible in version 2.1. Before 2.1 you had to instantiate one Account for each user. +Account class represents a specific account you would like to connect + +Setting your Account Instance +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +Connecting to API Account +""""""""""""""""""""""""" +.. code-block:: python + + from O365 import Account + + account = Account(credentials=('my_client_id', 'my_client_secret')) + +Setting Proxy +""""""""""""" +.. code-block:: python + + # Option 1 + account = Account(credentials=('my_client_id', 'my_client_secret'), + proxy_server='myserver.com', proxy_port=8080, + proxy_username='username', proxy_password='password) + + # Option 2 + account = Account(credentials=('my_client_id', 'my_client_secret')) + account.connection.set('myserver.com',8080,'username', 'password') + +Using Different Resource +"""""""""""""""""""""""" +.. code-block:: python + + from O365 import Account + + account = Account(credentials=('my_client_id', 'my_client_secret'), main_resource='shared_mail@example.com') + +Setting Scopes +"""""""""""""" +- You can set a list of scopes that your like to use, a huge list is available on `Microsoft Documentation `_ +- We have built a custom list make this scopes easier + + ========================= ========================================= ================================================== + Short Scope Name Description Scopes Included + ========================= ========================================= ================================================== + basic Read User Info ['User.Read'] + mailbox Read your mail ['Mail.Read'] + mailbox_shared Read shared mailbox ['Mail.Read.Shared'] + mailbox_settings Manage mailbox settings ['MailboxSettings.ReadWrite'] + message_send Send from your mailbox ['Mail.Send'] + message_send_shared Send using shared mailbox ['Mail.Send.Shared'] + message_all Full access to your mailbox ['Mail.ReadWrite', 'Mail.Send'] + message_all_shared Full access to shared mailbox ['Mail.ReadWrite.Shared', 'Mail.Send.Shared'] + address_book Read your Contacts ['Contacts.Read'] + address_book_shared Read shared contacts ['Contacts.Read.Shared'] + address_book_all Read/Write your Contacts ['Contacts.ReadWrite'] + address_book_all_shared Read/Write your Contacts ['Contacts.ReadWrite.Shared'] + calendar Read your Calendars ['Calendars.Read'] + calendar_shared Read shared Calendars ['Calendars.Read.Shared'] + calendar_all Full access to your Calendars ['Calendars.ReadWrite'] + calendar_shared_all Full access to your shared Calendars ['Calendars.ReadWrite.Shared'] + users Read info of all users ['User.ReadBasic.All'] + onedrive Read access to OneDrive ['Files.Read.All'] + onedrive_all Full access to OneDrive ['Files.ReadWrite.All'] + sharepoint Read access to Sharepoint ['Sites.Read.All'] + sharepoint_all Full access to Sharepoint ['Sites.ReadWrite.All'] + tasks Read access to Tasks ['Tasks.Read'] + tasks_all Full access to Tasks ['Tasks.ReadWrite'] + presence Read access to Presence ['Presence.Read'] + ========================= ========================================= ================================================== + +.. code-block:: python + + # Full permission to your mail + account = Account(credentials=('my_client_id', 'my_client_secret'), + requested_scopes=['message_all']) + + # Why change every time, add all at a time :) + account = Account(credentials=('my_client_id', 'my_client_secret'), + requested_scopes=['message_all', 'message_all_shared', 'address_book_all', + 'address_book_all_shared', + 'calendar', 'users', 'onedrive', 'sharepoint_dl']) + + +Authenticating your Account +^^^^^^^^^^^^^^^^^^^^^^^^^^^ +.. code-block:: python + + account = Account(credentials=('my_client_id', 'my_client_secret')) + account.authenticate() + +.. warning:: The call to authenticate is only required when you haven't authenticated before. If you already did the token file would have been saved + +The authenticate() method forces an authentication flow, which prints out a url + +#. Open the printed url +#. Give consent(approve) to the application +#. You will be redirected out outlook home page, copy the resulting url + .. note:: If the url is simply https://outlook.office.com/owa/?realm=blahblah, and nothing else after that, then you are currently on new Outlook look, revert to old look and try the authentication flow again +#. Paste the resulting URL into the python console. +#. That's it, you don't need this hassle again unless you want to add more scopes than you approved for + + +Account Class and Modularity +============================ +Usually you will only need to work with the ``Account`` Class. This is a wrapper around all functionality. + +But you can also work only with the pieces you want. + +For example, instead of: + +.. code-block:: python + + from O365 import Account + + account = Account(('client_id', 'client_secret')) + message = account.new_message() + # ... + mailbox = account.mailbox() + # ... + +You can work only with the required pieces: + +.. code-block:: python + + from O365 import Connection, MSGraphProtocol + from O365.message import Message + from O365.mailbox import MailBox + + protocol = MSGraphProtocol() + requested_scopes = ['...'] + con = Connection(('client_id', 'client_secret'), requested_scopes=requested_scopes) + + message = Message(con=con, protocol=protocol) + # ... + mailbox = MailBox(con=con, protocol=protocol) + message2 = Message(parent=mailbox) # message will inherit the connection and protocol from mailbox when using parent. + # ... + +It's also easy to implement a custom Class. Just Inherit from ApiComponent, define the endpoints, and use the connection to make requests. If needed also inherit from Protocol to handle different communications aspects with the API server. + +.. code-block:: python + + from O365.utils import ApiComponent + + class CustomClass(ApiComponent): + _endpoints = {'my_url_key': '/customendpoint'} + + def __init__(self, *, parent=None, con=None, **kwargs): + # connection is only needed if you want to communicate with the api provider + self.con = parent.con if parent else con + protocol = parent.protocol if parent else kwargs.get('protocol') + main_resource = parent.main_resource + + super().__init__(protocol=protocol, main_resource=main_resource) + # ... + + def do_some_stuff(self): + + # self.build_url just merges the protocol service_url with the endpoint passed as a parameter + # to change the service_url implement your own protocol inheriting from Protocol Class + url = self.build_url(self._endpoints.get('my_url_key')) + + my_params = {'param1': 'param1'} + + response = self.con.get(url, params=my_params) # note the use of the connection here. + + # handle response and return to the user... + + # the use it as follows: + from O365 import Connection, MSGraphProtocol + + protocol = MSGraphProtocol() # or maybe a user defined protocol + con = Connection(('client_id', 'client_secret'), requested_scopes=protocol.get_scopes_for(['...'])) + custom_class = CustomClass(con=con, protocol=protocol) + + custom_class.do_some_stuff() + + +.. _accessing_services: + +.. Accessing Services +.. ^^^^^^^^^^^^^^^^^^ +.. Below are the currently supported services + +.. - Mailbox - Read, Reply or send new mails to others +.. .. code-block:: python + +.. # Access Mailbox +.. mailbox = account.mailbox() + +.. # Access mailbox of another resource +.. mailbox = account.mailbox(resource='someone@example.com') + +.. - Address Book - Read or add new contacts to your address book +.. .. code-block:: python + +.. # Access personal address book +.. contacts = account.address_book() + +.. # Access personal address book of another resource +.. contacts = account.mailbox(resource='someone@example.com') + +.. # Access global shared server address book (Global Address List) +.. contacts = account.mailbox(address_book='gal') + +.. - Calendar Scheduler - Read or add new events to your calendar +.. .. code-block:: python + +.. # Access scheduler +.. scheduler = account.schedule() + +.. # Access scheduler of another resource +.. scheduler = account.schedule(resource='someone@example.com') + +.. - One Drive or Sharepoint Storage - Manipulate and Organize your Storage Drives +.. .. code-block:: python + +.. # Access storage +.. storage = account.storage() + +.. # Access storage of another resource +.. storage = account.storage(resource='someone@example.com') + +.. - Sharepoint Sites - Read and access items in your sharepoint sites +.. .. code-block:: python + +.. # Access sharepoint +.. sharepoint = account.sharepoint() + +.. # Access sharepoint of another resource +.. sharepoint = account.sharepoint(resource='someone@example.com') + diff --git a/docs/source/usage/addressbook.rst b/docs/source/usage/addressbook.rst new file mode 100644 index 00000000..9cf529a6 --- /dev/null +++ b/docs/source/usage/addressbook.rst @@ -0,0 +1,103 @@ +Address Book +============ +AddressBook groups the functionality of both the Contact Folders and Contacts. Outlook Distribution Groups are not supported (By the Microsoft API's). + +These are the scopes needed to work with the ``AddressBook`` and ``Contact`` classes. + +========================= ======================================= ====================================== +Raw Scope Included in Scope Helper Description +========================= ======================================= ====================================== +Contacts.Read address_book To only read my personal contacts +Contacts.Read.Shared address_book_shared To only read another user / shared mailbox contacts +Contacts.ReadWrite address_book_all To read and save personal contacts +Contacts.ReadWrite.Shared address_book_all_shared To read and save contacts from another user / shared mailbox +User.ReadBasic.All users To only read basic properties from users of my organization (User.Read.All requires administrator consent). +========================= ======================================= ====================================== + +Contact Folders +--------------- +Represents a Folder within your Contacts Section in Office 365. AddressBook class represents the parent folder (it's a folder itself). + +You can get any folder in your address book by requesting child folders or filtering by name. + +.. code-block:: python + + address_book = account.address_book() + + contacts = address_book.get_contacts(limit=None) # get all the contacts in the Personal Contacts root folder + + work_contacts_folder = address_book.get_folder(folder_name='Work Contacts') # get a folder with 'Work Contacts' name + + message_to_all_contats_in_folder = work_contacts_folder.new_message() # creates a draft message with all the contacts as recipients + + message_to_all_contats_in_folder.subject = 'Hallo!' + message_to_all_contats_in_folder.body = """ + George Best quote: + + If you'd given me the choice of going out and beating four men and smashing a goal in + from thirty yards against Liverpool or going to bed with Miss World, + it would have been a difficult choice. Luckily, I had both. + """ + message_to_all_contats_in_folder.send() + + # querying folders is easy: + child_folders = address_book.get_folders(25) # get at most 25 child folders + + for folder in child_folders: + print(folder.name, folder.parent_id) + + # creating a contact folder: + address_book.create_child_folder('new folder') + +.. _global_address_list: + +Global Address List +------------------- +MS Graph API has no concept such as the Outlook Global Address List. +However you can use the `Users API `_ to access all the users within your organization. + +Without admin consent you can only access a few properties of each user such as name and email and little more. You can search by name or retrieve a contact specifying the complete email. + +* Basic Permission needed is Users.ReadBasic.All (limit info) +* Full Permission is Users.Read.All but needs admin consent. + +To search the Global Address List (Users API): + +.. code-block:: python + + global_address_list = account.directory() + + # for backwards compatibility only this also works and returns a Directory object: + # global_address_list = account.address_book(address_book='gal') + + # start a new query: + builder = global_address_list.new_query() + query = builder.startswith('display_name', 'George Best') + + for user in global_address_list.get_users(query=q): + print(user) + +To retrieve a contact by their email: + +.. code-block:: python + + contact = global_address_list.get_user('example@example.com') + Contacts + + Everything returned from an AddressBook instance is a Contact instance. Contacts have all the information stored as attributes + + Creating a contact from an AddressBook: + + new_contact = address_book.new_contact() + + new_contact.name = 'George Best' + new_contact.job_title = 'football player' + new_contact.emails.add('george@best.com') + + new_contact.save() # saved on the cloud + + message = new_contact.new_message() # Bonus: send a message to this contact + + # ... + + new_contact.delete() # Bonus: deleted from the cloud \ No newline at end of file diff --git a/docs/source/usage/calendar.rst b/docs/source/usage/calendar.rst new file mode 100644 index 00000000..971fe45a --- /dev/null +++ b/docs/source/usage/calendar.rst @@ -0,0 +1,86 @@ +Calendar +======== +The calendar and events functionality is group in a Schedule object. + +A ``Schedule`` instance can list and create calendars. It can also list or create events on the default user calendar. To use other calendars use a ``Calendar`` instance. + +These are the scopes needed to work with the ``Schedule``, ``Calendar`` and ``Event`` classes. + +========================== ======================================= ====================================== +Raw Scope Included in Scope Helper Description +========================== ======================================= ====================================== +Calendars.Read calendar To only read my personal calendars +Calendars.Read.Shared calendar_shared To only read another user / shared mailbox calendars +Calendars.ReadWrite calendar_all To read and save personal calendars +Calendars.ReadWrite.Shared calendar_shared_all To read and save calendars from another user / shared mailbox +========================== ======================================= ====================================== + +Working with the ``Schedule`` instance: + +.. code-block:: python + + import datetime as dt + + # ... + schedule = account.schedule() + + calendar = schedule.get_default_calendar() + new_event = calendar.new_event() # creates a new unsaved event + new_event.subject = 'Recruit George Best!' + new_event.location = 'England' + + # naive datetimes will automatically be converted to timezone aware datetime + # objects using the local timezone detected or the protocol provided timezone + + new_event.start = dt.datetime(2019, 9, 5, 19, 45) + # so new_event.start becomes: datetime.datetime(2018, 9, 5, 19, 45, tzinfo=) + + new_event.recurrence.set_daily(1, end=dt.datetime(2019, 9, 10)) + new_event.remind_before_minutes = 45 + + new_event.save() + +Working with Calendar instances: + +.. code-block:: python + + calendar = schedule.get_calendar(calendar_name='Birthdays') + + builder = calendar.new_query() + calendar.name = 'Football players birthdays' + calendar.update() + + + start_q = builder.greater_equal('start', dt.datetime(2018, 5, 20)) + end_q = builder.less_equal('start', dt.datetime(2018, 5, 24)) + + birthdays = calendar.get_events( + include_recurring=True, # include_recurring=True will include repeated events on the result set. + start_recurring=start_q, + end_recurring=end_q, + ) + + for event in birthdays: + if event.subject == 'George Best Birthday': + # He died in 2005... but we celebrate anyway! + event.accept("I'll attend!") # send a response accepting + else: + event.decline("No way I'm coming, I'll be in Spain", send_response=False) # decline the event but don't send a response to the organizer + +**Notes regarding Calendars and Events**: + +1. Include_recurring=True: + + It's important to know that when querying events with include_recurring=True (which is the default), + it is required that you must provide start and end parameters, these may be simple date strings, python dates or individual queries. + Unlike when using include_recurring=False those attributes will NOT filter the data based on the operations you set on the query (greater_equal, less, etc.) + but just filter the events start datetime between the provided start and end datetimes. + +2. Shared Calendars: + + There are some known issues when working with `shared calendars `_ in Microsoft Graph. + +3. Event attachments: + + For some unknown reason, Microsoft does not allow to upload an attachment at the event creation time (as opposed with message attachments). + See `this `_. So, to upload attachments to Events, first save the event, then attach the message and save again. \ No newline at end of file diff --git a/docs/source/usage/connection.rst b/docs/source/usage/connection.rst new file mode 100644 index 00000000..0a1dcbbf --- /dev/null +++ b/docs/source/usage/connection.rst @@ -0,0 +1,92 @@ +Protocols +========= +Protocols handles the aspects of communications between different APIs. This project uses the Microsoft Graph APIs. But, you can use many other Microsoft APIs as long as you implement the protocol needed. + +You can use: + +* MSGraphProtocol to use the `Microsoft Graph API `_ + +.. code-block:: python + + from O365 import Account + + credentials = ('client_id', 'client_secret') + + account = Account(credentials, auth_flow_type='credentials', tenant_id='my_tenant_id') + if account.authenticate(): + print('Authenticated!') + mailbox = account.mailbox('sender_email@my_domain.com') + m = mailbox.new_message() + m.to.add('to_example@example.com') + m.subject = 'Testing!' + m.body = "George Best quote: I've stopped drinking, but only while I'm asleep." + m.save_message() + m.attachment.add = 'filename.txt' + m.send() + +The default protocol used by the ``Account`` Class is ``MSGraphProtocol``. + +You can implement your own protocols by inheriting from Protocol to communicate with other Microsoft APIs. + +You can instantiate and use protocols like this: + +.. code-block:: python + + from O365 import Account, MSGraphProtocol # same as from O365.connection import MSGraphProtocol + + # ... + + # try the api version beta of the Microsoft Graph endpoint. + protocol = MSGraphProtocol(api_version='beta') # MSGraphProtocol defaults to v1.0 api version + account = Account(credentials, protocol=protocol) + + +Resources +========= +Each API endpoint requires a resource. This usually defines the owner of the data. Every protocol defaults to resource 'ME'. 'ME' is the user which has given consent, but you can change this behaviour by providing a different default resource to the protocol constructor. + +.. note:: + + When using the "with your own identity" authentication method the resource 'ME' is overwritten to be blank as the authentication method already states that you are login with your own identity. + +For example when accessing a shared mailbox: + +.. code-block:: python + + # ... + account = Account(credentials=my_credentials, main_resource='shared_mailbox@example.com') + # Any instance created using account will inherit the resource defined for account. + +This can be done however at any point. For example at the protocol level: + +.. code-block:: python + + # ... + protocol = MSGraphProtocol(default_resource='shared_mailbox@example.com') + + account = Account(credentials=my_credentials, protocol=protocol) + + # now account is accessing the shared_mailbox@example.com in every api call. + shared_mailbox_messages = account.mailbox().get_messages() + +Instead of defining the resource used at the account or protocol level, you can provide it per use case as follows: + +.. code-block:: python + + # ... + account = Account(credentials=my_credentials) # account defaults to 'ME' resource + + mailbox = account.mailbox('shared_mailbox@example.com') # mailbox is using 'shared_mailbox@example.com' resource instead of 'ME' + + # or: + + message = Message(parent=account, main_resource='shared_mailbox@example.com') # message is using 'shared_mailbox@example.com' resource + +Usually you will work with the default 'ME' resource, but you can also use one of the following: + +* 'me': the user which has given consent. The default for every protocol. Overwritten when using "with your own identity" authentication method (Only available on the authorization auth_flow_type). +* 'user:user@domain.com': a shared mailbox or a user account for which you have permissions. If you don't provide 'user:' it will be inferred anyway. +* 'site:sharepoint-site-id': a Sharepoint site id. +* 'group:group-site-id': an Microsoft 365 group id. + +By setting the resource prefix (such as 'user:' or 'group:') you help the library understand the type of resource. You can also pass it like 'users/example@exampl.com'. The same applies to the other resource prefixes. \ No newline at end of file diff --git a/docs/source/usage/directory.rst b/docs/source/usage/directory.rst new file mode 100644 index 00000000..8ba31933 --- /dev/null +++ b/docs/source/usage/directory.rst @@ -0,0 +1,32 @@ + +Directory and Users +=================== +The Directory object can retrieve users. + +A User instance contains by default the `basic properties of the user `_. If you want to include more, you will have to select the desired properties manually. + +Check :ref:`global_address_list` for further information. + +These are the scopes needed to work with the Directory class. + +========================= ======================================= ====================================== +Raw Scope Included in Scope Helper Description +========================= ======================================= ====================================== +User.ReadBasic.All users To read a basic set of profile properties of other users in your organization on behalf of the signed-in user. This includes display name, first and last name, email address, open extensions and photo. Also allows the app to read the full profile of the signed-in user. +User.Read.All — To read the full set of profile properties, reports, and managers of other users in your organization, on behalf of the signed-in user. +User.ReadWrite.All — To read and write the full set of profile properties, reports, and managers of other users in your organization, on behalf of the signed-in user. Also allows the app to create and delete users as well as reset user passwords on behalf of the signed-in user. +Directory.Read.All — To read data in your organization's directory, such as users, groups and apps, without a signed-in user. +Directory.ReadWrite.All — To read and write data in your organization's directory, such as users, and groups, without a signed-in user. Does not allow user or group deletion. +========================= ======================================= ====================================== + +.. note:: + + To get authorized with the above scopes you need a work or school account, it doesn't work with personal account. + +Working with the ``Directory`` instance to read the active directory users: + +.. code-block:: python + + directory = account.directory() + for user in directory.get_users(): + print(user) diff --git a/docs/source/usage/excel.rst b/docs/source/usage/excel.rst new file mode 100644 index 00000000..902528fc --- /dev/null +++ b/docs/source/usage/excel.rst @@ -0,0 +1,71 @@ +Excel +===== +You can interact with new Excel files (.xlsx) stored in OneDrive or a SharePoint Document Library. You can retrieve workbooks, worksheets, tables, and even cell data. You can also write to any excel online. + +To work with Excel files, first you have to retrieve a ``File`` instance using the OneDrive or SharePoint functionality. + +The scopes needed to work with the ``WorkBook`` and Excel related classes are the same used by OneDrive. + +This is how you update a cell value: + +.. code-block:: python + + from O365.excel import WorkBook + + # given a File instance that is a xlsx file ... + excel_file = WorkBook(my_file_instance) # my_file_instance should be an instance of File. + + ws = excel_file.get_worksheet('my_worksheet') + cella1 = ws.get_range('A1') + cella1.values = 35 + cella1.update() + +Workbook Sessions +----------------- + +When interacting with Excel, you can use a workbook session to efficiently make changes in a persistent or nonpersistent way. These sessions become usefull if you perform numerous changes to the Excel file. + +The default is to use a session in a persistent way. Sessions expire after some time of inactivity. When working with persistent sessions, new sessions will automatically be created when old ones expire. + +You can however change this when creating the ``Workbook`` instance: + +.. code-block:: python + + excel_file = WorkBook(my_file_instance, use_session=False, persist=False) + +Available Objects +----------------- + +After creating the ``WorkBook`` instance you will have access to the following objects: + +* WorkSheet +* Range and NamedRange +* Table, TableColumn and TableRow +* RangeFormat (to format ranges) +* Charts (not available for now) + +Some examples: + +Set format for a given range + +.. code-block:: python + + # ... + my_range = ws.get_range('B2:C10') + fmt = myrange.get_format() + fmt.font.bold = True + fmt.update() + +Autofit Columns: + +.. code-block:: python + + ws.get_range('B2:C10').get_format().auto_fit_columns() + +Get values from Table: + +.. code-block:: python + + table = ws.get_table('my_table') + column = table.get_column_at_index(1) + values = column.values[0] # values returns a two-dimensional array. diff --git a/docs/source/usage/group.rst b/docs/source/usage/group.rst new file mode 100644 index 00000000..54fbd4a9 --- /dev/null +++ b/docs/source/usage/group.rst @@ -0,0 +1,36 @@ +Group +===== +Groups enables viewing of groups + +These are the scopes needed to work with the ``Group`` classes. + +========================= ======================================= ====================================== +Raw Scope Included in Scope Helper Description +========================= ======================================= ====================================== +Group.Read.All — To read groups +========================= ======================================= ====================================== + +Assuming an authenticated account and a previously created group, create a Plan instance. + +.. code-block:: python + + #Create a plan instance + from O365 import Account + account = Account(('app_id', 'app_pw')) + groups = account.groups() + + # To retrieve the list of groups + group_list = groups.list_groups() + + # Or to retrieve a list of groups for a given user + user_groups = groups.get_user_groups(user_id="object_id") + + # To retrieve a group by an identifier + group = groups.get_group_by_id(group_id="object_id") + group = groups.get_group_by_mail(group_mail="john@doe.com") + + + # To retrieve the owners and members of a group + owners = group.get_group_owners() + members = group.get_group_members() + diff --git a/docs/source/usage/mailbox.rst b/docs/source/usage/mailbox.rst new file mode 100644 index 00000000..dc71711b --- /dev/null +++ b/docs/source/usage/mailbox.rst @@ -0,0 +1,295 @@ +Mailbox +======= +Mailbox groups the functionality of both the messages and the email folders. + +These are the scopes needed to work with the ``MailBox`` and ``Message`` classes. + +========================= ======================================= ====================================== +Raw Scope Included in Scope Helper Description +========================= ======================================= ====================================== +Mail.Read mailbox To only read my mailbox +Mail.Read.Shared mailbox_shared To only read another user / shared mailboxes +Mail.Send message_send, message_all To only send message +Mail.Send.Shared message_send_shared, message_all_shared To only send message as another user / shared mailbox +Mail.ReadWrite message_all To read and save messages in my mailbox +MailboxSettings.ReadWrite mailbox_settings To read and write user mailbox settings +========================= ======================================= ====================================== + +.. Useful Methods +.. ^^^^^^^^^^^^^^^^^^^^^^^^^ +.. `get_folder()` and `get_folders()` are useful to fetch folders that are available under the current instance + +.. Get Single Folder +.. """"""""""""""""" +.. **Using Name** + +.. Using name to get a folder will only search the folders directly under the current folder or root + +.. .. code-block:: python + +.. # By Name - Will only find direct child folder +.. mail_folder = mailbox.get_folder(folder_name='Todo') + +.. # By Name - If Todo folder is under Inbox folder +.. mail_folder = (mailbox.get_folder(folder_name='Inbox') +.. .get_folder(folder_name='Todo')) + +.. **Using ID** + +.. As opposed to getting folder by name, using the id you can fetch folder from any child + +.. .. code-block:: python + +.. # Assuming we are getting folder Todo under Inbox +.. mail_folder = mailbox.get_folder(folder_id='some_id_you_may_have_obtained') + +.. **Well Known Folders** + +.. There are few well know folders like **Inbox**, **Drafts**, etc.. +.. As they are generally used we have added functions to quickly access them + +.. .. code-block:: python + +.. # Inbox +.. mail_folder = mailbox.inbox_folder() + +.. # DeletedItems +.. mail_folder = mailbox.deleted_folder() + +.. # Drafts +.. mail_folder = mailbox.drafts_folder() + +.. # Junk +.. mail_folder = mailbox.junk_folder() + +.. # Outbox +.. mail_folder = mailbox.outbox_folder() + +.. Get Child Folders +.. """"""""""""""""" +.. **All or Some Child Folders** + +.. .. code-block:: python + +.. # All child folders under root +.. mail_folders = mailbox.get_folders() + +.. # All child folders under Inbox +.. mail_folders = mailbox.inbox_folder().get_folders() + +.. # Limit the number or results, will get the top x results +.. mail_folders = mailbox.get_folders(limit=7) + +.. **Filter the results** + +.. Query is a class available, that lets you filter results + +.. .. code-block:: python + +.. # All child folders whose name startswith 'Top' +.. mail_folders = mailbox.get_folders(query=mailbox.new_query().startswith('display_name', 'Top')) + +Mailbox and Messages +"""""""""""""""""""" + +.. code-block:: python + + mailbox = account.mailbox() + + inbox = mailbox.inbox_folder() + + for message in inbox.get_messages(): + print(message) + + sent_folder = mailbox.sent_folder() + + for message in sent_folder.get_messages(): + print(message) + + m = mailbox.new_message() + + m.to.add('to_example@example.com') + m.body = 'George Best quote: In 1969 I gave up women and alcohol - it was the worst 20 minutes of my life.' + m.save_draft() + + +Email Folder +"""""""""""" + +Represents a Folder within your email mailbox. + +You can get any folder in your mailbox by requesting child folders or filtering by name. + +.. code-block:: python + + mailbox = account.mailbox() + + archive = mailbox.get_folder(folder_name='archive') # get a folder with 'archive' name + + child_folders = archive.get_folders(25) # get at most 25 child folders of 'archive' folder + + for folder in child_folders: + print(folder.name, folder.parent_id) + + new_folder = archive.create_child_folder('George Best Quotes') + +Message +""""""" + +**An email object with all its data and methods** + +Creating a draft message is as easy as this: + +.. code-block:: python + + message = mailbox.new_message() + message.to.add(['example1@example.com', 'example2@example.com']) + message.sender.address = 'my_shared_account@example.com' # changing the from address + message.body = 'George Best quote: I might go to Alcoholics Anonymous, but I think it would be difficult for me to remain anonymous' + message.attachments.add('george_best_quotes.txt') + message.save_draft() # save the message on the cloud as a draft in the drafts folder + +**Working with saved emails is also easy** + +.. code-block:: python + + builder = mailbox.new_query() + query = builder.chain_or(builder.contains('subject', 'george best'), builder.startswith('subject', 'quotes') # see Query object in Utils + messages = mailbox.get_messages(limit=25, query=query) + + message = messages[0] # get the first one + + message.mark_as_read() + reply_msg = message.reply() + + if 'example@example.com' in reply_msg.to: # magic methods implemented + reply_msg.body = 'George Best quote: I spent a lot of money on booze, birds and fast cars. The rest I just squandered.' + else: + reply_msg.body = 'George Best quote: I used to go missing a lot... Miss Canada, Miss United Kingdom, Miss World.' + + reply_msg.send() + +**Sending Inline Images** + +You can send inline images by doing this: + +.. code-block:: python + + # ... + msg = account.new_message() + msg.to.add('george@best.com') + msg.attachments.add('my_image.png') + att = msg.attachments[0] # get the attachment object + + # this is super important for this to work. + att.is_inline = True + att.content_id = 'image.png' + + # notice we insert an image tag with source to: "cid:{content_id}" + body = """ + + + There should be an image here: +

+ +

+ + + """ + msg.body = body + msg.send() + +**Retrieving Message Headers** + +You can retrieve message headers by doing this: + +.. code-block:: python + + # ... + mb = account.mailbox() + msg = mb.get_message(query=mb.q().select('internet_message_headers')) + print(msg.message_headers) # returns a list of dicts. + +Note that only message headers and other properties added to the select statement will be present. + +**Saving as EML** + +Messages and attached messages can be saved as ``*.eml``. + +Save message as "eml": + +.. code-block:: python + + msg.save_as_eml(to_path=Path('my_saved_email.eml')) + +**Save attached message as "eml"** + +Careful: there's no way to identify that an attachment is in fact a message. You can only check if the attachment.attachment_type == 'item'. if is of type "item" then it can be a message (or an event, etc...). You will have to determine this yourself. + +.. code-block:: python + + msg_attachment = msg.attachments[0] # the first attachment is attachment.attachment_type == 'item' and I know it's a message. + msg.attachments.save_as_eml(msg_attachment, to_path=Path('my_saved_email.eml')) + +Mailbox Settings +"""""""""""""""" +The mailbox settings and associated methods. + +Retrieve and update mailbox auto reply settings: + +.. code-block:: python + + from O365.mailbox import AutoReplyStatus, ExternalAudience + + mailboxsettings = mailbox.get_settings() + ars = mailboxsettings.automaticrepliessettings + + ars.scheduled_startdatetime = start # Sets the start date/time + ars.scheduled_enddatetime = end # Sets the end date/time + ars.status = AutoReplyStatus.SCHEDULED # DISABLED/SCHEDULED/ALWAYSENABLED - Uses start/end date/time if scheduled. + ars.external_audience = ExternalAudience.NONE # NONE/CONTACTSONLY/ALL + ars.internal_reply_message = "ARS Internal" # Internal message + ars.external_reply_message = "ARS External" # External message + mailboxsettings.save() + Alternatively to enable and disable + + mailboxsettings.save() + + mailbox.set_automatic_reply( + "Internal", + "External", + scheduled_start_date_time=start, # Status will be 'scheduled' if start/end supplied, otherwise 'alwaysEnabled' + scheduled_end_date_time=end, + externalAudience=ExternalAudience.NONE, # Defaults to ALL + ) + mailbox.set_disable_reply() + + +Outlook Categories +"""""""""""""""""" +You can retrieve, update, create and delete outlook categories. These categories can be used to categorize Messages, Events and Contacts. + +These are the scopes needed to work with the SharePoint and Site classes. + +========================= ======================================= ====================================== +Raw Scope Included in Scope Helper Description +========================= ======================================= ====================================== +MailboxSettings.Read — To only read outlook settings +MailboxSettings.ReadWrite mailbox_settings To read and write outlook settings +========================= ======================================= ====================================== + +Example: + +.. code-block:: python + + from O365.category import CategoryColor + + oc = account.outlook_categories() + categories = oc.get_categories() + for category in categories: + print(category.name, category.color) + + my_category = oc.create_category('Important Category', color=CategoryColor.RED) + my_category.update_color(CategoryColor.DARKGREEN) + + my_category.delete() # oops! diff --git a/docs/source/usage/onedrive.rst b/docs/source/usage/onedrive.rst new file mode 100644 index 00000000..a3b34adc --- /dev/null +++ b/docs/source/usage/onedrive.rst @@ -0,0 +1,109 @@ +OneDrive +======== +The ``Storage`` class handles all functionality around One Drive and Document Library Storage in SharePoint. + +The ``Storage`` instance allows retrieval of ``Drive`` instances which handles all the Files +and Folders from within the selected ``Storage``. Usually you will only need to work with the +default drive. But the ``Storage`` instances can handle multiple drives. + +A ``Drive`` will allow you to work with Folders and Files. + +These are the scopes needed to work with the ``Storage``, ``Drive`` and ``DriveItem`` classes. + +========================= ======================================= ====================================== +Raw Scope Included in Scope Helper Description +========================= ======================================= ====================================== +Files.Read — To only read my files +Files.Read.All onedrive To only read all the files the user has access +Files.ReadWrite — To read and save my files +Files.ReadWrite.All onedrive_all To read and save all the files the user has access +========================= ======================================= ====================================== + +.. code-block:: python + + account = Account(credentials=my_credentials) + + storage = account.storage() # here we get the storage instance that handles all the storage options. + + # list all the drives: + drives = storage.get_drives() + + # get the default drive + my_drive = storage.get_default_drive() # or get_drive('drive-id') + + # get some folders: + root_folder = my_drive.get_root_folder() + attachments_folder = my_drive.get_special_folder('attachments') + + # iterate over the first 25 items on the root folder + for item in root_folder.get_items(limit=25): + if item.is_folder: + print(list(item.get_items(2))) # print the first to element on this folder. + elif item.is_file: + if item.is_photo: + print(item.camera_model) # print some metadata of this photo + elif item.is_image: + print(item.dimensions) # print the image dimensions + else: + # regular file: + print(item.mime_type) # print the mime type + +Both Files and Folders are DriveItems. Both Image and Photo are Files, but Photo is also an Image. All have some different methods and properties. Take care when using 'is_xxxx'. + +When copying 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. + +.. code-block:: python + + # copy a file to the documents special folder + + documents_folder = my_drive.get_special_folder('documents') + + files = my_drive.search('george best quotes', limit=1) + + if files: + george_best_quotes = files[0] + operation = george_best_quotes.copy(target=documents_folder) # operation here is an instance of CopyOperation + + # to check for the result just loop over check_status. + # check_status is a generator that will yield a new status and progress until the file is finally copied + for status, progress in operation.check_status(): # if it's an async operations, this will request to the api for the status in every loop + print(f"{status} - {progress}") # prints 'in progress - 77.3' until finally completed: 'completed - 100.0' + copied_item = operation.get_item() # the copy operation is completed so you can get the item. + if copied_item: + copied_item.delete() # ... oops! + +You can also work with share permissions: + +.. code-block:: python + + current_permisions = file.get_permissions() # get all the current permissions on this drive_item (some may be inherited) + + # share with link + permission = file.share_with_link(share_type='edit') + if permission: + print(permission.share_link) # the link you can use to share this drive item + # share with invite + permission = file.share_with_invite(recipients='george_best@best.com', send_email=True, message='Greetings!!', share_type='edit') + if permission: + print(permission.granted_to) # the person you share this item with + +You can also: + +.. code-block:: python + + # download files: + file.download(to_path='/quotes/') + + # upload files: + + # if the uploaded file is bigger than 4MB the file will be uploaded in chunks of 5 MB until completed. + # this can take several requests and can be time consuming. + uploaded_file = folder.upload_file(item='path_to_my_local_file') + + # restore versions: + versions = file.get_versions() + for version in versions: + if version.name == '2.0': + version.restore() # restore the version 2.0 of this file + + # ... and much more ... \ No newline at end of file diff --git a/docs/source/usage/planner.rst b/docs/source/usage/planner.rst new file mode 100644 index 00000000..c563051e --- /dev/null +++ b/docs/source/usage/planner.rst @@ -0,0 +1,56 @@ +Planner +======= +Planner enables the creation and maintenance of plans, buckets and tasks + +These are the scopes needed to work with the ``Planner`` classes. + +========================= ======================================= ====================================== +Raw Scope Included in Scope Helper Description +========================= ======================================= ====================================== +Group.Read.All — To only read plans +Group.ReadWrite.All — To create and maintain a plan +========================= ======================================= ====================================== + +Assuming an authenticated account and a previously created group, create a Plan instance. + +.. code-block:: python + + #Create a plan instance + from O365 import Account + account = Account(('app_id', 'app_pw')) + planner = account.planner() + plan = planner.create_plan( + owner="group_object_id", title="Test Plan" + ) + +| Common commands for :code:`planner` include :code:`.create_plan()`, :code:`.get_bucket_by_id()`, :code:`.get_my_tasks()`, :code:`.list_group_plans()`, :code:`.list_group_tasks()` and :code:`.delete()`. +| Common commands for :code:`plan` include :code:`.create_bucket()`, :code:`.get_details()`, :code:`.list_buckets()`, :code:`.list_tasks()` and :code:`.delete()`. + +Then to create a bucket within a plan. + +.. code-block:: python + + #Create a bucket instance in a plan + bucket = plan.create_bucket(name="Test Bucket") + +Common commands for :code:`bucket` include :code:`.list_tasks()` and :code:`.delete()`. + +Then to create a task, assign it to a user, set it to 50% completed and add a description. + +.. code-block:: python + + #Create a task in a bucket + assignments = { + "user_object_id: { + "@odata.type": "microsoft.graph.plannerAssignment", + "orderHint": "1 !", + } + } + task = bucket.create_task(title="Test Task", assignments=assignments) + + task.update(percent_complete=50) + + task_details = task.get_details() + task_details.update(description="Test Description") + +Common commands for :code:`task` include :code:`.get_details()`, :code:`.update()` and :code:`.delete()`. \ No newline at end of file diff --git a/docs/source/usage/sharepoint.rst b/docs/source/usage/sharepoint.rst new file mode 100644 index 00000000..62a727c3 --- /dev/null +++ b/docs/source/usage/sharepoint.rst @@ -0,0 +1,115 @@ +Sharepoint +========== + +These are the scopes needed to work with the SharePoint and Site classes. + +========================= ======================================= ======================================= +Raw Scope Included in Scope Helper Description +========================= ======================================= ======================================= +Sites.Selected *None* Sites a permission was granted by admins (see https://github.com/O365/python-o365/issues/1122) +Sites.Read.All sharepoint To only read sites, lists and items +Sites.ReadWrite.All sharepoint_dl To read and save sites, lists and items +========================= ======================================= ======================================= + +Note that using the :code:`.All` scopes is way less secure than granting permissions to specific sites and using +:code:`Sites.Selected` scope. + +Assuming an authenticated account, create a Sharepoint instance, and connect +to a Sharepoint site. + +.. code-block:: python + + #Create Sharepoint instance and connect to a site + from O365 import Account + acct = Account(('app_id', 'app_pw')) + sp_site = acct.sharepoint().get_site('root', 'path/tosite') + +Common commands for :code:`sp_site` include :code:`.display_name`, +:code:`.get_document_library()`, :code:`.get_subsites()`, :code:`.get_lists()`, +and :code:`.get_list_by_name('list_name')`. + +**Accessing Subsites** + +If a Sharepoint site contains subsites they can be returned as a list of +Sharepoint sites by the :code:`.get_subsites()` function. + +.. code-block:: python + + #Return a List of subsites + sp_site_subsites = sp_site.get_subsites() + print(sp_sites_subsites) + [Site: subsitename1, Site: subsitename2] + + #Make another Site object from a desired subsite + new_sp_site = sp_site_subsites[0] #return the first subsite + +Sharepoint Lists +^^^^^^^^^^^^^^^^ + +Sharepoint Lists are accessible from their Sharepoint site using :code:`.get_lists()` which +returns a Python list of Sharepoint list objects. A known list can be accessed +by providing a :code:`list_name` to :code:`.get_list_by_name('list_name')` which will return +the requested list as a :code:`sharepointlist` object. + +.. code-block:: python + + #Return a list of sharepoint lists + sp_site_lists = sp_site.get_lists() + + #Return a specific list by name + sp_list = sp_site.get_list_by_name('list_name') + + +Commmon functions on a Sharepoint list include :code:`.get_list_columns()`, +:code:`.get_items()`, :code:`.get_item_by_id()`, :code:`.create_list_item()`, +:code:`.delete_list_item()`. + + +Sharepoint List Items +""""""""""""""""""""" + +Accessing a list item from a Sharepoint list is done by utilizing :code:`.get_items()`, +or :code:`.get_item_by_id(item_id)`. + +.. code-block:: python + + #Return a list of sharepoint list Items + sp_list_items = sp_list.get_items() + + #Return a specific sharepoint list item by its object ID + sp_list_item = sp_list.get_item_by_id(item_id) + + +**Creating & Deleting Sharepoint Items** + +A Sharepoint list item can be created by passing the new data in a dictionary +consisting of :code:`{'column_name': 'new_data'}`. Not all columns in the Sharepoint list have to +be accounted for in the dictionary, any Sharepoint List column not in the dictionary +will be filled with a blank. The `column_name` must be the internal column name +of the sharepoint list. :code:`.column_name_cw` of a sharepoint list will provide a +dictionary of :code:`{'Display Name': 'Internal Name'}` if needed. + +.. code-block:: python + + #Create a new sharepoint list item + new_item = sp_list.create_list_item({'col1': 'New Data Col 1', + 'col2': 'New Data Col 2'}) + + #Delete the item just created + sp_list.delete_list_item(new_item.object_id) #Pass the item ID to be deleted + +**Updating a Sharepoint List Item** + +Sharepoint list items can be updated by passing a dictionary of +:code:`{'column_name': 'Updated Data'}` to the :code:`.update_fields()` function of a +Sharepoint list item. The `column_name` keys of the dictionary must again refer +to the internal column name, otherwise an error will occur. + +.. code-block:: python + + #Update a Sharepoint List item + new_item.update_fields({'col1': 'Updated Data Col1', + 'col2': 'Updated Data Col2'}) + + #Once done updating a sharepoint item save changes to the cloud + new_item.save_updates() #Returns True if successful diff --git a/docs/source/usage/subscriptions.rst b/docs/source/usage/subscriptions.rst new file mode 100644 index 00000000..5f507032 --- /dev/null +++ b/docs/source/usage/subscriptions.rst @@ -0,0 +1,218 @@ +Subscriptions +============= + +Subscriptions provides the ability to create and manage webhook subscriptions for change notifications against Microsoft Graph. Read here for more details on MS Graph subscriptions + +- https://learn.microsoft.com/en-us/graph/api/resources/subscription?view=graph-rest-1.0 +- https://learn.microsoft.com/en-us/graph/change-notifications-delivery-webhooks?tabs=http + +Create a Subscription +^^^^^^^^^^^^^^^^^^^^^ + +Assuming a web host (example uses `flask`) and an authenticated account, create a subscription to be notified about new emails. + +.. code-block:: python + + from flask import Flask, abort, jsonify, request + + RESOURCE = "/me/mailFolders('inbox')/messages" + DEFAULT_EXPIRATION_MINUTES = 10069 # Maximum expiration is 10,070 in the future for Outlook message. + + app = Flask(__name__) + + @app.get("/subscriptions") + def create_subscription(): + """Create a subscription.""" + notification_url = request.args.get("notification_url") + if not notification_url: + abort(400, description="notification_url is required") + + expiration_minutes = int(request.args.get("expiration_minutes", DEFAULT_EXPIRATION_MINUTES)) + client_state = request.args.get("client_state") + resource = request.args.get("resource", RESOURCE) + + subscription = account.subscriptions().create_subscription( + notification_url=notification_url, + resource=resource, + change_type="created", + expiration_minutes=expiration_minutes, + client_state=client_state, + ) + return jsonify(subscription), 201 + + @app.post("/webhook") + def webhook_handler(): + """Handle Microsoft Graph webhook calls. + + - During subscription validation, Graph sends POST with ?validationToken=... . + We must echo the token as plain text within 10 seconds. + - For change notifications, Graph posts JSON; we just log/ack. + """ + validation_token = request.args.get("validationToken") + if validation_token: + # Echo back token exactly as plain text with HTTP 200. + return validation_token, 200, {"Content-Type": "text/plain"} + + # Change notifications: inspect or log as needed. + payload = request.get_json(silent=True) or {} + print("Received notification payload:", payload) + return ("", 202) + +Use this url: + + ``https:///subscriptions?notification_url=https%3A%2F%2F%2Fwebhook&client_state=abc123`` + +HTTP status 201 and the following should be returned: + +.. code-block:: JSON + + { + "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#subscriptions/$entity", + "applicationId": "12345678-bad9-4c34-94d6-f9a1388522f8", + "changeType": "created", + "clientState": "abc123", + "creatorId": "12345678-a5c7-46da-8107-b25090a1ed66", + "encryptionCertificate": null, + "encryptionCertificateId": null, + "expirationDateTime": "2026-01-07T11:20:42.305776Z", + "id": "548355f8-c2c0-47ae-aac7-3ad02b2dfdb1", + "includeResourceData": null, + "latestSupportedTlsVersion": "v1_2", + "lifecycleNotificationUrl": null, + "notificationQueryOptions": null, + "notificationUrl": "https:///webhook", + "notificationUrlAppId": null, + "resource": "/me/mailFolders('inbox')/messages" + } + +List Subscriptions +^^^^^^^^^^^^^^^^^^ + +.. code-block:: python + + @app.get("/subscriptions/list") + def list_subscriptions(): + """List all subscriptions.""" + limit = int(request.args.get("limit")) + subscriptions = account.subscriptions().list_subscriptions(limit=limit) + return jsonify(list(subscriptions)), 200 + +Use this url: + + ``https:///subscriptions/list`` + +HTTP status 200 and the following should be returned: + +.. code-block:: JSON + + [ + { + "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#subscriptions/$entity", + "applicationId": "12345678-bad9-4c34-94d6-f9a1388522f8", + "changeType": "created", + "clientState": "abc123", + "creatorId": "12345678-a5c7-46da-8107-b25090a1ed66", + "encryptionCertificate": null, + "encryptionCertificateId": null, + "expirationDateTime": "2026-01-07T11:20:42.305776Z", + "id": "548355f8-c2c0-47ae-aac7-3ad02b2dfdb1", + "includeResourceData": null, + "latestSupportedTlsVersion": "v1_2", + "lifecycleNotificationUrl": null, + "notificationQueryOptions": null, + "notificationUrl": "https:///webhook", + "notificationUrlAppId": null, + "resource": "/me/mailFolders('inbox')/messages" + } + ] + +Renew a Subscription +^^^^^^^^^^^^^^^^^^^^ + +.. code-block:: python + + @app.get("/subscriptions//renew") + def renew_subscription(subscription_id: str): + """Renew a subscription.""" + expiration_minutes = int(request.args.get("expiration_minutes", DEFAULT_EXPIRATION_MINUTES)) + updated = account.subscriptions().renew_subscription( + subscription_id, + expiration_minutes=expiration_minutes, + ) + return jsonify(updated), 200 + +Use this url: + + ``http:///subscriptions/548355f8-c2c0-47ae-aac7-3ad02b2dfdb1/renew?expiration_minutes=10069`` + +HTTP status 200 and the following should be returned: + +.. code-block:: JSON + + { + "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#subscriptions/$entity", + "applicationId": "12345678-bad9-4c34-94d6-f9a1388522f8", + "changeType": "created", + "clientState": "abc123", + "creatorId": "12345678-a5c7-46da-8107-b25090a1ed66", + "encryptionCertificate": null, + "encryptionCertificateId": null, + "expirationDateTime": "2026-01-07T11:35:40.301594Z", + "id": "548355f8-c2c0-47ae-aac7-3ad02b2dfdb1", + "includeResourceData": null, + "latestSupportedTlsVersion": "v1_2", + "lifecycleNotificationUrl": null, + "notificationQueryOptions": null, + "notificationUrl": "https:///webhook", + "notificationUrlAppId": null, + "resource": "/me/mailFolders('inbox')/messages" + } + +Delete a Subscription +^^^^^^^^^^^^^^^^^^^^^ + +.. code-block:: python + + @app.get("/subscriptions//delete") + def delete_subscription(subscription_id: str): + """Delete a subscription.""" + deleted = account.subscriptions().delete_subscription(subscription_id) + if not deleted: + abort(404, description="Subscription not found") + return ("", 204) + +Use this url: + + ``http:///subscriptions/548355f8-c2c0-47ae-aac7-3ad02b2dfdb1/delete`` + +HTTP status 204 should be returned. + +Webhook +^^^^^^^ + +With a subscription as described above and an email sent to the inbox, a webhook will be received as below: + +.. code-block:: python + + { + 'value': [ + { + 'subscriptionId': '548355f8-c2c0-47ae-aac7-3ad02b2dfdb12', + 'subscriptionExpirationDateTime': '2026-01-07T11:35:40.301594+00:00', + 'changeType': 'created', + 'resource': 'Users/12345678-a5c7-46da-8107-b25090a1ed66/Messages/=', + 'resourceData': { + '@odata.type': '#Microsoft.Graph.Message', + '@odata.id': 'Users/12345678-a5c7-46da-8107-b25090a1ed66/Messages/=', + '@odata.etag': 'W/"CQAAABYACCCoiRErLbiNRJDCFyMjq4khBBnH4N7A"', + 'id': '=' + }, + 'clientState': 'abc123', + 'tenantId': '12345678-abcd-1234-abcd-1234567890ab' + } + ] + } + +The client state should be validated for accuracy and if correct, the message can be acted upon as approriate for the type of subscription. + +An example application can be found in the examples directory here - https://github.com/O365/python-o365/blob/master/examples/subscriptions_example.py \ No newline at end of file diff --git a/docs/source/usage/tasks.rst b/docs/source/usage/tasks.rst new file mode 100644 index 00000000..9d6856fb --- /dev/null +++ b/docs/source/usage/tasks.rst @@ -0,0 +1,57 @@ +Tasks +===== +The tasks functionality is grouped in a ToDo object. + +A ToDo instance can list and create task folders. It can also list or create tasks on the default user folder. To use other folders use a Folder instance. + +These are the scopes needed to work with the ToDo, Folder and Task classes. + +========================= ======================================= ====================================== +Raw Scope Included in Scope Helper Description +========================= ======================================= ====================================== +Tasks.Read tasks To only read my personal tasks +Tasks.ReadWrite tasks_all To read and save personal calendars +========================= ======================================= ====================================== + +Working with the `ToDo`` instance: + +.. code-block:: python + + import datetime as dt + + # ... + todo = account.tasks() + + #list current tasks + folder = todo.get_default_folder() + new_task = folder.new_task() # creates a new unsaved task + new_task.subject = 'Send contract to George Best' + new_task.due = dt.datetime(2020, 9, 25, 18, 30) + new_task.save() + + #some time later.... + + new_task.mark_completed() + new_task.save() + + # naive datetimes will automatically be converted to timezone aware datetime + # objects using the local timezone detected or the protocol provided timezone + # as with the Calendar functionality + +Working with Folder instances: + +.. code-block:: python + + #create a new folder + new_folder = todo.new_folder('Defenders') + + #rename a folder + folder = todo.get_folder(folder_name='Strikers') + folder.name = 'Forwards' + folder.update() + + #list current tasks + task_list = folder.get_tasks() + for task in task_list: + print(task) + print('') \ No newline at end of file diff --git a/docs/source/usage/teams.rst b/docs/source/usage/teams.rst new file mode 100644 index 00000000..97288b3d --- /dev/null +++ b/docs/source/usage/teams.rst @@ -0,0 +1,109 @@ +Teams +===== +Teams enables the communications via Teams Chat, plus Presence management + +These are the scopes needed to work with the ``Teams`` classes. + +========================= ======================================= ====================================== +Raw Scope Included in Scope Helper Description +========================= ======================================= ====================================== +Channel.ReadBasic.All — To read basic channel information +ChannelMessage.Read.All — To read channel messages +ChannelMessage.Send — To send messages to a channel +Chat.Read — To read users chat +Chat.ReadWrite — To read users chat and send chat messages +Presence.Read presence To read users presence status +Presence.Read.All — To read any users presence status +Presence.ReadWrite — To update users presence status +Team.ReadBasic.All — To read only the basic properties for all my teams +User.ReadBasic.All users To only read basic properties from users of my organization (User.Read.All requires administrator consent) +========================= ======================================= ====================================== + +Presence +-------- +Assuming an authenticated account. + +.. code-block:: python + + # Retrieve logged-in user's presence + from O365 import Account + account = Account(('app_id', 'app_pw')) + teams = account.teams() + presence = teams.get_my_presence() + + # Retrieve another user's presence + user = account.directory().get_user("john@doe.com") + presence2 = teams.get_user_presence(user.object_id) + +To set a users status or preferred status: + +.. code-block:: python + + # Set user's presence + from O365.teams import Activity, Availability, PreferredActivity, PreferredAvailability + + status = teams.set_my_presence(CLIENT_ID, Availability.BUSY, Activity.INACALL, "1H") + + # or set User's preferred presence (which is more likely the one you want) + + status = teams.set_my_user_preferred_presence(PreferredAvailability.OFFLINE, PreferredActivity.OFFWORK, "1H") + + +Chat +---- +Assuming an authenticated account. + +.. code-block:: python + + # Retrieve logged-in user's chats + from O365 import Account + account = Account(('app_id', 'app_pw')) + teams = account.teams() + chats = teams.get_my_chats() + + # Then to retrieve chat messages and chat members + for chat in chats: + if chat.chat_type != "unknownFutureValue": + message = chat.get_messages(limit=10) + memberlist = chat.get_members() + + + # And to send a chat message + + chat.send_message(content="Hello team!", content_type="text") + +| Common commands for :code:`Chat` include :code:`.get_member()` and :code:`.get_message()` + + +Team +---- +Assuming an authenticated account. + +.. code-block:: python + + # Retrieve logged-in user's teams + from O365 import Account + account = Account(('app_id', 'app_pw')) + teams = account.teams() + my_teams = teams.get_my_teams() + + # Then to retrieve team channels and messages + for team in my_teams: + channels = team.get_channels() + for channel in channels: + messages = channel.get_messages(limit=10) + for channelmessage in messages: + print(channelmessage) + + + # To send a message to a team channel + channel.send_message("Hello team") + + # To send a reply to a message + channelmessage.send_message("Hello team leader") + +| Common commands for :code:`Teams` include :code:`.create_channel()`, :code:`.get_apps_in_channel()` and :code:`.get_channel()` +| Common commands for :code:`Team` include :code:`.get_channel()` +| Common commands for :code:`Channel` include :code:`.get_message()` +| Common commands for :code:`ChannelMessage` include :code:`.get_replies()` and :code:`.get_reply()` + diff --git a/docs/source/usage/utils.rst b/docs/source/usage/utils.rst new file mode 100644 index 00000000..8c307e19 --- /dev/null +++ b/docs/source/usage/utils.rst @@ -0,0 +1,11 @@ +===== +Utils +===== + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + + utils/query + utils/token + utils/utils diff --git a/docs/source/usage/utils/query.rst b/docs/source/usage/utils/query.rst new file mode 100644 index 00000000..13d497b8 --- /dev/null +++ b/docs/source/usage/utils/query.rst @@ -0,0 +1,52 @@ +Query +===== + +.. _query_builder: + +Query Builder +------------- + +A query can be created for every ``ApiComponent`` (such as ``MailBox``). The ``Query`` can be used to handle the filtering, sorting, selecting, expanding and search very easily. + +For example: + +.. code-block:: python + + builder = mailbox.new_query() + + query = builder.chain_or(builder.contains('subject', 'george best'), builder.startswith('subject', 'quotes') + + # 'created_date_time' will automatically be converted to the protocol casing. + # For example when using MS Graph this will become 'createdDateTime'. + + query = query & builder.greater('created_date_time', datetime(2018, 3, 21)) + + print(query) + + # contains(subject, 'george best') or startswith(subject, 'quotes') and createdDateTime gt '2018-03-21T00:00:00Z' + # note you can pass naive datetimes and those will be converted to you local timezone and then send to the api as UTC in iso8601 format + + # To use Query objects just pass it to the query parameter: + filtered_messages = mailbox.get_messages(query=query) + +You can also specify specific data to be retrieved with "select": + +.. code-block:: python + + # select only some properties for the retrieved messages: + query = builder.select('subject', 'to_recipients', 'created_date_time) + + messages_with_selected_properties = mailbox.get_messages(query=query) + +You can also search content. As said in the graph docs: + + You can currently search only message and person collections. A $search request returns up to 250 results. You cannot use $filter or $orderby in a search request. + + If you do a search on messages and specify only a value without specific message properties, the search is carried out on the default search properties of from, subject, and body. + + .. code-block:: python + + # searching is the easy part ;) + query = builder.search('george best is da boss') + messages = mailbox.get_messages(query=query) + diff --git a/docs/source/usage/utils/token.rst b/docs/source/usage/utils/token.rst new file mode 100644 index 00000000..fffac541 --- /dev/null +++ b/docs/source/usage/utils/token.rst @@ -0,0 +1,34 @@ +Token +===== + +When initiating the account connection you may wish to store the token for ongoing usage, removing the need to re-authenticate every time. There are a variety of storage mechanisms available which are shown in the detailed api. + +FileSystemTokenBackend +---------------------- +To store the token in your local file system, you can use the ``FileSystemTokenBackend``. This takes a path and a file name as parameters. + +For example: + +.. code-block:: python + + from O365 import Account, FileSystemTokenBackend + + token_backend = FileSystemTokenBackend(token_path=token_path, token_filename=token_filename) + + account = Account(credentials=('my_client_id', 'my_client_secret'), token_backend=token_backend) + +The methods are similar for the other token backends. + +You can also pass in a cryptography manager to the token backend so encrypt the token in the store, and to decrypt on retrieval. The cryptography manager must support the ``encrypt`` and ``decrypt`` methods. + +.. code-block:: python + + from O365 import Account, FileSystemTokenBackend + from xxx import CryptoManager + + key = "my really secret key" + mycryptomanager = CryptoManager(key) + + token_backend = FileSystemTokenBackend(token_path=token_path, token_filename=token_filename, cryptography_manager=mycryptomanager) + + account = Account(credentials=('my_client_id', 'my_client_secret'), token_backend=token_backend) \ No newline at end of file diff --git a/docs/source/usage/utils/utils.rst b/docs/source/usage/utils/utils.rst new file mode 100644 index 00000000..5007b340 --- /dev/null +++ b/docs/source/usage/utils/utils.rst @@ -0,0 +1,95 @@ +Utils +===== +Pagination +---------- +When using certain methods, it is possible that you request more items than the api can return in a single api call. In this case the Api, returns a "next link" url where you can pull more data. + +When this is the case, the methods in this library will return a ``Pagination`` object which abstracts all this into a single iterator. The pagination object will request "next links" as soon as they are needed. + +For example: + +.. code-block:: python + + mailbox = account.mailbox() + + messages = mailbox.get_messages(limit=1500) # the MS Graph API have a 999 items limit returned per api call. + + # Here messages is a Pagination instance. It's an Iterator so you can iterate over. + + # The first 999 iterations will be normal list iterations, returning one item at a time. + # When the iterator reaches the 1000 item, the Pagination instance will call the api again requesting exactly 500 items + # or the items specified in the batch parameter (see later). + + for message in messages: + print(message.subject) + +When using certain methods you will have the option to specify not only a limit option (the number of items to be returned) but a batch option. This option will indicate the method to request data to the api in batches until the limit is reached or the data consumed. This is useful when you want to optimize memory or network latency. + +For example: + +.. code-block:: python + + messages = mailbox.get_messages(limit=100, batch=25) + + # messages here is a Pagination instance + # when iterating over it will call the api 4 times (each requesting 25 items). + + for message in messages: # 100 loops with 4 requests to the api server + print(message.subject) + +Query helper +------------ +.. note:: + + This method of creating queries is now deprecated, queries shoould now be created using the ExperimentalQuery methods - :ref:`query_builder` + +Every ``ApiComponent`` (such as ``MailBox``) implements a new_query method that will return a ``Query`` instance. This ``Query`` instance can handle the filtering, sorting, selecting, expanding and search very easily. + +For example: + +.. code-block:: python + + builder = mailbox.new_query() # you can use the shorthand: mailbox.q() + + query = builder.chain_or(builder.contains('subject', 'george best'), builder.startswith('subject', 'quotes') + + # 'created_date_time' will automatically be converted to the protocol casing. + # For example when using MS Graph this will become 'createdDateTime'. + + query = query & builder.greater('created_date_time', datetime(2018, 3, 21)) + + print(query) + + # contains(subject, 'george best') or startswith(subject, 'quotes') and createdDateTime gt '2018-03-21T00:00:00Z' + # note you can pass naive datetimes and those will be converted to you local timezone and then send to the api as UTC in iso8601 format + + # To use Query objetcs just pass it to the query parameter: + filtered_messages = mailbox.get_messages(query=query) + +You can also specify specific data to be retrieved with "select": + +.. code-block:: python + + # select only some properties for the retrieved messages: + query = mailbox.new_query().select('subject', 'to_recipients', 'created_date_time') + + messages_with_selected_properties = mailbox.get_messages(query=query) + +You can also search content. As said in the graph docs: + + You can currently search only message and person collections. A $search request returns up to 250 results. You cannot use $filter or $orderby in a search request. + + If you do a search on messages and specify only a value without specific message properties, the search is carried out on the default search properties of from, subject, and body. + + .. code-block:: python + + # searching is the easy part ;) + query = mailbox.q().search('george best is da boss') + messages = mailbox.get_messages(query=query) + +Request Error Handling +---------------------- +Whenever a Request error raises, the connection object will raise an exception. Then the exception will be captured and logged it to the stdout with its message, and return Falsy (None, False, [], etc...) + +HttpErrors 4xx (Bad Request) and 5xx (Internal Server Error) are considered exceptions and +raised also by the connection. You can tell the ``Connection`` to not raise http errors by passing ``raise_http_errors=False`` (defaults to True). \ No newline at end of file diff --git a/examples/EmailPrinting/emailprinting.lock b/examples/EmailPrinting/emailprinting.lock deleted file mode 100644 index d00491fd..00000000 --- a/examples/EmailPrinting/emailprinting.lock +++ /dev/null @@ -1 +0,0 @@ -1 diff --git a/examples/EmailPrinting/emailprinting.py b/examples/EmailPrinting/emailprinting.py deleted file mode 100644 index 0fef6410..00000000 --- a/examples/EmailPrinting/emailprinting.py +++ /dev/null @@ -1,159 +0,0 @@ -from O365 import * -from printing import * -import json -import os -import sys -import time -import logging - -logging.basicConfig(filename='ep.log',level=logging.DEBUG) - -log = logging.getLogger('ep') - -''' -This script represents a way that O365 could be used to integrate parts of your enviorment. This is -not a theoretical example, this is a production script that I use at our facility. The objective -here is to aliviate a problem where the printer rests inside a protecte network but students who -want to print are outside of that network. By sending print jobs they have to an email address they -do not need to install the printer on their local device, nor do they need direct access to the -device. - -The basic architecture of this script is as follows: -1. spin off as a server. If you don't have access to cron or cron is not working for you, this is a - work around. -2. Global Exception Handling. Because we don't have cron to spin us backup, we need to catch any - problem that mich crash the whole process. -3. Check for messages -4. Check that the sender is from our domain. -5. Create a username that is compatible with the printer (max 8 chars, no punctuation) -6. Verify attachment type -7. Download attachment -8. Send attachment to be printed -5-8: notify the user if there is problems or successes at any point in here. - -Feel free to rework this to your enviorment. You'll want to change the verification method and the -printer.py file to match your needs. -''' - - -def userFromEmail(email): - name = email[:email.index('@')] - fname, lname = name.split('.') - if fname > 7: - fname = fname[:7] - lname = lname[0] - if fname < 4: - lname = lname[:2] - name = fname+lname - log.debug('Exctracted username: {0}'.format(name)) - return name - -def verifyUser(email): - if '@om.org' not in email.lower(): - log.debug('Not an OM address: {0}'.format(email)) - return False - - log.debug('Valid OM address: {0}'.format(email)) - return True - -def getLock(): - f = open('emailprinting.lock','r').read() - lock = int(f) - return lock - -def processMessage(m,auth): - m.fetchAttachments() - m.markAsRead() - - resp = Message(auth=auth) - resp.setSubject('Printing failed.') - resp.setRecipients(m.getSender()) - - sender = m.json['From']['EmailAddress']['Address'] - num_att = len(m.attachments) - - if not verifyUser(sender): - resp.setBody('I am sorry, but you must email from your om.org email address.') - resp.sendMessage() - return False - - if num_att == 0: - resp.setBody('Did you remember to attach the file?') - resp.sendMessage() - log.debug('No attachments found.') - - log.debug('\t I have {0} attachments from {1} in their email "{2}"'.format(num_att,sender,m.json['Subject'])) - - printer.setFlag('U',userFromEmail(m.json['From']['EmailAddress']['Address'])) - - for att in m.attachments: - if not verifyPDF(att,resp): - continue - - processAttachment(att,resp) - - return True - -def verifyPDF(att,resp): - if '.pdf' not in att.json['Name'].lower(): - log.debug('{0} is not a pdf. skipping!'.format(att.json['Name'])) - resp.setBody('I can only print pdfs. please convert your file and send it again.\n Problematic File: {0}'.format(att.json['Name'])) - resp.sendMessage() - return False - return True - -def processAttachment(att,resp): - p = att.getByteString() - if not p: - log.debug('Something went wrong with decoding attachment: {0} {1}'.format(att.json['Name'],str(p))) - resp.setBody('Did you remember to attach the file?') - resp.sendMessage() - return False - - log.debug('length of byte string: {0} for attachment: {1}'.format(len(p),att.json['Name'])) - if p: - log.debug('ready. set. PRINT!') - printer.setFlag('t',att.json['Name']) - ret = printer.sendPrint(p) - resp.setBody('Your print has been passed on to the printer. You can now go to the printer to collect it. It will be locked, the password is 1234. \n\n{0}'.format(str(ret))) - resp.setSubject('Printing succeeded') - resp.sendMessage() - log.debug('Response from printer: {0}'.format(ret)) - - return True - -emails = open('./emails.pw','r').read().split('\n') -printer = getRicoh() - -if __name__ == '__main__': - newpid = os.fork() - if newpid > 0: - print newpid - f = open('pid','a') - f.write(str(newpid)) - f.write('\n') - f.close() - sys.exit(0) - - while getLock(): - try: - print "checking for emails" - with open('./ep.pw','r') as configFile: - config = configFile.read() - cjson = json.loads(config) - - e = cjson ['email'] - p = cjson ['password'] - - i = Inbox(e,p) - - auth = (e,p) - - log.debug("messages: {0}".format(len(i.messages))) - for m in i.messages: - processMessage(m,auth) - time.sleep(55) - except Exception as e: - log.critical('something went really really bad: {0}',str(e)) - -#To the King! diff --git a/examples/EmailPrinting/printing.py b/examples/EmailPrinting/printing.py deleted file mode 100644 index 474e1f68..00000000 --- a/examples/EmailPrinting/printing.py +++ /dev/null @@ -1,172 +0,0 @@ -import subprocess - -class Printer( object ): - - - def __init__(self, name, flags=None, options=None): - self.name = name - - if flags: - self.flags = flags - else: - self.options = {} - - if options: - self.options = options - else: - self.options = [] - - def __str__(self): - ret = 'Printer: ' + self.name + '\n' - ret += 'With the call of: ' - for flag in self.flags.keys(): - ret += '-{0} {1} '.format(flag,self.flags[flag]) - - for op in self.options: - o = str(op) - if o != '': - ret += o + ' ' - - return ret - - - def setFlag(self,flag,value): - if flag == 'd': - return False - try: - self.flags[flag] = value - except: - return False - return True - - - def getFlag(self,flag): - try: - return self.flags[flag] - except: - return False - - - def addOption(self,new_op): - for i,op in enumerate(self.options): - if op.name == new_op.name: - self.options[i] = new_op - return True - - self.options.append(op) - - - def getOption(self,name): - for op in self.options: - if op.name == name: - return op - - return False - - def __call__(self,item): - self.sendPrint(item) - - - def sendPrint(self,item): - #command = ['lp','-d',self.name] - command = ['/usr/bin/lp'] - for flag in self.flags.keys(): - command.append('-{0} {1}'.format(flag,self.flags[flag])) - - for op in self.options: - o = str(op) - if o != '': - command.append(str(op)) - - print command - p = subprocess.Popen(command,stdout=subprocess.PIPE,stdin=subprocess.PIPE) - #outs = p.communicate(input=item)[0] - p.stdin.write(item) - outs = p.communicate() - print outs - - -class Option( object ): - - - def __init__(self,name,options,default=None,human_name=None): - self.name = name - self.options = options - self.human_name = human_name - if default: - self.default = default - else: - self.default = self.options[0] - - - def __str__(self): - if self.default: - return '-o{0}={1} '.format(self.name,self.default) - return '' - - def setDefault(self,op): - self.default = op - return True - - -def listPrinters(): - lpsc = subprocess.Popen(['lpstat','-s'],stdout=subprocess.PIPE) - lpstats = lpsc.communicate()[0] - - lpsplit = lpstats.split('\n')[1:-1] - - printers = [] - for p in lpsplit: - printers.append(p.split()[2:4]) - - return printers - - -def listOptions(printer): - lpop = subprocess.Popen(['lpoptions','-p',printer,'-l'],stdout=subprocess.PIPE) - lpout = lpop.communicate()[0].split('\n')[:-1] - ops = [] - - for line in lpout: - name, values = line.split(':') - human_name = name[name.index('/')+1:] - name = name[:name.index('/')] - valuelist = values.split(' ') - for i,v in enumerate(valuelist): - if '*' in v: - valuelist[i] = valuelist[i].replace('*','') - - ops.append(Option(name,valuelist,None,human_name)) - - return ops - - -def getRicoh(): - ops = listOptions('ricoh-double') - prin = Printer('ricoh-double',{'U':'tester','t':'testPrint.pdf'},ops) - - op = prin.getOption('ColorModel') - op.setDefault('Gray') - prin.addOption(op) - - op = prin.getOption('Duplex') - op.setDefault('DuplexNoTumble') - prin.addOption(op) - - op = prin.getOption('JobType') - op.setDefault('LockedPrint') - prin.addOption(op) - - op = prin.getOption('LockedPrintPassword') - op.setDefault('1234') - prin.addOption(op) - - return prin - -if __name__ == '__main__': - r = getRicoh() - print r - - r(open('printing.py','r').read()) - -#To the King! diff --git a/examples/VehicleBookings/veh.py b/examples/VehicleBookings/veh.py deleted file mode 100644 index 3d6b732d..00000000 --- a/examples/VehicleBookings/veh.py +++ /dev/null @@ -1,43 +0,0 @@ -from O365 import * -from printing import * -import json - - - -if __name__ == '__main__': - veh = open('./pw/veh.pw','r').read() - vj = json.loads(veh) - - schedules = [] - json_outs = {} - - for veh in vj: - e = veh['email'] - p = veh['password'] - - schedule = Schedule(e,p) - try: - result = schedule.getCalendars() - print 'Fetched calendars for',e,'was successful:',result - except: - print 'Login failed for',e - - bookings = [] - - for cal in schedule.calendars: - print 'attempting to fetch events for',e - try: - result = cal.getEvents() - print 'Got events',result,'got',len(cal.events) - except: - print 'failed to fetch events' - print 'attempting for event information' - for event in cal.events: - print 'HERE!' - bookings.append(event.fullcalendarioJson()) - json_outs[e] = bookings - - with open('bookings.json','w') as outs: - outs.write(json.dumps(json_outs,sort_keys=True,indent=4)) - -#To the King! diff --git a/examples/automatic_response_example.py b/examples/automatic_response_example.py new file mode 100644 index 00000000..c48889e5 --- /dev/null +++ b/examples/automatic_response_example.py @@ -0,0 +1,12 @@ +from O365 import Account + +client_id = '' # Your client_id +client_secret = '' # Your client_secret, create an (id, secret) at https://apps.dev.microsoft.com + +print("Connecting to O365") +account = Account(credentials=(client_id, client_secret), auth_flow_type='authorization') +if account.authenticate(scopes=['basic', 'MailboxSettings.ReadWrite']): + print('Authenticated!') +mailbox = account.mailbox() # here we get the storage instance that handles all the storage options. +success = mailbox.set_automatic_reply("Internal response", "External response", "2022-11-05T08:00:00.0000000", "2022-12-09T16:00:00.00000000", 'Europe/Berlin') + diff --git a/examples/before version 1/calendarCookbook.py b/examples/before version 1/calendarCookbook.py new file mode 100644 index 00000000..754d1218 --- /dev/null +++ b/examples/before version 1/calendarCookbook.py @@ -0,0 +1,29 @@ +from O365 import * + +# User's credentials +e = 'email_address' +p = 'password' + +# Create Schedule object, get calendars, and create empty dict +schedule = Schedule((e, p)) +result = schedule.getCalendars() + +# Funciton to invoke getName() and bind to var result +def cal_name(): + result = cal.getName() # This will get the name of a calendar + return result + +# Show name of each calendar +print('\nHere are your calendars:\n') +for cal in schedule.calendars: + print(cal_name()) + +# Get events for each calendar +print('\nHere are all your upcoming events:\n') +for cal in schedule.calendars: + result = cal_name() + events = cal.getEvents() # This will create an Event object + for x in cal.events: + contents = x.getSubject() # This will get the subject + date = x.getStart() # This will get the start time and we'll parse it below + print('{}-{} | {} : {}'.format(date.tm_mon, date.tm_mday, result, contents)) diff --git a/examples/before version 1/search_subfolders.py b/examples/before version 1/search_subfolders.py new file mode 100644 index 00000000..5c4813e3 --- /dev/null +++ b/examples/before version 1/search_subfolders.py @@ -0,0 +1,29 @@ +import getpass +from O365 import Connection, FluentInbox + + +def main(): + username = input("Username: ") + password = getpass.getpass("Password: ") + authentication = (username, password) + Connection.login(*authentication) + inbox = FluentInbox() + + # set inbox as current folder to use as parent, self.folder attribute + inbox.from_folder("Inbox") + + # reset current folder as subfolder + inbox.from_folder("Subfolder", parent_id=inbox.folder["Id"]) + for msg in inbox.search("Subject:Urgent").fetch_first(10): + print(msg.getSubject()) + + # reset current folder as a child folder of Subfolder + inbox.from_folder("Sub_subfolder", parent_id=inbox.folder["Id"]) + for msg in inbox.fetech_first(10): + print(msg.getSubject()) + + return 0 + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/examples/fetchFile.py b/examples/fetchFile.py deleted file mode 100644 index 84b6a878..00000000 --- a/examples/fetchFile.py +++ /dev/null @@ -1,52 +0,0 @@ -from O365 import * -import json -import os -import sys -import time -import logging - -logging.basicConfig(filename='ff.log',level=logging.DEBUG) - -log = logging.getLogger('ff') - -def processMessage(m): - path = m.json['BodyPreview'] - - path = path[:path.index('\n')] - if path[-1] == '\r': - path = path[:-1] - - att = Attachment(path=path) - - resp = Message(auth=auth) - resp.setRecipients(m.getSender()) - - resp.setSubject('Your file sir!') - resp.setBody(path) - resp.attachments.append(att) - resp.sendMessage() - - return True - - -print "checking for emails" -with open('./ff.pw','r') as configFile: - config = configFile.read() - cjson = json.loads(config) - -e = cjson ['email'] -p = cjson ['password'] - -auth = (e,p) - -i = Inbox(e,p,getNow=False) #Email, Password, Delay fetching so I can change the filters. - -i.setFilter("IsRead eq false & Subject eq 'Fetch File'") - -i.getMessages() - -log.debug("messages: {0}".format(len(i.messages))) -for m in i.messages: - processMessage(m) - -#To the King! diff --git a/examples/jwt_assertion.py b/examples/jwt_assertion.py new file mode 100644 index 00000000..b67a7231 --- /dev/null +++ b/examples/jwt_assertion.py @@ -0,0 +1,65 @@ +import codecs +import uuid +from datetime import datetime, timezone, timedelta + +import jwt + +_ALGORITHM = "RS256" + + +def _get_aud(tenant_id): + return f"https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token" + + +def create_jwt_assertion(private_key, tenant_id, thumbprint, client_id): + """ + Create a JWT assertion, used to obtain an auth token. + + + @param private_key: Private key in PEM format from the certificate that was registered as credentials for the + application. + @param tenant_id: The directory tenant the application plans to operate against, in GUID or domain-name format. + @param thumbprint: The X.509 certificate thumbprint. + @param client_id: The application (client) ID that's assigned to the app. + @return: JWT assertion to be used to obtain an auth token. + """ + x5t = codecs.encode(codecs.decode(thumbprint, "hex"), "base64").replace(b"\n", b"").decode() + aud = _get_aud(tenant_id) + + now = datetime.now(tz=timezone.utc) + exp = now + timedelta(hours=1) + jti = str(uuid.uuid4()) + + payload = { + "aud": aud, + "exp": exp, + "iss": client_id, + "jti": jti, + "nbf": now, + "sub": client_id, + "iat": now + } + headers = { + "alg": _ALGORITHM, + "typ": "JWT", + "x5t": x5t, + } + encoded = jwt.encode(payload, private_key, algorithm=_ALGORITHM, headers=headers) + + return encoded + + +def decode_jwt_assertion(jwt_assertion, public_key, tenant_id): + """ + Decode a JWT assertion, the opposite to 'create_jwt_assertion'. + + @param jwt_assertion: The JWT assertion obtained to be decoded. + @param public_key: Public key in PEM format from the certificate that was registered as credentials for the + application. + @param tenant_id: The directory tenant the application plans to operate against, in GUID or domain-name format. + @return: The decoded assertion. + """ + aud = _get_aud(tenant_id) + decoded = jwt.decode(jwt_assertion, public_key, audience=aud, algorithms=[_ALGORITHM]) + + return decoded diff --git a/examples/onedrive_example.py b/examples/onedrive_example.py new file mode 100644 index 00000000..3042155c --- /dev/null +++ b/examples/onedrive_example.py @@ -0,0 +1,195 @@ +import os +import argparse +from O365 import Account + +client_id = '' # Your client_id +client_secret = '' # Your client_secret, create an (id, secret) at https://apps.dev.microsoft.com +scopes = ['basic', 'https://graph.microsoft.com/Files.ReadWrite.All'] +CHUNK_SIZE = 1024 * 1024 * 5 + + +class O365Account(): + def __init__(self, client_id=client_id, + client_secret=client_secret, scopes=scopes): + self.client_id = client_id + self.client_secret = client_secret + self.account = Account(credentials=(client_id, client_secret)) + self.authenticate(scopes) + + self.storage = self.account.storage() + self.drives = self.storage.get_drives() + self.my_drive = self.storage.get_default_drive() # or get_drive('drive-id') + self.root_folder = self.my_drive.get_root_folder() + + def authenticate(self, scopes=scopes): + result = self.account.authenticate(scopes=scopes) + + def get_drive(self): + return self.my_drive + + def get_root_folder(self): + return self.root_folder + + def get_folder_from_path(self, folder_path): + if folder_path is None: + return self.my_drive + + subfolders = folder_path.split('/') + if len(subfolders) == 0: + return self.my_drive + + items = self.my_drive.get_items() + for subfolder in subfolders: + try: + subfolder_drive = list(filter(lambda x: subfolder in x.name, items))[0] + items = subfolder_drive.get_items() + except: + raise ('Path {} not exist.'.format(folder_path)) + return subfolder_drive + + ''' Upload a file named $filename to onedrive folder named $destination. ''' + + def upload_file(self, filename, destination=None): + folder = self.get_child_folder(self.root_folder, destination) + print('Uploading file ' + filename) + folder.upload_file(item=filename) + + ''' Download a file named $filename to local folder named $to_path. ''' + + def download_file(self, filename, to_path=None): + dirname = os.path.dirname(filename) + basename = os.path.basename(filename) + folder = self.get_folder_from_path(dirname) + items = folder.get_items() + if not os.path.exists(to_path): + os.makedirs(to_path) + try: + file = list(filter(lambda x: basename == x.name, items))[0] + print('Downloading file ' + filename) + file.download(to_path, chunk_size=CHUNK_SIZE) + return True + except: + print('File {} not exist.'.format(filename)) + return False + + def _get_child_folder(self, folder, child_folder_name): + items = folder.get_items() + found_child = list(filter(lambda x: x.is_folder and x.name == child_folder_name, items)) + return found_child[0] if found_child else folder.create_child_folder(child_folder_name) + + ''' Get child folder, folder tree from root folder. If child folder not exist, make it. ''' + + def get_child_folder(self, folder, child_folder_name): + child_folder_names = child_folder_name.split('/') + for _child_folder_name in child_folder_names: + folder = self._get_child_folder(folder, _child_folder_name) + return folder + + ''' + Upload entire folder named $folder_name from local to onedrive folder named $destination. + Keep cloud folder structure as that of local folder. + ''' + + def upload_folder(self, folder_name, destination=None): + print() + print('Uploading folder ' + folder_name) + if destination is None: + destination = folder_name + destination_item = self.get_child_folder(self.root_folder, destination) + + for file in os.listdir(folder_name): + path = os.path.join(folder_name, file) + if os.path.isfile(path): + self.upload_file(path, destination) + else: + folder = self.get_folder_from_path(destination) + child_destination = self.get_child_folder(folder, file) + self.upload_folder(path, os.path.join(destination, file)) + + ''' + Download entire folder named $folder_name from cloud to local folder named $to_folder. + Keep local folder structure as that of cloud folder. + ''' + + def download_folder(self, folder_name, to_folder='.', file_type=None): + to_folder = os.path.join(to_folder, folder_name) + self._download_folder(folder_name, to_folder, file_type) + + def _download_folder(self, folder_name, to_folder='.', file_type=None): + print() + print('Downloading folder ' + folder_name) + current_wd = os.getcwd() + if to_folder is not None and to_folder != '.': + if not os.path.exists(to_folder): + os.makedirs(to_folder) + os.chdir(to_folder) + + if folder_name is None: + folder = self.get_drive() + folder = self.get_folder_from_path(folder_name) + + items = folder.get_items() + if file_type is None: + file_type = '' + files = list(filter(lambda x: file_type in x.name or x.is_folder, items)) + + for file in files: + file_name = file.name + abs_path = os.path.join(folder_name, file_name) + if file.is_file: + print('Downloading file ' + abs_path) + file.download(chunk_size=CHUNK_SIZE) + else: + child_folder_name = abs_path + self._download_folder(child_folder_name, file_name, file_type) + os.chdir(current_wd) + + +def parse_arguments(): + parser = argparse.ArgumentParser() + parser.add_argument("-f", "--function", help="method used") + parser.add_argument("-s", "--source", help="source path", default=".") + parser.add_argument("-d", "--destination", help="destination path", default=".") + parser.add_argument("-t", "--file-type", default="") + return parser.parse_args() + + +def main(): + account = O365Account() + args = parse_arguments() + function_name = args.function + source = args.source + destination = args.destination + + if function_name == 'download_file': + account.download_file(source, destination) + elif function_name == 'upload_file': + account.upload_file(source, destination) + elif function_name == 'download_folder': + account.download_folder(source, destination, args.file_type) + elif function_name == 'upload_folder': + account.upload_folder(source, destination) + else: + print('Invalid function name') + + +if __name__ == '__main__': + ''' + Usage: + + 1. To download a file, run: + python -f download_file -s -d + + 2. To upload a file, run: + python -f upload_file -s -d + + 3. To download a folder, run: + python -f download_folder -s -d + + 4. To upload a folder, run: + python -f upload_folder -s -d + + (onedrive-folder-path/onedrive-file-path must be relative path from root folder of your onedrive) + ''' + + main() diff --git a/examples/pipemail.py b/examples/pipemail.py deleted file mode 100644 index 3710f0c5..00000000 --- a/examples/pipemail.py +++ /dev/null @@ -1,35 +0,0 @@ -#!/usr/bin/env python -#this is a simple script that can be used in conjunction with a unix pipeline. -# args must still provide: sending email, sending email password, reciving email, and subject. -from O365 import * -from sys import argv -import sys - -'''Usage: -This script is designed to provide a simple means of scripting the output of a program into -email. You pass it several arguments but the body of the email is to be sent from stdin. the -args in order are: -pipemail.py sender@example.com password recipient@example.com subject -''' - -if argv[1] == '/?': - print usage - exit() - -auth = (argv[1],argv[2]) - -rec = argv[3] - -subject = argv[4] - -body = sys.stdin.read() - -#Give the authentication to the message as instantiate it. then set it's values. -m = Message(auth=auth) -m.setRecipients(rec) -m.setSubject(subject) -m.setBody(body) -m.sendMessage() - - - diff --git a/examples/simple-message.py b/examples/simple-message.py deleted file mode 100644 index da517362..00000000 --- a/examples/simple-message.py +++ /dev/null @@ -1,55 +0,0 @@ -from O365 import * -import getpass -import json - -from sys import argv - -usage = '''Welcome to the O365 simple message script! Usage is pretty straight forward. -Run the script and you will be asked for username, password, reciving address, -subject, and then a body. When these have all come and gone your message will -be sent straight way. - -For attachments, include the path to the attachment in the call and the script -will attach the files or crash trying. (hopefully not the latter) -e.g.: python simple-message.py that_file_you_want_but_could_only_ssh_in.jpg -''' - -if len(argv) > 1: - if argv[1] == '/?': - print usage - exit() - -#get login credentials that will be needed to send the message. -uname = raw_input('Enter your user name: ') -password = getpass.getpass('Enter your password: ') -auth = (uname,password) - -#get the address that the message is to be sent to. -rec = raw_input('Reciving address: ') - -#get the subject line. -subject = raw_input('Subject line: ') - -#get the body. -line = 'please ignore.' -body = '' -print 'Now enter the body of the message. leave a blank line when you are done.' -while line != '': - line = raw_input() - body += line - -#Give the authentication to the message as instantiate it. then set it's values. -m = Message(auth=auth) -m.setRecipients(rec) -m.setSubject(subject) -m.setBody(body) - -if len(argv) > 1: - for arg in argv[1:]: - a = Attachment(path=arg) - m.attachments.append(a) - -#send the message and report back. -print 'Sending message...' -print m.sendMessage() - diff --git a/examples/storageDownloadFile b/examples/storageDownloadFile new file mode 100755 index 00000000..1cabb1c4 --- /dev/null +++ b/examples/storageDownloadFile @@ -0,0 +1,46 @@ +#!/usr/bin/python3 + +# To generate an Office365 token: + +# python3 +# from O365 import Account +# account = Account(credentials=('yourregisteredappname', 'yoursecret')) +# account.authenticate(scopes=['files.read', 'user.read', 'offline_access']) + +# It will return a URL, go to this in a browser, accept the permissions, then paste in the URL you are redirected to +# YOU MAY HAVE TO SWITCH TO THE 'OLD' VIEW TO DO THIS! + +import pandas as pd +from O365 import Account + +# Generated on the app registration portal +registered_app_name='yourregisteredappname' +registered_app_secret='yoursecret' + +# File to download, and location to download to +dl_path='/path/to/download' +f_name='myfile.xlsx' + +print("Connecting to O365") +account = Account(credentials=(registered_app_name, registered_app_secret), scopes=['files.read', 'user.read', 'offline_access']) + +storage = account.storage() # here we get the storage instance that handles all the storage options. + +# get the default drive +my_drive = storage.get_default_drive() + +print("Searching for {}...".format(f_name)) +files = my_drive.search(f_name, limit=1) +if files: + numberDoc = files[0] + print("... copying to local machine") + operation = numberDoc.download(to_path=dl_path) +else: + print("File not found!") + exit() + +print("Reading sheet to dataframe") +df = pd.read_excel('{}/{}'.format(dl_path, f_name)) + +with pd.option_context('display.max_rows', None, 'display.max_columns', None): + print(df) diff --git a/examples/subscriptions_example.py b/examples/subscriptions_example.py new file mode 100644 index 00000000..0d887168 --- /dev/null +++ b/examples/subscriptions_example.py @@ -0,0 +1,132 @@ + +""" Example on how to use and setup webhooks + +Quickstart for this example: +1) Run Flask locally withg the following command: + - flask --app examples/subscriptions_example.py run --debug +2) Expose HTTPS via a tunnel to your localhost:5000: + - Free: pinggy (https://pinggy.io/) to get https://.pinggy.link -> http://localhost:5000 + - Paid/free-tier: ngrok (https://ngrok.com/): ngrok http 5000, note the https URL. +3) Use the tunnel HTTPS URL as notification_url pointing to /webhook, URL-encoded. +4) To create a subscription, follow the example request below: + - https:///subscriptions?notification_url=https%3A%2F%2F%2Fwebhook&client_state=abc123 +5) To list subscriptions, follow the example request below: + - http:///subscriptions/list +6) To renew a subscription, follow the example request below: + - http:///subscriptions//renew?expiration_minutes=55 +7) To delete a subscription, follow the example request below: + - http:///subscriptions//delete +Graph will call https:///webhook; this app echoes validationToken and returns 202 for notifications. +""" + +from flask import Flask, abort, jsonify, request +from O365 import Account + +CLIENT_ID = "YOUR CLIENT ID" +CLIENT_SECRET = "YOUR CLIENT SECRET" +credentials = (CLIENT_ID, CLIENT_SECRET) + +account = Account(credentials) +# Pick the scopes that are relevant to you here +account.authenticate( + scopes=[ + "https://graph.microsoft.com/Mail.ReadWrite", + "https://graph.microsoft.com/Mail.Send", + "https://graph.microsoft.com/Calendars.ReadWrite", + "https://graph.microsoft.com/MailboxSettings.ReadWrite", + "https://graph.microsoft.com/User.Read", + "https://graph.microsoft.com/User.ReadBasic.All", + 'offline_access' + ]) + +RESOURCE = "/me/mailFolders('inbox')/messages" +DEFAULT_EXPIRATION_MINUTES = 10069 # Maximum expiration is 10,070 in the future. + +app = Flask(__name__) + + +def _int_arg(name: str, default: int) -> int: + raw = request.args.get(name) + if raw is None: + return default + try: + return int(raw) + except ValueError: + abort(400, description=f"{name} must be an integer") + + +@app.get("/subscriptions") +def create_subscription(): + notification_url = request.args.get("notification_url") + if not notification_url: + abort(400, description="notification_url is required") + + expiration_minutes = _int_arg("expiration_minutes", DEFAULT_EXPIRATION_MINUTES) + client_state = request.args.get("client_state") + resource = request.args.get("resource", RESOURCE) + + subscription = account.subscriptions().create_subscription( + notification_url=notification_url, + resource=resource, + change_type="created", + expiration_minutes=expiration_minutes, + client_state=client_state, + ) + return jsonify(subscription), 201 + + +@app.get("/subscriptions/list") +def list_subscriptions(): + limit_raw = request.args.get("limit") + limit = None + if limit_raw is not None: + try: + limit = int(limit_raw) + except ValueError: + abort(400, description="limit must be an integer") + if limit <= 0: + abort(400, description="limit must be a positive integer") + + subscriptions = account.subscriptions().list_subscriptions(limit=limit) + return jsonify(list(subscriptions)), 200 + + +@app.get("/subscriptions//renew") +def renew_subscription(subscription_id: str): + expiration_minutes = _int_arg("expiration_minutes", DEFAULT_EXPIRATION_MINUTES) + updated = account.subscriptions().renew_subscription( + subscription_id, + expiration_minutes=expiration_minutes, + ) + return jsonify(updated), 200 + + +@app.get("/subscriptions//delete") +def delete_subscription(subscription_id: str): + deleted = account.subscriptions().delete_subscription(subscription_id) + if not deleted: + abort(404, description="Subscription not found") + return ("", 204) + + +@app.post("/webhook") +def webhook_handler(): + """Handle Microsoft Graph webhook calls. + + - During subscription validation, Graph sends POST with ?validationToken=... . + We must echo the token as plain text within 10 seconds. + - For change notifications, Graph posts JSON; we just log/ack. + """ + validation_token = request.args.get("validationToken") + if validation_token: + # Echo back token exactly as plain text with HTTP 200. + return validation_token, 200, {"Content-Type": "text/plain"} + + # Change notifications: inspect or log as needed. + payload = request.get_json(silent=True) or {} + print("Received notification payload:", payload) + return ("", 202) + + +if __name__ == "__main__": + app.run(debug=True, ssl_context=("examples/cert.pem", "examples/key.pem")) diff --git a/examples/token_backends.py b/examples/token_backends.py new file mode 100644 index 00000000..d809edc7 --- /dev/null +++ b/examples/token_backends.py @@ -0,0 +1,212 @@ +from __future__ import annotations + +import time +import logging +import random +from typing import Optional, TYPE_CHECKING + +from portalocker import Lock +from portalocker.exceptions import LockException + +from O365.utils import FirestoreBackend, FileSystemTokenBackend + +if TYPE_CHECKING: + from O365.connection import Connection + + +log = logging.getLogger(__name__) + + +# This is an implementation of the 'should_refresh_token' method + + +class LockableFirestoreBackend(FirestoreBackend): + """ + A firestore backend that can answer to + 'should_refresh_token'. Synchronous. + """ + + def __init__(self, *args, **kwargs): + self.refresh_flag_field_name = kwargs.get("refresh_flag_field_name") + if self.refresh_flag_field_name is None: + raise ValueError("Must provide the db field name of the refresh token flag") + self.max_tries = kwargs.pop("max_tries", 5) # max db calls + self.factor = kwargs.pop("factor", 1.5) # incremental back off factor + super().__init__(*args, **kwargs) + + def _take_refresh_action(self) -> bool: + # this should transactional get the flag and set it to False only if it's True + # it should return True if it has set the flag to false (to say "hey you can safely refresh the token") + # if the flag was already False then return False (to say "hey somebody else is refreshing the token atm") + resolution = True # example... + return resolution + + def _check_refresh_flag(self) -> bool: + """ Returns the token if the flag is True or None otherwise""" + try: + doc = self.doc_ref.get() + except Exception as e: + log.error(f"Flag (collection: {self.collection}, doc_id: {self.doc_id}) " + f"could not be retrieved from the backend: {e}") + doc = None + if doc and doc.exists: + if doc.get(self.refresh_flag_field_name): # if the flag is True get the token + token_str = doc.get(self.field_name) + if token_str: + # store the token + self._cache = self.deserialize(token_str) + return True + return False + + def should_refresh_token(self, con: Optional[Connection] = None, username: Optional[str] = None): + # 1) check if the token is already a new one: + old_access_token = self.get_access_token(username=username) + if old_access_token: + self.load_token() # retrieve again the token from the backend + new_access_token = self.get_access_token(username=username) + if old_access_token["secret"] != new_access_token["secret"]: + # The token is different so the refresh took part somewhere else. + # Return False so the connection can update the token access from the backend into the session + return False + + # 2) Here the token stored in the token backend and in the token cache of this instance is the same + # Therefore ask if we can take the action of refreshing the access token + if self._take_refresh_action(): + # we have successfully updated the flag, and we can now tell the + # connection that it can begin to refresh the token + return True + + # 3) We should refresh the token, but can't as the flag was set to False by somebody else. + # Therefore, we must wait until the refresh is saved by another instance or thread. + tries = 0 + while True: + tries += 1 + value = self.factor * 2 ** (tries - 1) + seconds = random.uniform(0, value) + time.sleep(seconds) # we sleep first as _take_refresh_action already checked the flag + + # 4) Check again for the flag. If returns True then we now have a new token stored + token_stored = self._check_refresh_flag() + if token_stored: + break + if tries == self.max_tries: + # We tried and didn't get a result. We return True so the Connection can try a new refresh + # at the expense of possibly having other instances or threads with a stale refresh token + return True + # Return False so the connection can update the token access from the backend into the session + return False + + def save_token(self, force=False): + """We must overwrite this method to update also the 'refresh_flag_field_name' to True""" + if not self._cache: + return False + + if force is False and self._has_state_changed is False: + return True + + try: + # set token will overwrite previous data + self.doc_ref.set({ + self.field_name: self.serialize(), + # everytime we store a token we overwrite the flag to True so other instances or threads know + # then token was updated while waiting for it. + self.refresh_flag_field_name: True + }) + except Exception as e: + log.error(f"Token could not be saved: {str(e)}") + return False + + return True + + +class LockableFileSystemTokenBackend(FileSystemTokenBackend): + """ + See GitHub issue #350 + A token backend that ensures atomic operations when working with tokens + stored on a file system. Avoids concurrent instances of O365 racing + to refresh the same token file. It does this by wrapping the token refresh + method in the Portalocker package's Lock class, which itself is a wrapper + around Python's fcntl and win32con. + """ + + def __init__(self, *args, **kwargs): + self.max_tries: int = kwargs.pop("max_tries", 3) + self.fs_wait: bool = False + super().__init__(*args, **kwargs) + + def should_refresh_token(self, con: Optional[Connection] = None, username: Optional[str] = None): + """ + Method for refreshing the token when there are concurrently running + O365 instances. Determines if we need to call the MS server and refresh + the token and its file, or if another Connection instance has already + updated it, and we should just load that updated token from the file. + + It will always return False, None, OR raise an error if a token file + couldn't be accessed after X tries. That is because this method + completely handles token refreshing via the passed Connection object + argument. If it determines that the token should be refreshed, it locks + the token file, calls the Connection's 'refresh_token' method (which + loads the fresh token from the server into memory and the file), then + unlocks the file. Since refreshing has been taken care of, the calling + method does not need to refresh and we return None. + + If we are blocked because the file is locked, that means another + instance is using it. We'll change the backend's state to waiting, + sleep for 2 seconds, reload a token into memory from the file (since + another process is using it, we can assume it's being updated), and + loop again. + + If this newly loaded token is not expired, the other instance loaded + a new token to file, and we can happily move on and return False. + (since we don't need to refresh the token anymore). If the same token + was loaded into memory again and is still expired, that means it wasn't + updated by the other instance yet. Try accessing the file again for X + more times. If we don't succeed after the loop has terminated, raise a + runtime exception + """ + + # 1) check if the token is already a new one: + old_access_token = self.get_access_token(username=username) + if old_access_token: + self.load_token() # retrieve again the token from the backend + new_access_token = self.get_access_token(username=username) + if old_access_token["secret"] != new_access_token["secret"]: + # The token is different so the refresh took part somewhere else. + # Return False so the connection can update the token access from the backend into the session + return False + + # 2) Here the token stored in the token backend and in the token cache of this instance is the same + for i in range(self.max_tries, 0, -1): + try: + with Lock(self.token_path, "r+", fail_when_locked=True, timeout=0) as token_file: + # we were able to lock the file ourselves so proceed to refresh the token + # we have to do the refresh here as we must do it with the lock applied + log.debug("Locked oauth token file. Refreshing the token now...") + token_refreshed = con.refresh_token() + if token_refreshed is False: + raise RuntimeError("Token Refresh Operation not working") + + # we have refreshed the auth token ourselves to we must take care of + # updating the header and save the token file + con.update_session_auth_header() + log.debug("New oauth token fetched. Saving the token data into the file") + token_file.write(self.serialize()) + log.debug("Unlocked oauth token file") + return None + except LockException: + # somebody else has adquired a lock so will be in the process of updating the token + self.fs_wait = True + log.debug(f"Oauth file locked. Sleeping for 2 seconds... retrying {i - 1} more times.") + time.sleep(2) + log.debug("Waking up and rechecking token file for update from other instance...") + # Check if new token has been created. + self.load_token() + if not self.token_is_expired(): + log.debug("Token file has been updated in other instance...") + # Return False so the connection can update the token access from the + # backend into the session + return False + + # if we exit the loop, that means we were locked out of the file after + # multiple retries give up and throw an error - something isn't right + raise RuntimeError(f"Could not access locked token file after {self.max_tries}") diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..345af59c --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,47 @@ +[project] +name = "o365" +version = "2.1.9" +description = "O365 - Microsoft Graph and Office 365 API made easy" +readme = "README.md" +requires-python = ">=3.10" +authors = [ + { name = "Alejcas", email = "alejcas@users.noreply.github.com" }, + { name = "Narcolapser", email = "narcolapser@users.noreply.github.com" }, + { name = "Roycem90", email = "roycem90@users.noreply.github.com" } +] +maintainers = [{ name = "Alejcas", email = "alejcas@users.noreply.github.com" }] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Operating System :: OS Independent", +] +dependencies = [ + "beautifulsoup4>=4.12.3", + "msal>=1.31.1", + "python-dateutil>=2.9.0.post0", + "requests>=2.32.3", + "tzdata>=2024.2", + "tzlocal>=5.2", +] + +[dependency-groups] +dev = [ + "click>=8.1.8", + "pytest>=8.3.4", + "sphinx>=7.4.7", + "sphinx-rtd-theme>=3.0.2" +] + +[build-system] +requires = ["uv_build>=0.8.15,<0.9.0"] +build-backend = "uv_build" + +[tool.uv.build-backend] +module-name = "O365" +module-root = "" + diff --git a/release.py b/release.py new file mode 100644 index 00000000..52def0c3 --- /dev/null +++ b/release.py @@ -0,0 +1,189 @@ +""" +Release script +""" + +import os +import shutil +import subprocess +import sys +import requests +from pathlib import Path +from math import floor + +import click + +PYPI_PACKAGE_NAME = 'O365' +PYPI_URL = 'https://pypi.org/pypi/{package}/json' +DIST_PATH = 'dist' +DIST_PATH_DELETE = 'dist_delete' +CONTEXT_SETTINGS = dict(help_option_names=['-h', '--help']) + + +@click.group(context_settings=CONTEXT_SETTINGS) +def cli(): + pass + + +@cli.command() +@click.option('--force/--no-force', default=False, help='Will force a new build removing the previous ones') +def build(force): + """ Builds the distribution files: wheels and source. """ + dist_path = Path(DIST_PATH) + if dist_path.exists() and list(dist_path.glob('*')): + if force or click.confirm('{} is not empty - delete contents?'.format(dist_path)): + dist_path.rename(DIST_PATH_DELETE) + shutil.rmtree(Path(DIST_PATH_DELETE)) + dist_path.mkdir() + else: + click.echo('Aborting') + sys.exit(1) + + subprocess.check_call(['python', 'setup.py', 'bdist_wheel']) + subprocess.check_call(['python', 'setup.py', 'sdist', '--formats=gztar']) + + +@cli.command() +@click.option('--release/--no-release', default=False, help='--release to upload to pypi otherwise upload to test.pypi') +@click.option('--rebuild/--no-rebuild', default=True, help='Will force a rebuild of the build files (src and wheels)') +@click.pass_context +def upload(ctx, release, rebuild): + """ Uploads distribuition files to pypi or pypitest. """ + dist_path = Path(DIST_PATH) + if rebuild is False: + if not dist_path.exists() or not list(dist_path.glob('*')): + print("No distribution files found. Please run 'build' command first") + return + else: + ctx.invoke(build, force=True) + + if release: + args = ['twine', 'upload', 'dist/*'] + else: + repository = 'https://test.pypi.org/legacy/' + args = ['twine', 'upload', '--repository-url', repository, 'dist/*'] + + env = os.environ.copy() + + p = subprocess.Popen(args, env=env) + p.wait() + + +@cli.command() +def check(): + """ Checks the long description. """ + dist_path = Path(DIST_PATH) + if not dist_path.exists() or not list(dist_path.glob('*')): + print("No distribution files found. Please run 'build' command first") + return + + subprocess.check_call(['twine', 'check', 'dist/*']) + + +@cli.command() +@click.option('--annotate/--no-annotate', default=False, help='Annotate coverage on files') +@click.option('--coverage/--no-coverage', default=False, help='Run coverage') +@click.option('-v/-nv', default=False, help='Verbose') +@click.option('-vv/-nvv', default=False, help='Very verbose') +def test(annotate, coverage, v, vv): + """ Runs tests and optionally creates annotated files of coverage. """ + args = ['python3', '-m', 'pytest', 'tests/'] + if coverage: + args.append('--cov=O365') + if annotate: + args.append('--cov-report') + args.append('annotate') + if v: # Verbose + args.append('-v') + if vv and not v: # Very verbose + args.append('-vv') + + env = os.environ.copy() + + p = subprocess.Popen(args, env=env) + p.wait() + + +def _get_releases(): + """ Retrieves all releases on pypi """ + releases = None + + response = requests.get(PYPI_URL.format(package=PYPI_PACKAGE_NAME)) + if response: + data = response.json() + + releases = [] + releases_dict = data.get('releases', {}) + + if releases_dict: + for version, release in releases_dict.items(): + release_formats = [] + published_on_date = None + for fmt in release: + release_formats.append(fmt.get('packagetype')) + published_on_date = fmt.get('upload_time') + + release_formats = ' | '.join(release_formats) + releases.append((version, published_on_date, release_formats)) + + releases.sort(key=lambda x: x[1]) + + return releases + + +# noinspection PyShadowingBuiltins +@cli.command(name='list') +def list_releases(): + """ Lists all releases published on pypi. """ + + releases = _get_releases() + + if releases is None: + print('Package "{}" not found on Pypi.org'.format(PYPI_PACKAGE_NAME)) + elif not releases: + print('No releases found for {}'.format(PYPI_PACKAGE_NAME)) + else: + for version, published_on_date, release_formats in releases: + print('{:<10}{:>15}{:>25}'.format(version, published_on_date, release_formats)) + + +# Mostly just for fun. I was curious to see what the shape of contributions was. +@cli.command(name='contributors') +def contribution_breakdown(): + """ Displays a table of the contributors and to what extent we have them to thank.""" + args = ['git', 'blame'] + counts = {} + line_format = '{0:30}\t{1:>10}\t{2:>10}%' + files = subprocess.check_output(['git', 'ls-files']).decode("utf-8").split('\n') + + for f in files[:-1]: + if 'docs/latest' in f or '_themes' in f: + continue # skip generated stuff + lines = subprocess.check_output(args + [f]).decode('utf-8') + blames = [get_line_blame(line) for line in lines.split('\n')] + for blame in blames: + counts[blame] = counts.get(blame, 0) + 1 + + total = sum([counts[count] for count in counts]) + contribs = [(user, counts[user]) for user in counts] + contribs.sort(key=lambda x: x[1], reverse=True) + + print(line_format.format('User', 'Lines', 'Line ')) + + for user in contribs: + percent = floor(100.0 * user[1] / total) + if percent == 0: percent = '>1' + print(line_format.format(user[0], user[1], percent)) + + print(line_format.format('Total', total, 100)) + + +def get_line_blame(line): + line = line + start = line.find('(') + 1 + end = line.find(' 2', start) # should be good for the next ~900 years + name = line[start:end] + return name.rstrip(' ').title() if name != '' else 'Unknown' + + +if __name__ == "__main__": + cli() diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index b88034e4..00000000 --- a/setup.cfg +++ /dev/null @@ -1,2 +0,0 @@ -[metadata] -description-file = README.md diff --git a/setup.py b/setup.py deleted file mode 100644 index a9fbb4ee..00000000 --- a/setup.py +++ /dev/null @@ -1,49 +0,0 @@ -#!/usr/bin/env python - -from distutils.core import setup - -CLASSIFIERS = [ - 'Development Status :: 3 - Alpha', - 'Intended Audience :: Developers', - 'License :: OSI Approved :: Apache Software License', - 'Topic :: Office/Business :: Office Suites', - 'Topic :: Software Development :: Libraries' -] -long_desc = '''When I started making this library I was looking for something that would provide a simple interface to an office365 mailbox. I was creating a system that would allow people send an email to our printer without having to require they install drivers or be inside the office firewall (important for students). As I found working with the office API to be fairly easy, I quickly built up solid general use library for working with office 365. - -The objective here is to make it easy to make utilities that are to be run against an office 365 account. for example, the code for sending an email is: - - -from O365 import Message - -authenticiation = ('YourAccount@office365.com','YourPassword') - -m = Message(auth=authenticiation) - -m.setRecipients('reciving@office365.com') - -m.setSubject('I made an email script.') - -m.setBody('Talk to the computer, cause the human does not want to hear it any more.') - -m.sendMessage() - - -That's it. making and sending emails and events is now very simple and straight forward. I've used it for emailing the printer and creating a overview of our car booking system. simple, easy, but still in development. Any suggestions or advice are quite welcome at the projects github page: -https://github.com/Narcolapser/python-o365''' - -setup(name='O365', - version='0.7.1', - description='Python library for working with Microsoft Office 365', - long_description=long_desc, - author='Toben Archer', - author_email='sandslash+O365@gmail.com', - maintainer='Toben Archer', - maintainer_email='sandslash+O365@gmail.com', - url='https://github.com/Narcolapser/python-o365', - packages=['O365'], - install_requires=['requests'], - license='Apache 2.0', - classifiers=CLASSIFIERS - ) - diff --git a/tests/attachment.json b/tests/attachment.json deleted file mode 100644 index 8d2ec346..00000000 --- a/tests/attachment.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "@odata.context":"https://outlook.office365.com/api/v1.0/$metadata#Me/Messages('bigoldguid')/Attachments", - "value":[ - { - "@odata.type":"#Microsoft.OutlookServices.FileAttachment", - "@odata.id":"https://outlook.office365.com/api/v1.0/Users('test@unit.com')/Messages('bigoldguid')/Attachments('attguid')", - "Id":"attguid", - "Name":"test.txt", - "ContentType":null, - "Size":73560, - "IsInline":false, - "DateTimeLastModified":"2015-01-11T14:55:16Z", - "ContentId":null, - "ContentLocation":null, - "IsContactPhoto":false, - "ContentBytes":"dGVzdGluZyB3MDB0IQ==\n" - } - ] -} diff --git a/tests/attachment_message.json b/tests/attachment_message.json deleted file mode 100644 index 792bcafd..00000000 --- a/tests/attachment_message.json +++ /dev/null @@ -1,62 +0,0 @@ -{ - "@odata.context":"https://outlook.office365.com/api/v1.0/$metadata#Me/Messages", - "value":[ - { - "@odata.id":"https://outlook.office365.com/api/v1.0/Users('test@unit.com')/Messages('bigoldguid')", - "@odata.etag":"W/\"ck\"", - "Id":"bigoldguid", - "ChangeKey":"ck", - "Categories":[ - - ], - "DateTimeCreated":"2015-04-22T11:31:18Z", - "DateTimeLastModified":"2015-04-22T11:32:08Z", - "Subject":"I've got a lovely bunch of coconuts", - "BodyPreview":"This email has an at", - "Body":{ - "ContentType":"HTML", - "Content":"This email has an attachment!" - }, - "Importance":"Normal", - "HasAttachments":true, - "ParentFolderId":"parfoid", - "From":{ - "EmailAddress":{ - "Address":"Speach@unit.com", - "Name":"For talking" - } - }, - "Sender":{ - "EmailAddress":{ - "Address":"Speach@unit.com", - "Name":"For talking" - } - }, - "ToRecipients":[ - { - "EmailAddress":{ - "Address":"test@unit.com", - "Name":"Tester for Testing" - } - } - ], - "CcRecipients":[ - - ], - "BccRecipients":[ - - ], - "ReplyTo":[ - - ], - "ConversationId":"addyconvoid=", - "DateTimeReceived":"2015-04-22T11:31:18Z", - "DateTimeSent":"2015-04-22T11:30:46Z", - "IsDeliveryReceiptRequested":false, - "IsReadReceiptRequested":false, - "IsDraft":false, - "IsRead":false, - "WebLink":"https://outlook.office365.com/owa/?ItemID=addywebid&exvsurl=1&viewmodel=ReadMessageItem" - } - ] -} diff --git a/tests/conbill.json b/tests/conbill.json deleted file mode 100644 index 7306b135..00000000 --- a/tests/conbill.json +++ /dev/null @@ -1,59 +0,0 @@ -{ - - "@odata.context":"https://outlook.office365.com/api/v1.0/$metadata#Me/Contacts", - - "value":[ - - { - - "@odata.id":"https://outlook.office365.com/api/v1.0/Users('Wernher.VonKerman@ksp.org')/Contacts('bigguid2')", - - "@odata.etag":"W/\"etag2\"", - - "Id":"bigguid2", - - "ChangeKey":"etag2", - - "Categories":[ - - - - ], - - "DateTimeCreated":"2015-05-14T15:39:21Z", - - "DateTimeLastModified":"2015-05-18T10:59:36Z", - - "ParentFolderId":"Engineers", - - "FileAs":"Kerman, Bill", - - "DisplayName":"Bill Kerman (KSP)", - - "GivenName":"Bill", - - "Initials":"B.K.", - - "Surname":"Kerman", - - "EmailAddresses":[ - - { - - "Address":"Bill.Kerman@ksp.org", - - "Name":"Bill Kerman (KSP)" - - }, - - null, - - null - - ] - - } - - ] - -} diff --git a/tests/contacts.json b/tests/contacts.json deleted file mode 100644 index 9e88eeab..00000000 --- a/tests/contacts.json +++ /dev/null @@ -1,83 +0,0 @@ -{ - "@odata.context":"https://outlook.office365.com/api/v1.0/$metadata#Me/Contacts", - "value":[ - { - "@odata.id":"https://outlook.office365.com/api/v1.0/Users('Wernher.VonKerman@ksp.org')/Contacts('bigguid1')", - "@odata.etag":"etag1", - "Id":"bigguid1", - "ChangeKey":"etag1", - "Categories":[ - - ], - "DateTimeCreated":"2015-05-14T15:39:22Z", - "DateTimeLastModified":"2015-05-20T01:59:31Z", - "ParentFolderId":"parentfolder", - "Birthday":null, - "FileAs":"Kerman, Jebediah", - "DisplayName":"Jebediah Kerman (KSP)", - "GivenName":"Jebediah", - "Initials":"J.K.", - "MiddleName":"", - "NickName":null, - "Surname":"Kerman", - "Title":"", - "Generation":"(KSP)", - "EmailAddresses":[ - { - "Address":"Jebediah.Kerman@ksp.org", - "Name":"Jebediah Kerman (KSP)" - }, - null, - null - ] - }, - { - "@odata.id":"https://outlook.office365.com/api/v1.0/Users('Wernher.VonKerman@ksp.org')/Contacts('bigguid2')", - "@odata.etag":"W/\"etag2\"", - "Id":"bigguid2", - "ChangeKey":"etag2", - "Categories":[ - - ], - "DateTimeCreated":"2015-05-14T15:39:21Z", - "DateTimeLastModified":"2015-05-18T10:59:36Z", - "ParentFolderId":"Engineers", - "FileAs":"Kerman, Bill", - "DisplayName":"Bill Kerman (KSP)", - "GivenName":"Bill", - "Initials":"B.K.", - "Surname":"Kerman", - "EmailAddresses":[ - { - "Address":"Bill.Kerman@ksp.org", - "Name":"Bill Kerman (KSP)" - }, - null, - null - ] - }, - { - "@odata.id":"https://outlook.office365.com/api/v1.0/Users('Wernher.VonKerman@ksp.org')/Contacts('bigguid3')", - "@odata.etag":"W/\"etag3\"", - "Id":"bigguid3", - "ChangeKey":"etag3", - "Categories":[], - "DateTimeCreated":"2015-05-14T15:38:57Z", - "DateTimeLastModified":"2015-05-18T10:59:35Z", - "ParentFolderId":"parentfolder", - "FileAs":"Kerman, Bob", - "DisplayName":"Bob Kerman (KSP)", - "GivenName":"Bob", - "Initials":"B.K.", - "Surname":"Kerman", - "EmailAddresses":[ - { - "Address":"Bob.Kerman@ksp.org", - "Name":"Bob Kerman (KSP)" - }, - null, - null - ] - } - ] -} diff --git a/tests/events.json b/tests/events.json deleted file mode 100644 index 0768f6f8..00000000 --- a/tests/events.json +++ /dev/null @@ -1,164 +0,0 @@ -{ - "@odata.context":"https://outlook.office365.com/api/v1.0/$metadata#Me/Calendars('bigolguid')/CalendarView", - "value":[ - { - "@odata.id":"https://outlook.office365.com/api/v1.0/Users('test@unit.com')/Events('bigolguid=')", - "@odata.etag":"W/\"ck1\"", - "Id":"bigolguid=", - "ChangeKey":"ck1", - "Categories":[ - - ], - "DateTimeCreated":"2015-04-22T11:04:08.5260627Z", - "DateTimeLastModified":"2015-04-22T11:03:34.025Z", - "Subject":"da vent", - "BodyPreview":"Viva la reddit.", - "Body":{ - "ContentType":"HTML", - "Content":"Viva la reddit. content" - }, - "Importance":"Normal", - "HasAttachments":false, - "Start":"2015-04-22T12:00:00Z", - "StartTimeZone":"GMT Standard Time", - "End":"2015-04-22T12:30:00Z", - "EndTimeZone":"GMT Standard Time", - "Reminder":15, - "Location":{ - "DisplayName":"da place", - "Address":{ - "Street":"", - "City":"", - "State":"", - "CountryOrRegion":"", - "PostalCode":"" - }, - "Coordinates":{ - "Accuracy":"NaN", - "Altitude":"NaN", - "AltitudeAccuracy":"NaN", - "Latitude":"NaN", - "Longitude":"NaN" - } - }, - "ResponseStatus":{ - "Response":"None", - "Time":"0001-01-01T00:00:00Z" - }, - "ShowAs":"Busy", - "IsAllDay":false, - "IsCancelled":false, - "IsOrganizer":true, - "ResponseRequested":true, - "Type":"SingleInstance", - "SeriesMasterId":null, - "Attendees":[ - { - "EmailAddress":{ - "Address":"test@unit.com", - "Name":"Tester for Testing" - }, - "Status":{ - "Response":"None", - "Time":"0001-01-01T00:00:00Z" - }, - "Type":"Required" - } - ], - "Recurrence":null, - "Organizer":{ - "EmailAddress":{ - "Address":"organizer@unit.com", - "Name":"The Choosen One" - } - }, - "iCalUId":"calid", - "WebLink":"https://outlook.office365.com/owa/?ItemID=webid1&exvsurl=1&viewmodel=ICalendarItemDetailsViewModelFactory" - }, - { - "@odata.id":"https://outlook.office365.com/api/v1.0/Users('test@unit.com')/Events('otherguid')", - "@odata.etag":"W/\"crusader kings II\"", - "Id":"otherguid", - "ChangeKey":"crusader kings II", - "Categories":[ - - ], - "DateTimeCreated":"2015-04-22T11:05:49.0137691Z", - "DateTimeLastModified":"2015-04-22T11:03:54.775Z", - "Subject":"dat oughter", - "BodyPreview":"Blub blub", - "Body":{ - "ContentType":"HTML", - "Content":"blub blub goes Microsoft's flag ship." - }, - "Importance":"Normal", - "HasAttachments":false, - "Start":"2015-04-24T00:00:00Z", - "StartTimeZone":"UTC", - "End":"2015-04-25T00:00:00Z", - "EndTimeZone":"UTC", - "Reminder":1080, - "Location":{ - "DisplayName":"dat ocean", - "Address":{ - "Street":"", - "City":"", - "State":"", - "CountryOrRegion":"", - "PostalCode":"" - }, - "Coordinates":{ - "Accuracy":"NaN", - "Altitude":"NaN", - "AltitudeAccuracy":"NaN", - "Latitude":"NaN", - "Longitude":"NaN" - } - }, - "ResponseStatus":{ - "Response":"Organizer", - "Time":"0001-01-01T00:00:00Z" - }, - "ShowAs":"Free", - "IsAllDay":true, - "IsCancelled":false, - "IsOrganizer":true, - "ResponseRequested":true, - "Type":"SingleInstance", - "SeriesMasterId":null, - "Attendees":[ - { - "EmailAddress":{ - "Address":"test@unit.com", - "Name":"Tester for Testing" - }, - "Status":{ - "Response":"None", - "Time":"0001-01-01T00:00:00Z" - }, - "Type":"Required" - }, - { - "EmailAddress":{ - "Address":"test@unit.com", - "Name":"test@unit.com" - }, - "Status":{ - "Response":"None", - "Time":"0001-01-01T00:00:00Z" - }, - "Type":"Required" - } - ], - "Recurrence":null, - "Organizer":{ - "EmailAddress":{ - "Address":"mrfins@splashy.whale", - "Name":"Mr. Splashy Fins" - } - }, - "iCalUId":"clid2", - "WebLink":"https://outlook.office365.com/owa/?ItemID=webid22&exvsurl=1&viewmodel=ICalendarItemDetailsViewModelFactory" - } - ] -} diff --git a/tests/groups.json b/tests/groups.json deleted file mode 100644 index 18830f6c..00000000 --- a/tests/groups.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "@odata.context":"https://outlook.office365.com/api/v1.0/$metadata#Me/ContactFolders", - "value":[ - { - "@odata.id":"https://outlook.office365.com/api/v1.0/Users('Wernher.VonKerman@ksp.org')/ContactFolders('engiID')", - "Id":"engiID", - "ParentFolderId":"parentfolder", - "DisplayName":"Engineers" - }, - { - "@odata.id":"https://outlook.office365.com/api/v1.0/Users('Wernher.VonKerman@ksp.org')/ContactFolders('OrangesID')", - "Id":"OrangesID", - "ParentFolderId":"parentfolder", - "DisplayName":"Oranges" - } - ] -} diff --git a/tests/newmessage.json b/tests/newmessage.json deleted file mode 100644 index e3460164..00000000 --- a/tests/newmessage.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "Message": { - "Subject": "Meet for lunch?", - "Body": { - "ContentType": "Text", - "Content": "The new cafeteria is open." - }, - "ToRecipients": [ - { - "EmailAddress": { - "Address": "garthf@a830edad9050849NDA1.onmicrosoft.com" - } - } - ], - "Attachments": [ - { - "@odata.type": "#Microsoft.OutlookServices.FileAttachment", - "Name": "menu.txt", - "ContentBytes": "bWFjIGFuZCBjaGVlc2UgdG9kYXk=" - } - ] - }, - "SaveToSentItems": "false" -} diff --git a/tests/read_message.json b/tests/read_message.json deleted file mode 100644 index 06ff78ad..00000000 --- a/tests/read_message.json +++ /dev/null @@ -1,234 +0,0 @@ -{ - "@odata.context":"https://outlook.office365.com/api/v1.0/$metadata#Me/Messages", - "value":[ - { - "@odata.id":"https://outlook.office365.com/api/v1.0/Users('test@unit.com')/Messages('guid1')", - "@odata.etag":"ck1", - "Id":"guid1", - "ChangeKey":"CQAAABYAAAD9eTSzqEULQLIWnNHLzkE/AADSuoSD", - "Categories":[ - - ], - "DateTimeCreated":"2015-04-22T09:30:16Z", - "DateTimeLastModified":"2015-04-22T09:30:38Z", - "Subject":"m1", - "BodyPreview":"rb1", - "Body":{ - "ContentType":"HTML", - "Content":"read body 1" - }, - "Importance":"Normal", - "HasAttachments":false, - "ParentFolderId":"parfoId", - "From":{ - "EmailAddress":{ - "Address":"sender1@unit.com", - "Name":"first sender" - } - }, - "Sender":{ - "EmailAddress":{ - "Address":"sender1@unit.com", - "Name":"first sender" - } - }, - "ToRecipients":[ - { - "EmailAddress":{ - "Address":"test@unit.com", - "Name":"Tester for Testing" - } - } - ], - "CcRecipients":[ - - ], - "BccRecipients":[ - - ], - "ReplyTo":[ - - ], - "ConversationId":"convoId1", - "DateTimeReceived":"2015-04-22T09:30:16Z", - "DateTimeSent":"2015-04-22T09:30:09Z", - "IsDeliveryReceiptRequested":false, - "IsReadReceiptRequested":false, - "IsDraft":false, - "IsRead":true, - "WebLink":"https://outlook.office365.com/owa/?ItemID=webid1&exvsurl=1&viewmodel=ReadMessageItem" - }, - { - "@odata.id":"https://outlook.office365.com/api/v1.0/Users('test@unit.com')/Messages('guid2')", - "@odata.etag":"W/\"ck2\"", - "Id":"guid2", - "ChangeKey":"ck2", - "Categories":[ - - ], - "DateTimeCreated":"2015-04-22T09:22:01Z", - "DateTimeLastModified":"2015-04-22T09:23:38Z", - "Subject":"FW: Microsoft Office Not Activating", - "BodyPreview":"rb2", - "Body":{ - "ContentType":"HTML", - "Content":"read body 2" - }, - "Importance":"Normal", - "HasAttachments":false, - "ParentFolderId":"parfoId", - "From":{ - "EmailAddress":{ - "Address":"sender1@unit.com", - "Name":"first sender" - } - }, - "Sender":{ - "EmailAddress":{ - "Address":"sender1@unit.com", - "Name":"first sender" - } - }, - "ToRecipients":[ - { - "EmailAddress":{ - "Address":"test@unit.com", - "Name":"Tester for Testing" - } - } - ], - "CcRecipients":[ - - ], - "BccRecipients":[ - - ], - "ReplyTo":[ - - ], - "ConversationId":"convoId2", - "DateTimeReceived":"2015-04-22T09:22:01Z", - "DateTimeSent":"2015-04-22T09:22:00Z", - "IsDeliveryReceiptRequested":false, - "IsReadReceiptRequested":false, - "IsDraft":false, - "IsRead":true, - "WebLink":"https://outlook.office365.com/owa/?ItemID=webid2&exvsurl=1&viewmodel=ReadMessageItem" - }, - { - "@odata.id":"https://outlook.office365.com/api/v1.0/Users('test@unit.com')/Messages('guid3')", - "@odata.etag":"W/\"ck3\"", - "Id":"guid3", - "ChangeKey":"ck3", - "Categories":[ - - ], - "DateTimeCreated":"2015-04-21T16:04:30Z", - "DateTimeLastModified":"2015-04-22T08:54:50Z", - "Subject":"sub 3", - "BodyPreview":"rb3", - "Body":{ - "ContentType":"HTML", - "Content":"Read body 3" - }, - "Importance":"Normal", - "HasAttachments":false, - "ParentFolderId":"parfoId", - "From":{ - "EmailAddress":{ - "Address":"Another@unit.com", - "Name":"Another One" - } - }, - "Sender":{ - "EmailAddress":{ - "Address":"Another@unit.com", - "Name":"Another One" - } - }, - "ToRecipients":[ - { - "EmailAddress":{ - "Address":"test@unit.com", - "Name":"Tester for Testing" - } - } - ], - "CcRecipients":[ - - ], - "BccRecipients":[ - - ], - "ReplyTo":[ - - ], - "ConversationId":"convoId3", - "DateTimeReceived":"2015-04-21T16:04:30Z", - "DateTimeSent":"2015-04-21T16:04:28Z", - "IsDeliveryReceiptRequested":false, - "IsReadReceiptRequested":false, - "IsDraft":false, - "IsRead":true, - "WebLink":"https://outlook.office365.com/owa/?ItemID=weblink3&exvsurl=1&viewmodel=ReadMessageItem" - }, - { - "@odata.id":"https://outlook.office365.com/api/v1.0/Users('test@unit.com')/Messages('guid4')", - "@odata.etag":"W/\"ck4\"", - "Id":"guid4", - "ChangeKey":"ck4", - "Categories":[ - - ], - "DateTimeCreated":"2015-04-21T01:36:21Z", - "DateTimeLastModified":"2015-04-22T08:49:41Z", - "Subject":"Windows 10 is still underwhealming.", - "BodyPreview":"rb4", - "Body":{ - "ContentType":"HTML", - "Content":"rb4" - }, - "Importance":"High", - "HasAttachments":false, - "ParentFolderId":"parfoId", - "From":{ - "EmailAddress":{ - "Address":"super@unit.com", - "Name":"Sudo Su" - } - }, - "Sender":{ - "EmailAddress":{ - "Address":"super@unit.com", - "Name":"Sudo Su" - } - }, - "ToRecipients":[ - { - "EmailAddress":{ - "Address":"test@unit.com", - "Name":"Tester for Testing" - } - } - ], - "CcRecipients":[ - - ], - "BccRecipients":[ - - ], - "ReplyTo":[ - - ], - "ConversationId":"convoId", - "DateTimeReceived":"2015-04-21T01:36:22Z", - "DateTimeSent":"2015-04-21T01:35:54Z", - "IsDeliveryReceiptRequested":null, - "IsReadReceiptRequested":false, - "IsDraft":false, - "IsRead":true, - "WebLink":"https://outlook.office365.com/owa/?ItemID=weblink4&exvsurl=1&viewmodel=ReadMessageItem" - } - ], - "@odata.nextLink":"https://outlook.office365.com/api/v1.0/me/messages/?%24filter=IsRead+eq+true&%24skip=10" -} diff --git a/tests/requests.py b/tests/requests.py deleted file mode 100644 index 088d3aed..00000000 --- a/tests/requests.py +++ /dev/null @@ -1,66 +0,0 @@ -#This file is a mock the requests library used for unit testing. - -def delete(url,**params): - pass - -def get(url,**params): - print url,params - -def patch(url,**params): - pass - -def post(url,**params): - pass - -def put(url,**params): - pass - -def head(url,**params): - pass - -def options(url,**params): - pass - - - - -############################################################################################################# -####################################### RESPONSES #################################################### -############################################################################################################# -class response: - - def __init__(self,url,**params): - ''' - translates the the url and params given to the correct response for this test. - ''' - - if url == 'https://outlook.office365.com/EWS/OData/Me/Calendars': - self.resp = schedule_resp - - def json(self): - return self.resp - - -schedule_resp = '''{ - u'value':[ - { - u'Name':u'Calendar', - u'Color':u'Auto', - u'@odata.id': u"https://outlook.office365.com/EWS/OData/Users('test@unit.org')/Calendars('bigolguid=')", - u'ChangeKey':u'littleguid=', - u'Id':u'bigolguid=', - u'@odata.etag':u'W/"littleguid="' - }, - { - u'Name':u'dat other cal', - u'Color':u'Auto', - u'@odata.id': u"https://outlook.office365.com/EWS/OData/Users('test@unit.org')/Calendars('bigolguid2=')", - u'ChangeKey':u'littleguid2=', - u'Id':u'bigolguid2=', - u'@odata.etag':u'W/"littleguid2="' - } - ], - u'@odata.context': u'https://outlook.office365.com/EWS/OData/$metadata#Me/Calendars' -} - -''' diff --git a/tests/run_test.sh b/tests/run_test.sh deleted file mode 100755 index 3c8fd6b5..00000000 --- a/tests/run_test.sh +++ /dev/null @@ -1,10 +0,0 @@ -python test_attachment.py -python test_cal.py -python test_event.py -python test_inbox.py -python test_message.py -python test_schedule.py -python test_group.py -python test_contact.py - -echo "all tests done." diff --git a/tests/run_tests_notes.txt b/tests/run_tests_notes.txt new file mode 100644 index 00000000..1cbeafb1 --- /dev/null +++ b/tests/run_tests_notes.txt @@ -0,0 +1,8 @@ +To run this tests you will need pytest installed. + +This tests also needs a "config.py" file with two variables: + +CLIENT_ID = 'you client_id' +CLIENT_SECRET = 'your client_secret' + +For oauth to work you will need to include the o365_token.txt file inside the tests folder once it's configured from the standard oauth authorization flow. \ No newline at end of file diff --git a/tests/test_account.py b/tests/test_account.py new file mode 100644 index 00000000..873face8 --- /dev/null +++ b/tests/test_account.py @@ -0,0 +1,104 @@ +import pytest +from O365 import Account +from .config import Config +from O365.utils import EnvTokenBackend +import logging +log = logging.getLogger(__name__) + +class TestAccount: + + @pytest.mark.parametrize("pop, key_to_pop", [ + (False, ""), + (True, "scopes"), + (True, "password"), + (True, "tenant_id"), + (True, "username"), + (True, "auth_flow_type"), + ]) + def test_authentication(self, pop, key_to_pop): + """ + Test the new auth flow type "password" + """ + kwargs = { + "scopes" : ["basic"], + "tenant_id" : Config.TENANT_ID, + "username" : Config.EMAIL, + "password" : Config.PASSWORD, + "auth_flow_type" : 'password' + } + + if pop: + kwargs.pop(key_to_pop) + + # ValueError: When using the "credentials" or "password" auth_flow the "tenant_id" must be set + if "tenant_id" in key_to_pop: + with pytest.raises(ValueError): + account = Account((Config.CLIENT_ID),**kwargs) + + # ValueError: auth_flow_type is needed + if "auth_flow_type" in key_to_pop: + with pytest.raises(ValueError): + account = Account((Config.CLIENT_ID),**kwargs) + + if key_to_pop not in ("tenant_id", "auth_flow_type"): + account = Account((Config.CLIENT_ID),**kwargs) + # instantiate an account with "offline_access" (scopes="basic" is a default scope that includes "offline_access") + # will give the possibility to use a refresh_token + if not pop: + account.authenticate() + assert account.is_authenticated + assert account.con.refresh_token() + + # instantiate an account without scopes -> no refresh_token + if "scopes" in key_to_pop: + account.authenticate() + assert account.is_authenticated + assert not account.con.refresh_token() + + # Cannot account authenticate without password or username + if key_to_pop in ("username", "password"): + assert not account.authenticate() + + + @pytest.mark.parametrize("token", [ + "authenticate", + "load", + "delete", + ]) + def test_auth_with_environment_variable_token_storage(self, token): + """ + Test the authentication with the new token storage system. + we will use EnvTokenBackend(BaseTokenBackend) + default environment variable name is "O365TOKEN", initialize the class with another token_env_name to change it + """ + env_token = EnvTokenBackend() + kwargs = { + "scopes": ["basic"], + "tenant_id": Config.TENANT_ID, + "username": Config.EMAIL, + "password": Config.PASSWORD, + "auth_flow_type": 'password', + "token_backend": env_token + } + + if token in ("authenticate", "load"): + + # if "load" the account (so the connection) will be initialized with a valid token loaded from environment variable + # this can work only if there is an already valid token stored + kwargs["token_backend"].token = env_token.load_token() if token == "load" else None + account = Account((Config.CLIENT_ID), **kwargs) + # if "authenticate" token will be requested to Microsoft server and stored in environmental variable + account.authenticate() if token == "authenticate" else None + + assert account.is_authenticated + assert account.con.refresh_token() + assert account.con.token_backend.check_token() + + else: + # The authentication fails after loading a None token. + env_token.delete_token() + kwargs["token_backend"].token = env_token.load_token() + account = Account((Config.CLIENT_ID), **kwargs) + + assert not account.is_authenticated + assert not account.con.token_backend.check_token() \ No newline at end of file diff --git a/tests/test_attachment.py b/tests/test_attachment.py deleted file mode 100644 index 00cda8aa..00000000 --- a/tests/test_attachment.py +++ /dev/null @@ -1,68 +0,0 @@ -from O365 import attachment -import unittest -import json -import base64 -from random import randint - - -att_rep = open('attachment.json','r').read() -att_j = json.loads(att_rep) - -class TestAttachment (unittest.TestCase): - - def setUp(self): - self.att = attachment.Attachment(att_j['value'][0]) - - def test_isType(self): - self.assertTrue(self.att.isType('txt')) - - def test_getType(self): - self.assertEqual(self.att.getType(),'.txt') - - def test_save(self): - name = self.att.json['Name'] - name1 = self.newFileName(name) - self.att.json['Name'] = name1 - self.assertTrue(self.att.save('/tmp')) - f = open('/tmp/'+name1,'r').read() - self.assertEqual('testing w00t!',f) - - name2 = self.newFileName(name) - self.att.json['Name'] = name2 - self.assertTrue(self.att.save('/tmp/')) - f = open('/tmp/'+name2,'r').read() - self.assertEqual('testing w00t!',f) - - def newFileName(self,val): - for i in range(4): - val = str(randint(0,9)) + val - - return val - - def test_getByteString(self): - self.assertEqual(self.att.getByteString(),'testing w00t!') - - def test_getBase64(self): - self.assertEqual(self.att.getBase64(),'dGVzdGluZyB3MDB0IQ==\n') - - def test_setByteString(self): - test_string = 'testing testie test' - self.att.setByteString(test_string) - - enc = base64.encodestring(test_string) - - self.assertEqual(self.att.json['ContentBytes'],enc) - - def setBase64(self): - wrong_test_string = 'I am sooooo not base64 encoded.' - right_test_string = 'Base64 <3 all around!' - enc = base64.encodestring(right_test_string) - - self.assertRaises(self.att.setBase64(wrong_test_string)) - self.assertEqual(self.att.json['ContentBytes'],'dGVzdGluZyB3MDB0IQ==\n') - - self.att.setBase64(enc) - self.assertEqual(self.att.json['ContentBytes'],enc) - -if __name__ == '__main__': - unittest.main() diff --git a/tests/test_cal.py b/tests/test_cal.py deleted file mode 100644 index 8ef261b7..00000000 --- a/tests/test_cal.py +++ /dev/null @@ -1,93 +0,0 @@ -from O365 import cal -import unittest -import json -import time - -class Event: - '''mock up event class''' - def __init__(self,json,auth): - self.json = json - self.auth = auth - -cal.Event = Event - -class Resp: - def __init__(self,json_string): - self.jsons = json_string - - def json(self): - return json.loads(self.jsons) - -event_rep = open('events.json','r').read() -no_event_rep = '''{"@odata.context":"https://outlook.office365.com/api/v1.0/$metadata#Me/Calendars('bigolguid')/CalendarView","value":[]}''' - -sch_rep = '''{"@odata.context": "https://outlook.office365.com/EWS/OData/$metadata#Me/Calendars", "value": [{"Name": "Calendar", "Color": "Auto", "@odata.id": "https://outlook.office365.com/EWS/OData/Users(\'test@unit.org\')/Calendars(\'bigolguid=\')", "ChangeKey": "littleguid=", "Id": "bigolguid=", "@odata.etag": "W/\\"littleguid=\\""}, {"Name": "dat other cal", "Color": "Auto", "@odata.id": "https://outlook.office365.com/EWS/OData/Users(\'test@unit.org\')/Calendars(\'bigoldguid2=\')", "ChangeKey": "littleguid2=", "Id": "bigoldguid2=", "@odata.etag": "W/\\"littleguid2=\\""}]}''' - -t_string = '%Y-%m-%dT%H:%M:%SZ' - -s1 = '2015-04-20T17:18:25Z' -e1 = '2016-04-20T17:18:25Z' - -s2 = time.strftime(t_string) -e2 = time.time() -e2 += 3600*24*365 -e2 = time.gmtime(e2) -e2 = time.strftime(t_string,e2) - -s3 = s1 -e3 = '2015-04-25T17:18:25Z' - -def get(url,**params): - t1_url = 'https://outlook.office365.com/api/v1.0/me/calendars/bigoldguid2=/calendarview?startDateTime={0}&endDateTime={1}'.format(s1,e1) - t2_url = 'https://outlook.office365.com/api/v1.0/me/calendars/bigoldguid2=/calendarview?startDateTime={0}&endDateTime={1}'.format(s2,e2) - t3_url = 'https://outlook.office365.com/api/v1.0/me/calendars/bigoldguid2=/calendarview?startDateTime={0}&endDateTime={1}'.format(s3,e3) - if url == t1_url: - ret = Resp(event_rep) - elif url == t2_url: - ret = Resp(no_event_rep) - elif url == t3_url: - ret = Resp(event_rep) - else: - print url - print t1_url - print t2_url - print t3_url - raise - if params['auth'][0] != 'test@unit.com': - raise - if params['auth'][1] != 'pass': - raise - - return ret - -cal.requests.get = get - -auth = ('test@unit.com','pass') - -class TestCalendar (unittest.TestCase): - - def setUp(self): - caljson = json.loads(sch_rep) - self.cal = cal.Calendar(caljson['value'][1],auth) - - def test_getName(self): - self.assertEqual('dat other cal',self.cal.getName()) - - def test_getCalendarId(self): - self.assertEqual('bigoldguid2=',self.cal.getCalendarId()) - - def test_getId(self): - self.assertEqual('bigoldguid2=',self.cal.getCalendarId()) - - def test_getEvents_blank(self): - self.assertEqual(0,len(self.cal.events)) - self.cal.getEvents() - self.assertEqual(0,len(self.cal.events)) - - def test_auth(self): - self.assertEqual('test@unit.com',self.cal.auth[0]) - self.assertEqual('pass',self.cal.auth[1]) - - -if __name__ == '__main__': - unittest.main() diff --git a/tests/test_connection.py b/tests/test_connection.py new file mode 100644 index 00000000..5a921457 --- /dev/null +++ b/tests/test_connection.py @@ -0,0 +1,18 @@ +import pytest +import json + +from O365.connection import Connection, Protocol, MSGraphProtocol, MSOffice365Protocol, DEFAULT_SCOPES + + +class TestConnection: + + def setup_class(self): + pass + + def teardown_class(self): + pass + + def test_blank_connection(self): + with pytest.raises(TypeError): + c1 = Connection() + diff --git a/tests/test_contact.py b/tests/test_contact.py deleted file mode 100644 index bf9fc539..00000000 --- a/tests/test_contact.py +++ /dev/null @@ -1,108 +0,0 @@ -from O365 import contact -import unittest -import json -import time - - -class Resp: - def __init__(self,json_string,code=None): - self.jsons = json_string - self.status_code = code - - def json(self): - return json.loads(self.jsons) - -contact_rep = open('contacts.json','r').read() -contacts_json = json.loads(contact_rep) -jeb = contacts_json['value'][0] -bob = contacts_json['value'][2] - -t_string = '%Y-%m-%dT%H:%M:%SZ' -urls = ['https://outlook.office365.com/api/v1.0/me/contacts/', - 'https://outlook.office365.com/api/v1.0/me/contacts/bigguid1', - 'https://outlook.office365.com/api/v1.0/me/contacts/bigguid2', - 'https://outlook.office365.com/api/v1.0/me/contacts/bigguid3'] - -def delete(url,headers,auth): - if url not in urls: - print url - raise BaseException('Url wrong') - if auth[0] != 'test@unit.com': - raise BaseException('wrong email') - if auth[1] != 'pass': - raise BaseException('wrong password') - if headers['Content-type'] != 'application/json': - raise BaseException('header wrong value for content-type.') - if headers['Accept'] != 'text/plain': - raise BaseException('header accept wrong.') - - return Resp(None,204) - -contact.requests.delete = delete - -def post(url,data,headers,auth): - if url not in urls: - raise BaseException('Url wrong') - if auth[0] != 'test@unit.com': - raise BaseException('wrong email') - if auth[1] != 'pass': - raise BaseException('wrong password') - if headers['Content-type'] != 'application/json': - raise BaseException('header wrong value for content-type.') - if headers['Accept'] != 'application/json': - raise BaseException('header accept wrong.') - - if json.loads(data) != jeb and json.loads(data) != bob: - raise BaseException('data is wrong.') - - return Resp(data,202) - #return True - -contact.requests.post = post - -def patch(url,data,headers,auth): - if url not in urls: - raise BaseException('Url wrong') - if auth[0] != 'test@unit.com': - raise BaseException('wrong email') - if auth[1] != 'pass': - raise BaseException('wrong password') - if headers['Content-type'] != 'application/json': - raise BaseException('header wrong value for content-type.') - if headers['Accept'] != 'application/json': - raise BaseException('header accept wrong.') - - return Resp(data,202) - #return True - -contact.requests.patch = patch - -auth = ('test@unit.com','pass') - -class TestInbox (unittest.TestCase): - - def setUp(self): - self.jeb = contact.Contact(jeb,auth) - self.bob = contact.Contact(bob,auth) - - def test_create(self): - self.assertTrue(self.jeb.create()) - self.assertTrue(self.bob.create()) - - def test_update(self): - self.assertTrue(self.jeb.update()) - self.assertTrue(self.bob.update()) - - def test_delete(self): - self.assertTrue(self.jeb.delete()) - self.assertTrue(self.bob.delete()) - - def test_auth(self): - self.assertEqual('test@unit.com',self.jeb.auth[0]) - self.assertEqual('pass',self.jeb.auth[1]) - - self.assertEqual('test@unit.com',self.bob.auth[0]) - self.assertEqual('pass',self.bob.auth[1]) - -if __name__ == '__main__': - unittest.main() diff --git a/tests/test_event.py b/tests/test_event.py deleted file mode 100644 index 8443ee86..00000000 --- a/tests/test_event.py +++ /dev/null @@ -1,111 +0,0 @@ -from O365 import event -import unittest -import json -import time - -class Calendar: - '''mock up calendar class''' - def __init__(self,json,auth): - self.json = json - self.auth = auth - self.calendarId = json['Id'] - -class Resp: - def __init__(self,json_string): - self.jsons = json_string - - def json(self): - return json.loads(self.jsons) - -event_rep = open('events.json','r').read() -events_json = json.loads(event_rep) -lough = events_json['value'][0] -oughter = events_json['value'][1] - -t_string = '%Y-%m-%dT%H:%M:%SZ' -urls = ['https://outlook.office365.com/api/v1.0/me/events/bigolguid=', - 'https://outlook.office365.com/api/v1.0/me/events/otherguid'] - -def delete(url,headers,auth): - if url not in urls: - raise BaseException('Url wrong') - if auth[0] != 'test@unit.com': - raise BaseException('wrong email') - if auth[1] != 'pass': - raise BaseException('wrong password') - if headers['Content-type'] != 'application/json': - raise BaseException('header wrong value for content-type.') - if headers['Accept'] != 'text/plain': - raise BaseException('header accept wrong.') - - return True - -event.requests.delete = delete - -def post(url,data,headers,auth): - if url != 'https://outlook.office365.com/api/v1.0/me/calendars/0/events': - raise BaseException('Url wrong') - if auth[0] != 'test@unit.com': - raise BaseException('wrong email') - if auth[1] != 'pass': - raise BaseException('wrong password') - if headers['Content-type'] != 'application/json': - raise BaseException('header wrong value for content-type.') - if headers['Accept'] != 'application/json': - raise BaseException('header accept wrong.') - - if json.loads(data) != lough and json.loads(data) != oughter: - raise BaseException('data is wrong.') - - return Resp(data) - -event.requests.post = post - -def patch(url,data,headers,auth): - if url not in urls: - raise BaseException('Url wrong') - if auth[0] != 'test@unit.com': - raise BaseException('wrong email') - if auth[1] != 'pass': - raise BaseException('wrong password') - if headers['Content-type'] != 'application/json': - raise BaseException('header wrong value for content-type.') - if headers['Accept'] != 'application/json': - raise BaseException('header accept wrong.') - - return Resp(data) - -event.requests.patch = patch - -auth = ('test@unit.com','pass') - -cal_json = {'Id':0} -cal = Calendar(cal_json,auth) - -class TestInbox (unittest.TestCase): - - def setUp(self): - self.lough = event.Event(lough,auth,cal) - self.oughter = event.Event(oughter,auth,cal) - - def test_create(self): - self.assertTrue(self.lough.create()) - self.assertTrue(self.oughter.create()) - - def test_update(self): - self.assertTrue(self.lough.update()) - self.assertTrue(self.oughter.update()) - - def test_delete(self): - self.assertTrue(self.lough.delete()) - self.assertTrue(self.oughter.delete()) - - def test_auth(self): - self.assertEqual('test@unit.com',self.lough.auth[0]) - self.assertEqual('pass',self.lough.auth[1]) - - self.assertEqual('test@unit.com',self.oughter.auth[0]) - self.assertEqual('pass',self.oughter.auth[1]) - -if __name__ == '__main__': - unittest.main() diff --git a/tests/test_group.py b/tests/test_group.py deleted file mode 100644 index 2438bdc7..00000000 --- a/tests/test_group.py +++ /dev/null @@ -1,85 +0,0 @@ -from O365 import group -import unittest -import json - -class Contact: - '''mock up Contact class''' - def __init__(self,json,auth): - self.json = json - self.auth = auth - -group.Contact = Contact - -class Resp: - def __init__(self,json_string,status_code=204): - self.jsons = json_string - self.status_code = status_code - - def json(self): - return json.loads(self.jsons) - -cat = open('contacts.json','r').read() -grop = open('groups.json','r').read() -bill = open('conbill.json','r').read() - - -con_folder_url = 'https://outlook.office365.com/api/v1.0/me/contactfolders/{0}/contacts' -folder_url = 'https://outlook.office365.com/api/v1.0/me/contactfolders?$filter=DisplayName eq \'{0}\'' - -engiurl = 'https://outlook.office365.com/api/v1.0/me/contactfolders?$filter=DisplayName eq \'Engineers\'' -billurl = 'https://outlook.office365.com/api/v1.0/me/contactfolders/engiID/contacts' -con_url = 'https://outlook.office365.com/api/v1.0/me/contacts' - -def get(url,auth,params=None): - ret = True - if url == engiurl: - ret = Resp(grop) - elif url == con_url: - ret = Resp(cat) - elif url == billurl: - ret = Resp(bill) - else: - raise Exception('Wrong URL') - if auth[0] != 'Wernher.VonKerman@ksp.org': - raise Exception('Wrong Email') - if auth[1] != 'rakete': - raise Exception('Wrong Password') - - return ret - -group.requests.get = get - -class TestGroup (unittest.TestCase): - - def setUp(self): - self.cons = group.Group('Wernher.VonKerman@ksp.org','rakete') - self.folds = group.Group('Wernher.VonKerman@ksp.org','rakete','Engineers') - - def test_getContacts(self): - #Sanity check - self.assertEqual(len(self.cons.contacts),0) - - #real test - self.assertTrue(self.cons.getContacts()) - - self.assertEqual(len(self.cons.contacts),3) - - def test_folders(self): - #Sanity check - self.assertEqual(len(self.folds.contacts),0) - - #real test - self.assertTrue(self.folds.getContacts()) - - self.assertEqual(len(self.folds.contacts),1) - - def test_auth(self): - self.assertEqual('Wernher.VonKerman@ksp.org',self.cons.auth[0]) - self.assertEqual('rakete',self.cons.auth[1]) - - self.assertEqual('Wernher.VonKerman@ksp.org',self.folds.auth[0]) - self.assertEqual('rakete',self.folds.auth[1]) - - -if __name__ == '__main__': - unittest.main() diff --git a/tests/test_inbox.py b/tests/test_inbox.py deleted file mode 100644 index 54881f14..00000000 --- a/tests/test_inbox.py +++ /dev/null @@ -1,91 +0,0 @@ -from O365 import inbox -import unittest -import json - -class Message: - '''mock up Message class''' - def __init__(self,json,auth): - self.json = json - self.auth = auth - -inbox.Message = Message - -class Resp: - def __init__(self,json_string): - self.jsons = json_string - - def json(self): - return json.loads(self.jsons) - -read_rep = open('read_message.json','r').read() -un_rep = open('unread_message.json','r').read() - - -def get(url,auth,params): - if url == 'https://outlook.office365.com/api/v1.0/me/messages': -# print params - if params == {'$filter': 'IsRead eq false'}: -# print 'getting the unread' - ret = Resp(un_rep) - else: -# print 'getting the read' - ret = Resp(read_rep) - else: - raise Exception('Wrong URL') - if auth[0] != 'test@unit.com': - raise Exception('Wrong Email') - if auth[1] != 'pass': - raise Exception('Wrong Password') - - return ret - -inbox.requests.get = get - -class TestInbox (unittest.TestCase): - - def setUp(self): - self.preFetch = inbox.Inbox('test@unit.com','pass') - self.JITFetch = inbox.Inbox('test@unit.com','pass',getNow=False) - - def test_getMessages(self): - #test to see if they got the messages already, should only work for prefetch - self.assertEqual(len(self.preFetch.messages),1) - self.assertEqual(len(self.JITFetch.messages),0) - - #test to see what happens when they try to download again. this specifically - #addresses an issue raised in on github for issue #3 - self.preFetch.getMessages() - self.JITFetch.setFilter('IsRead eq false') - self.JITFetch.getMessages() - self.assertEqual(len(self.preFetch.messages),1) - self.assertEqual(len(self.JITFetch.messages),1) - - - def test_getRead(self): - #sanity check - self.assertEqual(len(self.preFetch.messages),1) - self.assertEqual(len(self.JITFetch.messages),0) - - - #now fetch the un-read emails. prefetch should still have one extra. - self.preFetch.setFilter('IsRead eq true') - self.preFetch.getMessages() - self.JITFetch.setFilter('IsRead eq true') - self.JITFetch.getMessages() - self.assertEqual(len(self.JITFetch.messages),4) - self.assertEqual(len(self.preFetch.messages),5) - - - def test_auth(self): - self.assertEqual('test@unit.com',self.preFetch.auth[0]) - self.assertEqual('pass',self.preFetch.auth[1]) - - self.assertEqual('test@unit.com',self.JITFetch.auth[0]) - self.assertEqual('pass',self.JITFetch.auth[1]) - - def test_filters(self): - pass - - -if __name__ == '__main__': - unittest.main() diff --git a/tests/test_mailbox.py b/tests/test_mailbox.py new file mode 100644 index 00000000..a72b0967 --- /dev/null +++ b/tests/test_mailbox.py @@ -0,0 +1,31 @@ +from O365 import Account +import json + +class MockConnection: + + ret_value = None + + def get(self, url, params=None, **kwargs): + self.url = url + self.kwargs = kwargs + +class TestMailBox: + + def setup_class(self): + credentials = ("client id","client secret") + self.account = Account(credentials) + self.mailbox = self.account.mailbox() + self.mailbox.con = MockConnection() + + def teardown_class(self): + pass + + def test_mailbox(self): + assert self.mailbox.root + +# def test_get_mailbox_folders(self): +# self.mailbox.con.ret_value = ['Inbox','Drafts'] +# +# folders = self.mailbox.get_folders(limit=5) +# +# assert len(folders) > 0 diff --git a/tests/test_message.py b/tests/test_message.py index 478ac856..1ff60859 100644 --- a/tests/test_message.py +++ b/tests/test_message.py @@ -1,156 +1,402 @@ - -from O365 import message -import unittest -import json - -class Attachment: - '''mock up Message class''' - def __init__(self,json): - self.json = json - -message.Attachment = Attachment - -class Resp: - def __init__(self,json_string,code=200): - self.jsons = json_string - self.status_code = code - - def json(self): - return json.loads(self.jsons) - -read_rep = open('read_message.json','r').read() -un_rep = open('unread_message.json','r').read() -att_m_rep = open('attachment_message.json','r').read() -att_rep = open('attachment.json','r').read() -new_rep = open('newmessage.json','r').read() - -def get(url,**params): - if url == 'https://outlook.office365.com/api/v1.0/me/messages/bigoldguid/attachments': - ret = Resp(att_rep) - else: - raise - if params['auth'][0] != 'test@unit.com': - raise - if params['auth'][1] != 'pass': - raise - - return ret - -message.requests.get = get - -def post(url,data,headers,auth): - if url != 'https://outlook.office365.com/api/v1.0/me/sendmail': - raise - if auth[0] != 'test@unit.com': - raise - if auth[1] != 'pass': - raise - if headers['Content-type'] != 'application/json': - raise - if headers['Accept'] != 'text/plain': - raise - - if isinstance(data,dict) and 'Message' in data.keys(): - if data['Message']['Body']['Content'] == 'The new Cafetaria is open.': - return Resp(None,202) - else: - return Resp(None,400) - else: - return Resp(None,202) - - - -message.requests.post = post - -def patch(url,data,headers,auth): - if url != 'https://outlook.office365.com/api/v1.0/me/messages/big guid=': - raise - if auth[0] != 'test@unit.com': - raise - if auth[1] != 'pass': - raise - if headers['Content-type'] != 'application/json': - raise - if headers['Accept'] != 'application/json': - raise - return True - -message.requests.patch = patch - -auth = ('test@unit.com','pass') - -class TestMessage (unittest.TestCase): - - def setUp(self): - ur = json.loads(un_rep)['value'][0] - self.unread = message.Message(ur,auth) - re = json.loads(read_rep)['value'][0] - self.read = message.Message(re,auth) - att = json.loads(att_m_rep)['value'][0] - self.att = message.Message(att,auth) - - self.newm = message.Message(auth=auth) - - def test_fetchAttachments(self): - self.assertTrue(len(self.att.attachments) == 0) - self.assertTrue(len(self.unread.attachments) == 0) - self.assertTrue(len(self.read.attachments) == 0) - - self.assertEqual(1,self.att.fetchAttachments()) - self.assertEqual(0,self.unread.fetchAttachments()) - self.assertEqual(0,self.read.fetchAttachments()) - - self.assertTrue(len(self.att.attachments) == 1) - self.assertTrue(len(self.unread.attachments) == 0) - self.assertTrue(len(self.read.attachments) == 0) - - def test_sendMessage(self): - self.assertTrue(self.read.sendMessage()) - - self.assertFalse(self.newm.sendMessage()) - - self.newm.setSubject('Meet for lunch?') - self.newm.setBody('The new cafeteria is open.') - self.newm.setRecipients('garthf@1830edad9050849NDA1.onmicrosoft.com') - self.assertTrue(self.newm.sendMessage()) - - def test_markAsRead(self): - self.unread.markAsRead() - - def test_setRecipients(self): - self.assertTrue(len(self.read.json['ToRecipients']) == 1) - self.assertTrue(len(self.unread.json['ToRecipients']) == 1) - self.assertTrue(len(self.att.json['ToRecipients']) == 1) - - self.read.setRecipients('bob@unit.com') - self.assertTrue(self.read.json['ToRecipients'][0]['EmailAddress']['Address'] == 'bob@unit.com') - - self.unread.setRecipients({'EmailAddress':{'Address':'bob@unit.com','Name':'What about'}}) - self.assertTrue(self.unread.json['ToRecipients'][0]['EmailAddress']['Address'] == 'bob@unit.com') - self.assertTrue(self.unread.json['ToRecipients'][0]['EmailAddress']['Name'] == 'What about') - - self.att.setRecipients([{'EmailAddress':{'Address':'bob@unit.com','Name':'What about'}}]) - self.assertTrue(self.att.json['ToRecipients'][0]['EmailAddress']['Address'] == 'bob@unit.com') - self.assertTrue(self.att.json['ToRecipients'][0]['EmailAddress']['Name'] == 'What about') - - def test_addRecipient(self): - self.assertTrue(len(self.read.json['ToRecipients']) == 1) - - self.read.addRecipient('second@unit.com','later') - - self.assertTrue(len(self.read.json['ToRecipients']) == 2) - - self.assertTrue(self.read.json['ToRecipients'][1]['EmailAddress']['Address'] == 'second@unit.com') - self.assertTrue(self.read.json['ToRecipients'][1]['EmailAddress']['Name'] == 'later') - - - def test_auth(self): - self.assertEqual(auth[0],self.read.auth[0]) - self.assertEqual(auth[1],self.read.auth[1]) - self.assertEqual(auth[0],self.unread.auth[0]) - self.assertEqual(auth[1],self.unread.auth[1]) - self.assertEqual(auth[0],self.att.auth[0]) - self.assertEqual(auth[1],self.att.auth[1]) - -if __name__ == '__main__': - unittest.main() +import io +from unittest import mock +from collections import namedtuple, deque + +from O365.connection import MSGraphProtocol +from O365.message import Flag, Message +from O365.utils import ImportanceLevel + + +class TestMessageData: + def test_equality(self): + msg_1 = message(__cloud_data__={"id": "123"}) + msg_2 = message(__cloud_data__={"id": "123"}) + assert msg_1 == msg_2 + + def test_attachments(self): + msg = message() + assert repr(msg.attachments) == "Number of Attachments: 0" + msg.attachments.add([(io.BytesIO(b"content"), "filename.txt")]) + assert len(msg.attachments) == 1 + assert repr(msg.attachments) == "Number of Attachments: 1" + assert "filename.txt" in msg.attachments + msg.attachments.clear() + assert len(msg.attachments) == 0 + + msg.attachments.add([(io.BytesIO(b"content"), "filename.txt")]) + assert [at.name for at in msg.attachments] == ["filename.txt"] + assert msg.attachments[0].name == "filename.txt" + msg.attachments.remove(["filename.txt"]) + + def test_properties(self): + msg = message( + __cloud_data__={ + "subject": "Test", + } + ) + + assert len(msg.bcc) == 0 + assert len(msg.cc) == 0 + assert len(msg.reply_to) == 0 + assert len(msg.to) == 0 + assert msg.sender.address == "" + + assert len(msg.attachments) == 0 + assert msg.body == "" + assert msg.body_preview == "" + assert msg.subject == "Test" + assert msg.unique_body == "" + assert str(msg) == "Subject: Test" + + assert msg.categories == [] + assert msg.created is None + assert msg.has_attachments is False + assert msg.is_delivery_receipt_requested is False + assert msg.is_draft is True + assert msg.is_event_message is False + assert msg.is_read is None + assert msg.is_read_receipt_requested is False + assert msg.meeting_message_type is None + assert msg.modified is None + assert msg.received is None + assert msg.sent is None + + assert msg.flag.status is Flag.NotFlagged + assert msg.importance is ImportanceLevel.Normal + + def test_changes(self): + msg = message() + msg.is_read = True + msg.subject = "Changed" + msg.sender = "alice@example.com" + msg.categories = ["Test"] + msg.add_category("Test") + msg.importance = "normal" + msg.is_read_receipt_requested = True + + def test_body(self): + msg = message( + __cloud_data__={ + "body": { + "contentType": "text", + "content": "content", + } + } + ) + assert msg.body_type == "text" + assert msg.get_body_soup() is None + assert msg.get_body_text() == "content" + msg.body = "more content" + assert msg.body == "more content\ncontent" + msg.body = "" + assert msg.body == "" + + msg = message( + __cloud_data__={ + "body": { + "content": "content", + } + } + ) + assert msg.get_body_soup() is not None + assert msg.get_body_text() == "content" + + def test_to_api_data(self): + msg = message( + __cloud_data__={ + "id": "123", + "isDraft": False, + "body": {"content": ""}, + } + ) + msg.to.add("alice@example.com") + msg.cc.add("alice@example.com") + msg.bcc.add("alice@example.com") + msg.reply_to.add("alice@example.com") + msg.sender = "alice@example.com" + assert msg.to_api_data() == { + "body": {"content": "", "contentType": "HTML"}, + "conversationId": None, + "flag": {"flagStatus": "notFlagged"}, + "hasAttachments": False, + "id": "123", + "importance": "normal", + "isDeliveryReceiptRequested": False, + "isDraft": False, + "isRead": None, + "isReadReceiptRequested": False, + "subject": "", + "parentFolderId": None, + "from": {"emailAddress": {"address": "alice@example.com"}}, + "toRecipients": [{"emailAddress": {"address": "alice@example.com"}}], + "bccRecipients": [{"emailAddress": {"address": "alice@example.com"}}], + "ccRecipients": [{"emailAddress": {"address": "alice@example.com"}}], + "replyTo": [{"emailAddress": {"address": "alice@example.com"}}], + } + + +class TestMessageApiCalls: + base_url = MSGraphProtocol().service_url + + def test_save_draft_with_small_attachment(self): + msg = message() + msg.subject = "Test" + msg.attachments.add([(io.BytesIO(b"content"), "filename.txt")]) + + assert msg.save_draft() is True + [call] = msg.con.calls + assert call.url == self.base_url + "me/mailFolders/Drafts/messages" + assert call.payload == { + "attachments": [ + { + "@odata.type": "#microsoft.graph.fileAttachment", + "contentBytes": "Y29udGVudA==", + "name": "filename.txt", + } + ], + "body": {"content": "", "contentType": "HTML"}, + "flag": {"flagStatus": "notFlagged"}, + "importance": "normal", + "isDeliveryReceiptRequested": False, + "isReadReceiptRequested": False, + "subject": "Test", + } + + def test_save_draft_with_with_small_attachment_when_object_id_is_set(self): + msg = message(__cloud_data__={"id": "123", "isDraft": True}) + msg.attachments.add([(io.BytesIO(b"content"), "filename.txt")]) + + assert msg.save_draft() is True + [call] = msg.con.calls + assert call.url == self.base_url + "me/messages/123/attachments" + assert call.payload == { + "@odata.type": "#microsoft.graph.fileAttachment", + "contentBytes": "Y29udGVudA==", + "name": "filename.txt", + } + + @mock.patch("O365.utils.attachment.UPLOAD_SIZE_LIMIT_SIMPLE", 7) + @mock.patch("O365.utils.attachment.DEFAULT_UPLOAD_CHUNK_SIZE", 5) + def test_save_draft_with_with_large_attachment_when_object_id_is_set(self): + upload_url = "https://sn3302.up.1drv.com/up/foobar" + + msg = message(__cloud_data__={"id": "123", "isDraft": True}) + msg.attachments.add([(io.BytesIO(b"long-content"), "filename.txt")]) + + msg.con.responses.clear() + msg.con.responses.extend( + [ + MockResponse({"uploadUrl": upload_url}), + MockResponse({}), + MockResponse({}), + MockResponse({}), + MockResponse({}), + ] + ) + assert msg.save_draft() is True + assert [c.url for c in msg.con.calls] == [ + self.base_url + "me/messages/123/attachments/createUploadSession", + upload_url, + upload_url, + upload_url, + ] + assert msg.con.calls[0].payload == { + "attachmentItem": { + "attachmentType": "file", + "name": "filename.txt", + "size": 12, + }, + } + assert msg.con.calls[1].payload == b"long-" + assert msg.con.calls[2].payload == b"conte" + assert msg.con.calls[3].payload == b"nt" + + def test_save_draft_with_custom_header(self): + msg = message() + msg.subject = "Test" + my_custom_header = [{"name": "x-my-custom-header", "value": "myHeaderValue"}] + msg.message_headers = my_custom_header + + assert msg.save_draft() is True + [call] = msg.con.calls + assert call.url == self.base_url + "me/mailFolders/Drafts/messages" + assert call.payload == { + "body": {"content": "", "contentType": "HTML"}, + "flag": {"flagStatus": "notFlagged"}, + "importance": "normal", + "isDeliveryReceiptRequested": False, + "isReadReceiptRequested": False, + "subject": "Test", + "internetMessageHeaders": my_custom_header, + } + + def test_save_message(self): + msg = message(__cloud_data__={"id": "123", "isDraft": False}) + msg.subject = "Changed" + msg.save_message() + + def test_delete(self): + msg = message(__cloud_data__={"id": "123"}) + msg.delete() + + def test_forward(self): + msg = message(__cloud_data__={"id": "123", "isDraft": False}) + msg.forward() + + def test_get_event(self): + msg = message( + __cloud_data__={ + "id": "123", + "meetingMessageType": "meetingRequest", + } + ) + msg.con.responses.clear() + msg.con.responses.append(MockResponse({"event": {}})) + assert msg.is_event_message + msg.get_event() + [call] = msg.con.calls + assert call.url == self.base_url + "me/messages/123" + + def test_get_mime_content(self): + msg = message(__cloud_data__={"id": "123"}) + msg.get_mime_content() + [call] = msg.con.calls + assert call.url == self.base_url + "me/messages/123/$value" + + def test_mark_as_read(self): + msg = message(__cloud_data__={"id": "123", "isDraft": False}) + msg.mark_as_read() + [call] = msg.con.calls + assert call.url == self.base_url + "me/messages/123" + assert call.payload == {"isRead": True} + + def test_mark_as_unread(self): + msg = message(__cloud_data__={"id": "123", "isDraft": False}) + msg.mark_as_unread() + [call] = msg.con.calls + assert call.url == self.base_url + "me/messages/123" + assert call.payload == {"isRead": False} + + def test_copy(self): + folder = "Test" + msg = message(__cloud_data__={"id": "123"}) + msg.copy(folder) + [call] = msg.con.calls + assert call.url == self.base_url + "me/messages/123/copy" + assert call.payload == {"destinationId": "Test"} + + def test_move(self): + folder = "Test" + msg = message(__cloud_data__={"id": "123"}) + msg.move(folder) + [call] = msg.con.calls + assert call.url == self.base_url + "me/messages/123/move" + assert call.payload == {"destinationId": "Test"} + + def test_send(self): + msg = message(__cloud_data__={}) + assert msg.send(save_to_sent_folder=False) + [call] = msg.con.calls + assert call.url == self.base_url + "me/sendMail" + assert call.payload == { + "message": { + "body": {"content": "", "contentType": "HTML"}, + "flag": {"flagStatus": "notFlagged"}, + "importance": "normal", + "isDeliveryReceiptRequested": False, + "isReadReceiptRequested": False, + "subject": "", + }, + "saveToSentItems": False, + } + + def test_send_with_headers(self): + my_testheader = {"x-my-custom-header": "some_value"} + msg = message(__cloud_data__={"internetMessageHeaders": [my_testheader]}) + assert msg.send(save_to_sent_folder=False) + [call] = msg.con.calls + assert call.url == self.base_url + "me/sendMail" + assert call.payload == { + "message": { + "body": {"content": "", "contentType": "HTML"}, + "flag": {"flagStatus": "notFlagged"}, + "importance": "normal", + "isDeliveryReceiptRequested": False, + "isReadReceiptRequested": False, + "subject": "", + "internetMessageHeaders": [my_testheader], + }, + "saveToSentItems": False, + } + + def test_send_existing_object(self): + msg = message(__cloud_data__={"id": "123"}) + assert msg.send() + [call] = msg.con.calls + assert call.url == self.base_url + "me/messages/123/send" + + def test_reply(self): + msg = message(__cloud_data__={"id": "123", "isDraft": False}) + msg.reply(to_all=True) + [call] = msg.con.calls + assert call.url == self.base_url + "me/messages/123/createReplyAll" + + def test_save_as_eml(self): + msg = message(__cloud_data__={"id": "123"}) + msg.save_as_eml(to_path=None) + [call] = msg.con.calls + assert call.url == self.base_url + "me/messages/123/$value" + + +def message(**kwargs): + defaults = dict( + con=MockConnection(), + protocol=MSGraphProtocol(), + ) + defaults.update(kwargs) + return Message(**defaults) + + +apicall = namedtuple("apicall", ["method", "url", "payload"]) + + +class MockConnection: + def __init__(self, data=None): + self.calls = [] + data = data or { + "id": "1", + "createdDateTime": "2010-10-10T10:10:10Z", + } + self.responses = deque([MockResponse(data=data)]) + + def patch(self, url, data): + return self._request("patch", url, data) + + def get(self, url, params=None): + return self._request("get", url, None) + + def delete(self, url): + return self._request("delete", url, None) + + def post(self, url, data=None): + return self._request("post", url, data) + + def naive_request(self, url, method, data, headers): + return self._request(method, url, data) + + def _request(self, method, url, data): + self.calls.append(apicall(method, url, data)) + if self.responses: + return self.responses.popleft() + else: + raise IndexError("No more MockResponses prepared") + + +class MockResponse: + def __init__(self, data=None, content="", status_code=200): + self.content = "" + self.data = data + self.status_code = status_code + + def json(self): + return self.data diff --git a/tests/test_planner.py b/tests/test_planner.py new file mode 100644 index 00000000..8f835e0e --- /dev/null +++ b/tests/test_planner.py @@ -0,0 +1,228 @@ +from datetime import datetime, timedelta +import pytest +from O365 import Account +from O365.planner import Plan, Task, Bucket, PlanDetails, TaskDetails +from functools import reduce +from .config import Config +from O365.utils import EnvTokenBackend +from string import printable, ascii_lowercase, digits +from random import choices, randint +from requests.exceptions import HTTPError +import logging +log = logging.getLogger(__name__) + +class TestPlanner: + + def setup_class(self): + credentials = ("client id","client secret") + self.account = Account( + (Config.CLIENT_ID), + scopes=["basic"], + tenant_id=Config.TENANT_ID, + username=Config.EMAIL, + password=Config.PASSWORD, + auth_flow_type='password', + token_backend = EnvTokenBackend() + ) + self.account.authenticate() + self.planner = self.account.planner() + + test_plan_name = "plan_" + ''.join(choices(printable, k=randint(10, 20))) + log.info(f"Creating Plan: {test_plan_name}...") + self.plan = self.planner.create_plan(owner=Config.GROUP_ID, title=test_plan_name) + + test_bucket_name = "bucket_" + ''.join(choices(printable, k=randint(10, 20))) + log.info(f"Creating Bucket: {test_bucket_name}...") + self.bucket = self.plan.create_bucket(name = test_bucket_name) + + test_task_name = "task_" + ''.join(choices(printable, k=randint(10, 20))) + log.info(f"Creating Task: {test_task_name}...") + self.task = self.bucket.create_task( + title=test_task_name, + assignments={ + Config.USER_ID : { + "@odata.type": "microsoft.graph.plannerAssignment", + "orderHint": " !" #Optional + } + }, + #optional kwargs + priority = choices([1, 3, 5, 9], k=1)[0], #1 -> "urgent", 3 -> "important", 5 -> "medium", 9 -> "low" + order_hint = " !", #order_hint is a delicate matter. here i will always use default value " !" + start_date_time = datetime.now(), + due_date_time= datetime.now() + timedelta(days=1), + assignee_priority = " !", + percent_complete = randint(0, 100), + applied_categories = {f"category{i}" : randint(0,1) == 1 for i in range(1, 25) if randint(0,1)} + ) + self.taskDetail = self.task.get_details() + self.planDetail = self.plan.get_details() + + + def teardown_class(self): + + buckets = self.plan.list_buckets() + tasks = self.plan.list_tasks() + + for bucket in buckets: + log.info(f"Deleting Bucket: {bucket.name}...") + bucket.delete() + for task in tasks: + log.info(f"Deleting Task: {task.title}...") + task.delete() + + log.info(f"Deleting Plan: {self.plan.title}...") + self.plan.delete() + + + @pytest.mark.parametrize("method", [ + "get_plan_by_id", + "get_bucket_by_id", + "get_task_by_id", + ]) + def test_planner_get_by_id(self, method): + + # if method == get_plan_by_id -> kwargs = {plan_id = self.plan.object_id} + kwargs = {method.split("_")[1] + "_id": reduce(getattr, [self, method.split("_")[1], "object_id"])} + # getting the object given the id + assert getattr(self.planner, method)(**kwargs) + + + @pytest.mark.parametrize("method, object", [ + ("list_user_tasks", Task), + ("list_group_plans", Plan) + ]) + def test_planner_lists(self, method, object): + + # if method == list_user_tasks -> kwargs = {user_id = Config.USER_ID} + _id = method.split("_")[1] + "_id" + kwargs = { _id : getattr(Config, _id.upper())} + assert len(getattr(self.planner, method)(**kwargs)) > 0 + + # if method == list_user_tasks -> method will return only Task objects + assert all(isinstance(o, object) for o in getattr(self.planner, method)(**kwargs)) + + + @pytest.mark.parametrize("method, object, return_object", [ + ("list_buckets", "plan", Bucket), + ("list_tasks", "plan", Task), + ("get_details", "plan", PlanDetails), + ("list_tasks", "bucket", Task), + ("get_details", "task", TaskDetails), + ]) + def test_get_with_no_args(self, method, object, return_object): + + result = reduce(getattr, [self, object, method])() + + if "list" in method: + assert len(result) > 0 + assert all(isinstance(o, return_object) for o in result) + else: + assert isinstance(result, return_object) + + + @pytest.mark.parametrize("object", [ + "plan", + "planDetail", + "bucket", + "task", + "taskDetail", + ]) + def test_update(self, object): + kwargs = { + #Update Plan + "plan_title" : "plan_update_" + ''.join(choices(printable, k=randint(10, 20))), + + #Update Plan Detail + "planDetail_shared_with" : {Config.USER_ID : randint(0,1) == 1}, + "planDetail_category_descriptions" : { + f"category{i}" : "category_" + ''.join(choices(printable, k=randint(10, 20))) + for i in range(1, 25) if randint(0,1) + }, + + #Update Bucket + "bucket_name" : "bucket_update_" + ''.join(choices(printable, k=randint(10, 20))), + #messing with order_hint will raise error + "bucket_order_hint" : " !", + + #Update Task + "task_title" : "task_update_" + ''.join(choices(printable, k=randint(10, 20))), + "task_assignments" : {Config.USER_ID : None}, #user id : None -> remove task from that user + "task_priority" : choices([1, 3, 5, 9], k=1)[0], + "task_order_hint" : " !", + #if due_date_time is < start_date_time an error will be raised for inconsistency, return 400 client error + "task_start_date_time" : datetime.now(), + "task_due_date_time": datetime.now() + timedelta(days=randint(1, 30)), #30 is an arbitrary choice + "task_assignee_priority" : " !", + "task_percent_complete" : randint(0, 100), + "task_applied_categories" : {f"category{i}" : randint(0,1) == 1 for i in range(1, 25) if randint(0,1)}, + + #Update Task Detail + "taskDetail_checklist" : {f"{i}" : { + "isChecked": randint(0,1) == 1, + "orderHint": " !", + "title": "checklist_" + ''.join(choices(printable, k=randint(10, 20))) + } for i in range(randint(1, 10))}, + "taskDetail_description" : "description " + ''.join(choices(printable, k=randint(10, 20))), + "taskDetail_preview_type" : choices(["automatic", "noPreview", "checklist", "description", "reference"], k=1)[0], + "taskDetail_references" : { + #a not correctly structured url will raise error (e.g. url without .com or others) + f"https://{''.join(choices(ascii_lowercase + digits, k=randint(5, 10)))}.com" : { + "alias": "alias " + ''.join(choices(printable, k=randint(10, 20))), + "previewPriority": " !", + "type": choices(["PowerPoint", "Excel", "Word", "Pdf"], k=1)[0], + } for _ in range(randint(1,10)) + }, + } + + update_kwargs = {} + for key, value in kwargs.items(): + if f"{object}_" in key : + update_kwargs[key[len(f"{object}_"):]] = value + + if update_kwargs: + + previous_etag = reduce(getattr, [self, object, "_etag"]) + assert getattr(self, object).update(**update_kwargs) + + subsequent_etag = reduce(getattr, [self, object, "_etag"]) + assert previous_etag != subsequent_etag + + + @pytest.mark.parametrize("method", [ + "get_plan_by_id", + "get_bucket_by_id", + "get_task_by_id", + "list_user_tasks", + "list_group_plans" + ]) + def test_planner_get_exceptions(self, method): + with pytest.raises(RuntimeError): + #Error: id not provided + kwargs = {method.split("_")[1] + "_id" : None} + getattr(self.planner, method)(**kwargs) + + with pytest.raises(HTTPError): + #Error: 404 id not found + kwargs = {method.split("_")[1] + "_id": "Not existing id"} + getattr(self.planner, method)(**kwargs) + + + @pytest.mark.parametrize("creator, created", [ + ("planner", "plan"), + ("plan", "bucket"), + ("bucket", "task"), + ]) + def test_create_exceptions(self, creator, created): + with pytest.raises(TypeError): + #Error: missing 1 required positional argument + reduce(getattr, [self, creator, "create_" + created])() + + with pytest.raises(RuntimeError): + #Error: required argument is None + reduce(getattr, [self, creator, "create_" + created])(None) + + if creator == "planner": + with pytest.raises(HTTPError): + #Error: 400 invalid id + reduce(getattr, [self, creator, "create_" + created])("Not existing group") + diff --git a/tests/test_protocol.py b/tests/test_protocol.py new file mode 100644 index 00000000..21e11782 --- /dev/null +++ b/tests/test_protocol.py @@ -0,0 +1,81 @@ +import pytest +import json + +from zoneinfo import ZoneInfoNotFoundError +from tzlocal import get_localzone + +from O365.connection import Connection, Protocol, MSGraphProtocol, MSOffice365Protocol, DEFAULT_SCOPES + +TEST_SCOPES = [ + 'Calendars.Read', 'Calendars.Read.Shared', 'Calendars.ReadWrite', 'Calendars.ReadWrite.Shared', + 'Contacts.Read', 'Contacts.Read.Shared', 'Contacts.ReadWrite', 'Contacts.ReadWrite.Shared', + 'Files.Read.All', 'Files.ReadWrite.All', + 'Mail.Read', 'Mail.Read.Shared', 'Mail.ReadWrite', 'Mail.ReadWrite.Shared', 'Mail.Send', 'Mail.Send.Shared', + 'MailboxSettings.ReadWrite', + 'Presence.Read', + 'Sites.Read.All', 'Sites.ReadWrite.All', + 'Tasks.Read', 'Tasks.ReadWrite', + 'User.Read', 'User.ReadBasic.All', + 'offline_access' + ] + +class TestProtocol: + + def setup_class(self): + self.proto = Protocol(protocol_url="testing", api_version="0.0") + + def teardown_class(self): + pass + + def test_blank_protocol(self): + with pytest.raises(ValueError): + p = Protocol() + + def test_to_api_case(self): + assert(self.proto.to_api_case("CaseTest") == "case_test") + + def test_get_scopes_for(self): + with pytest.raises(ValueError): + self.proto.get_scopes_for(123) # should error sicne it's not a list or tuple. + + assert(self.proto.get_scopes_for(['mailbox']) == ['mailbox']) + + assert(self.proto.get_scopes_for(None) == []) + + assert(self.proto.get_scopes_for('mailbox') == ['mailbox']) + + self.proto._oauth_scopes = DEFAULT_SCOPES + + assert(self.proto.get_scopes_for(['mailbox']) == ['Mail.Read']) + + # This test verifies that the scopes in the default list don't change + #without us noticing. It makes sure that all the scopes we get back are + #in the current set of scopes we expect. And all the scopes that we are + #expecting are in the scopes we are getting back. The list contains the + #same stuff but may not be in the same order and are therefore not equal + scopes = self.proto.get_scopes_for(None) + for scope in scopes: + assert(scope in TEST_SCOPES) + for scope in TEST_SCOPES: + assert(scope in scopes) + + assert(self.proto.get_scopes_for('mailbox') == ['Mail.Read']) + + def test_prefix_scope(self): + assert(self.proto.prefix_scope('Mail.Read') == 'Mail.Read') + + self.proto.protocol_scope_prefix = 'test_prefix_' + + assert(self.proto.prefix_scope('test_prefix_Mail.Read') == 'test_prefix_Mail.Read') + + assert(self.proto.prefix_scope('Mail.Read') == 'test_prefix_Mail.Read') + + def test_decendant_MSOffice365Protocol(self): + # Basically we just test that it can create the class w/o erroring. + msp = MSOffice365Protocol() + + # Make sure these don't change without going noticed. + assert(msp.keyword_data_store['message_type'] == 'Microsoft.OutlookServices.Message') + assert(msp.keyword_data_store['file_attachment_type'] == '#Microsoft.OutlookServices.FileAttachment') + assert(msp.keyword_data_store['item_attachment_type'] == '#Microsoft.OutlookServices.ItemAttachment') + assert(msp.max_top_value == 999) diff --git a/tests/test_range.py b/tests/test_range.py new file mode 100644 index 00000000..6fdbf373 --- /dev/null +++ b/tests/test_range.py @@ -0,0 +1,25 @@ +import pytest + +from O365.utils import col_index_to_label + + +class TestRange: + EXPECTED_CHARS = [ + 'A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z', + 'AA','AB','AC','AD','AE','AF','AG','AH','AI','AJ','AK','AL','AM','AN','AO','AP','AQ','AR','AS','AT','AU','AV','AW','AX','AY','AZ', + 'BA','BB','BC','BD','BE','BF','BG','BH','BI','BJ','BK','BL','BM','BN','BO','BP','BQ','BR','BS','BT','BU','BV','BW','BX','BY','BZ', + ] + def setup_class(self): + pass + + def teardown_class(self): + pass + + def test_col_index_to_label(self): + for i in range(len(self.EXPECTED_CHARS)): + expected_index = i + expected_label = self.EXPECTED_CHARS[expected_index] + label = col_index_to_label(i) + print(f'Index {i} Letter Index {i} Label {label} Expected {expected_label}') + + assert label == expected_label diff --git a/tests/test_recipient.py b/tests/test_recipient.py new file mode 100644 index 00000000..bf0dec99 --- /dev/null +++ b/tests/test_recipient.py @@ -0,0 +1,21 @@ +import pytest + +from O365.utils import Recipient + + +class TestRecipient: + def setup_class(self): + pass + + def teardown_class(self): + pass + + def test_recipient_str(self): + recipient = Recipient() + assert str(recipient) == "" + + recipient = Recipient(address="john@example.com") + assert str(recipient) == "john@example.com" + + recipient = Recipient(address="john@example.com", name="John Doe") + assert str(recipient) == "John Doe " diff --git a/tests/test_schedule.py b/tests/test_schedule.py deleted file mode 100644 index a930f431..00000000 --- a/tests/test_schedule.py +++ /dev/null @@ -1,51 +0,0 @@ -from O365 import schedule -import unittest -import json - -class Calendar: - '''mock up calendar class''' - def __init__(self,json,auth): - self.json = json - self.auth = auth - -schedule.Calendar = Calendar - -class Resp: - def __init__(self,json_string): - self.jsons = json_string - - def json(self): - return json.loads(self.jsons) - -sch_rep = '''{"@odata.context": "https://outlook.office365.com/EWS/OData/$metadata#Me/Calendars", "value": [{"Name": "Calendar", "Color": "Auto", "@odata.id": "https://outlook.office365.com/EWS/OData/Users(\'test@unit.org\')/Calendars(\'bigolguid=\')", "ChangeKey": "littleguid=", "Id": "bigolguid=", "@odata.etag": "W/\\"littleguid=\\""}, {"Name": "dat other cal", "Color": "Auto", "@odata.id": "https://outlook.office365.com/EWS/OData/Users(\'test@unit.org\')/Calendars(\'bigoldguid2=\')", "ChangeKey": "littleguid2=", "Id": "bigoldguid2=", "@odata.etag": "W/\\"littleguid2=\\""}]}''' - - -def get(url,**params): - if url != 'https://outlook.office365.com/EWS/OData/Me/Calendars': - raise - if params['auth'][0] != 'test@unit.com': - raise - if params['auth'][1] != 'pass': - raise - - ret = Resp(sch_rep) - return ret - -schedule.requests.get = get - -class TestSchedule (unittest.TestCase): - - def setUp(self): - self.val = schedule.Schedule('test@unit.com','pass') - - def test_getCalendar(self): - self.val.getCalendars() - self.assertEqual(2,len(self.val.calendars)) - - def test_auth(self): - self.assertEqual('test@unit.com',self.val.auth[0]) - self.assertEqual('pass',self.val.auth[1]) - - -if __name__ == '__main__': - unittest.main() diff --git a/tests/test_teams.py b/tests/test_teams.py new file mode 100644 index 00000000..0a2defcf --- /dev/null +++ b/tests/test_teams.py @@ -0,0 +1,282 @@ +import pytest +import time +from O365 import Account +from O365.utils import FileSystemTokenBackend +from tests import config + +TEST_SCOPES = ['offline_access', 'Channel.ReadBasic.All', + 'ChannelMessage.Read.All', 'ChannelMessage.Send', + 'Chat.ReadWrite', 'ChatMember.ReadWrite', 'Team.ReadBasic.All', + 'User.Read', 'Presence.Read', 'Channel.Create', + 'TeamsAppInstallation.ReadForTeam'] + + +@pytest.fixture(scope='module') +def teams(): + token_backend = FileSystemTokenBackend('.//') + credentials = (config.CLIENT_ID, config.CLIENT_SECRET) + account = Account(credentials, scopes=TEST_SCOPES, + token_backend=token_backend) + yield account.teams() + + +class TestTeams: + + def test_get_my_presence(self, teams): + my_presence = teams.get_my_presence() + assert my_presence + assert my_presence.object_id + + def test_get_my_teams(self, teams): + my_teams = teams.get_my_teams() + assert my_teams + for team in my_teams: + assert team.main_resource == '/teams/{}'.format(team.object_id) + + def test_get_my_chats(self, teams): + my_chats = teams.get_my_chats() + assert my_chats + for chat in my_chats: + assert chat.main_resource == '/chats/{}'.format(chat.object_id) + + def test_get_channels(self, teams): + my_teams = teams.get_my_teams() + assert my_teams + for team in my_teams: + channels = teams.get_channels(team.object_id) + assert channels + for channel in channels: + assert channel.main_resource == '/channels/{}'.format( + channel.object_id) + + def test_create_channel(self, teams): + my_teams = teams.get_my_teams() + assert my_teams + team_id = my_teams[0].object_id + display_name = 'My Test Channel {}'.format(time.time()) + description = 'My Description' + channel = teams.create_channel(team_id, display_name, + description) + assert channel + assert channel.display_name == display_name + assert channel.description == description + assert channel.main_resource == '/channels/{}'.format( + channel.object_id) + + def test_get_channel(self, teams): + my_teams = teams.get_my_teams() + assert my_teams + for team in my_teams: + channels = teams.get_channels(team.object_id) + assert channels + for channel in channels: + this_channel = teams.get_channel(team.object_id, + channel.object_id) + assert this_channel.main_resource == '/channels/{}'.format( + this_channel.object_id) + + def test_get_apps_in_team(self, teams): + my_teams = teams.get_my_teams() + assert my_teams + for team in my_teams: + apps = teams.get_apps_in_team(team.object_id) + assert apps + for app in apps: + assert app.object_id + + +class TestTeam: + + def test_get_channels(self, teams): + my_teams = teams.get_my_teams() + assert my_teams + for team in my_teams: + channels = team.get_channels() + assert channels + for channel in channels: + assert channel.main_resource == '/teams/{}/channels/{}'.format( + team.object_id, channel.object_id) + + def test_get_channel(self, teams): + my_teams = teams.get_my_teams() + assert my_teams + for team in my_teams: + channels = team.get_channels() + assert channels + for channel in channels: + this_channel = team.get_channel(channel.object_id) + assert this_channel.main_resource == '/teams/{}/channels/{}'.format( + team.object_id, this_channel.object_id) + + +class TestChannel: + + def test_get_messages(self, teams): + my_teams = teams.get_my_teams() + assert my_teams + for team in my_teams: + channels = team.get_channels() + assert channels + for channel in channels: + messages = channel.get_messages() + for message in messages: + assert message.main_resource == '/teams/{}/channels/{}/messages/{}'.format( + team.object_id, channel.object_id, message.object_id) + + def test_get_message(self, teams): + my_teams = teams.get_my_teams() + assert my_teams + for team in my_teams: + channels = team.get_channels() + assert channels + for channel in channels: + messages = channel.get_messages() + for message in messages: + this_message = channel.get_message(message.object_id) + assert this_message.main_resource == '/teams/{}/channels/{}/messages/{}'.format( + team.object_id, channel.object_id, + this_message.object_id) + + def test_send_message(self, teams): + my_teams = teams.get_my_teams() + assert my_teams + channels = my_teams[0].get_channels() + assert channels + + content_text = 'My Test Text' + message_text = channels[0].send_message(content_text) + assert message_text.content == content_text + assert message_text.content_type == 'text' + + content_html = '

My Test HTML

' + message_html = channels[0].send_message(content_html, + content_type='html') + assert message_html.content == content_html + assert message_html.content_type == 'html' + + +class TestChat: + + def test_get_messages(self, teams): + my_chats = teams.get_my_chats() + assert my_chats + for chat in my_chats: + messages = chat.get_messages() + assert messages + for message in messages: + assert message.main_resource == '/chats/{}/messages/{}'.format( + chat.object_id, message.object_id) + + def test_get_message(self, teams): + my_chats = teams.get_my_chats() + assert my_chats + for chat in my_chats: + messages = chat.get_messages() + assert messages + for message in messages: + this_message = chat.get_message(message.object_id) + assert this_message.main_resource == '/chats/{}/messages/{}'.format( + chat.object_id, this_message.object_id) + + def test_send_message(self, teams): + my_chats = teams.get_my_chats() + assert my_chats + chat = my_chats[0] + + content_text = 'My Test Text' + message_text = chat.send_message(content_text) + assert message_text.content == content_text + assert message_text.content_type == 'text' + + content_html = '

My Test HTML

' + message_html = chat.send_message(content_html, content_type='html') + assert message_html.content == content_html + assert message_html.content_type == 'html' + + def test_get_members(self, teams): + my_chats = teams.get_my_chats() + assert my_chats + for chat in my_chats: + members = chat.get_members() + assert members + for member in members: + assert member.main_resource == '/chats/{}/members/{}'.format( + chat.object_id, member.object_id) + + def test_get_member(self, teams): + my_chats = teams.get_my_chats() + assert my_chats + for chat in my_chats: + members = chat.get_members() + assert members + for member in members: + this_member = chat.get_member(member.object_id) + assert this_member.main_resource == '/chats/{}/members/{}'.format( + chat.object_id, this_member.object_id) + + +class TestChannelMessage: + + def test_get_replies(self, teams): + count = 0 + my_teams = teams.get_my_teams() + assert my_teams + for team in my_teams: + channels = team.get_channels() + assert channels + for channel in channels: + messages = channel.get_messages() + for message in messages: + replies = message.get_replies() + + for reply in replies: + count += 1 + assert reply.main_resource == '/teams/{}/channels/{}/messages/{}/replies/{}'.format( + team.object_id, channel.object_id, + message.object_id, reply.object_id) + assert count + + def test_get_reply(self, teams): + count = 0 + my_teams = teams.get_my_teams() + assert my_teams + for team in my_teams: + channels = team.get_channels() + assert channels + for channel in channels: + messages = channel.get_messages() + for message in messages: + replies = message.get_replies() + + for reply in replies: + count += 1 + this_reply = message.get_reply(reply.object_id) + assert this_reply.main_resource == '/teams/{}/channels/{}/messages/{}/replies/{}'.format( + team.object_id, channel.object_id, + message.object_id, this_reply.object_id) + assert count + + + def test_send_reply(self, teams): + my_teams = teams.get_my_teams() + assert my_teams + channels = my_teams[0].get_channels() + assert channels + messages = channels[0].get_messages() + assert messages + + message = None + for _ in messages: + message = _ + break + + content_text = 'My Test Text' + reply_text = message.send_reply(content_text) + assert reply_text.content == content_text + assert reply_text.content_type == 'text' + + content_html = '

My Test HTML

' + reply_html = message.send_reply(content_html, + content_type='html') + assert reply_html.content == content_html + assert reply_html.content_type == 'html' diff --git a/tests/unread_message.json b/tests/unread_message.json deleted file mode 100644 index 3cda7eb9..00000000 --- a/tests/unread_message.json +++ /dev/null @@ -1,62 +0,0 @@ -{ - "@odata.context":"https://outlook.office365.com/api/v1.0/$metadata#Me/Messages", - "value":[ - { - "@odata.id":"https://outlook.office365.com/api/v1.0/Users('test@unit.com')/Messages('big guid=')", - "@odata.etag":"lil guid", - "Id":"big guid=", - "ChangeKey":"lil guid", - "Categories":[ - - ], - "DateTimeCreated":"2015-04-20T16:17:42Z", - "DateTimeLastModified":"2015-04-22T10:10:38Z", - "Subject":"unread test email", - "BodyPreview":"this is the ", - "Body":{ - "ContentType":"HTML", - "Content":"this is the body fully" - }, - "Importance":"Normal", - "HasAttachments":false, - "ParentFolderId":"parfoId=", - "From":{ - "EmailAddress":{ - "Address":"sender@unit.com", - "Name":"Sender for Test" - } - }, - "Sender":{ - "EmailAddress":{ - "Address":"sender@unit.com", - "Name":"Sender for Test" - } - }, - "ToRecipients":[ - { - "EmailAddress":{ - "Address":"test@unit.com", - "Name":"Tester of Testing" - } - } - ], - "CcRecipients":[ - - ], - "BccRecipients":[ - - ], - "ReplyTo":[ - - ], - "ConversationId":"convoId=", - "DateTimeReceived":"2015-04-20T16:17:42Z", - "DateTimeSent":"2015-04-20T16:17:39Z", - "IsDeliveryReceiptRequested":false, - "IsReadReceiptRequested":false, - "IsDraft":false, - "IsRead":false, - "WebLink":"https://outlook.office365.com/owa/?ItemID=daitemidyo&exvsurl=1&viewmodel=ReadMessageItem" - } - ] -}