├── .coveragerc ├── .gitignore ├── .travis.yml ├── CHANGES.rst ├── CONTRIBUTING.rst ├── LICENSE ├── NOTICE ├── README.md ├── dev_requirements.txt ├── docs ├── Makefile ├── attendee.rst ├── changelog.rst ├── conf.py ├── contributing.rst ├── exchange2010.rst ├── index.rst └── make.bat ├── pyexchange ├── __init__.py ├── base │ ├── __init__.py │ ├── calendar.py │ ├── folder.py │ └── soap.py ├── compat.py ├── connection.py ├── exceptions.py ├── exchange2010 │ ├── __init__.py │ └── soap_request.py └── utils.py ├── requirements.txt ├── setup.cfg ├── setup.py └── tests ├── __init__.py ├── exchange2010 ├── __init__.py ├── fixtures.py ├── test_create_event.py ├── test_create_folder.py ├── test_create_recurring_event.py ├── test_delete_event.py ├── test_delete_folder.py ├── test_event_actions.py ├── test_exchange_service.py ├── test_find_folder.py ├── test_get_event.py ├── test_get_folder.py ├── test_list_events.py ├── test_move_event.py ├── test_move_folder.py └── test_update_event.py ├── fixtures.py ├── test_connection.py └── test_utils.py /.coveragerc: -------------------------------------------------------------------------------- 1 | [report] 2 | # Regexes for lines to exclude from consideration 3 | exclude_lines = 4 | # Have to re-enable the standard pragma 5 | pragma: no cover 6 | 7 | # Don't complain about missing debug-only code: 8 | def __repr__ 9 | if self\.debug 10 | 11 | # Don't complain if tests don't hit defensive assertion code: 12 | raise AssertionError 13 | raise NotImplementedError 14 | 15 | # Don't complain if non-runnable code isn't run: 16 | if 0: 17 | if __name__ == .__main__.: 18 | 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | 3 | samples/creds.py 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Packages 9 | *.egg 10 | *.egg-info 11 | dist 12 | build 13 | eggs 14 | parts 15 | bin 16 | var 17 | sdist 18 | develop-eggs 19 | .installed.cfg 20 | lib 21 | lib64 22 | 23 | docs/_build/* 24 | 25 | # Installer logs 26 | pip-log.txt 27 | 28 | # Unit test / coverage reports 29 | .coverage 30 | .tox 31 | nosetests.xml 32 | .noseids 33 | 34 | # Translations 35 | *.mo 36 | 37 | # Mr Developer 38 | .mr.developer.cfg 39 | .project 40 | .pydevproject 41 | 42 | .idea/ 43 | 44 | # damn you emacs 45 | *~ 46 | 47 | # vim 48 | *.swp 49 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | 3 | python: 4 | - "2.6" 5 | - "2.7" 6 | - "3.3" 7 | - "3.4" 8 | 9 | # command to install dependencies 10 | install: 11 | - pip install -r requirements.txt 12 | - pip install -r dev_requirements.txt 13 | - pip install python-coveralls 14 | 15 | # command to run tests 16 | script: py.test --cov pyexchange --cov-report term-missing tests 17 | 18 | after_success: 19 | - coveralls 20 | -------------------------------------------------------------------------------- /CHANGES.rst: -------------------------------------------------------------------------------- 1 | Changes 2 | ======= 3 | 4 | 0.1 (June 24, 2013) 5 | -------------------- 6 | 7 | Team release - shakin' the bugs out. 8 | 9 | 10 | 0.2 (July 1, 2013) 11 | -------------------- 12 | 13 | Internal company release - RELEASE THE KRAKEN. 14 | 15 | 16 | 0.3 (July 17, 2013) 17 | ------------------ 18 | 19 | Initial public release. 20 | 21 | 22 | 0.3.1 (April 18, 2014) 23 | ---------------------- 24 | 25 | Integrating some more granular exception handling. 26 | 27 | 0.4 (June 2, 2014) 28 | ------------------ 29 | 30 | We had some great contributions, so this is a release for that. 31 | 32 | Alejandro Ramirez (got-root): 33 | 34 | - Added functionality to create/delete/get/find/move folders of all types. (Creating a new CalendarFolder is creating a new calendar in exchange) 35 | - Added ability to create events in specific folders. 36 | - Added ability to move events between calendars (folders). 37 | - Created tests for all new features. 38 | 39 | Ben Le (kantas92) 40 | 41 | * Fixed unicode vs bytecode encoding madness when sending unicode. 42 | 43 | 0.4.1 (June 15, 2014) 44 | ------------------ 45 | 46 | Turns out I actually didn't release Ben Le's code when I thought I did. Bad release engineer, no biscuit. 47 | 48 | 0.4.2 (October 3, 2014) 49 | ---------------------- 50 | 51 | Alejandro Ramirez (got-root): 52 | 53 | - Bug fixes around the new folder code. 54 | - More documentation on how to use folders. 55 | 56 | 57 | 0.5 (October 15, 2014) 58 | ---------------------- 59 | 60 | ** This release has a potential backwards incompatible change, see below ** 61 | 62 | * Pyexchange uses requests under the hood now (@trustrachel) 63 | 64 | Hey did you know that requests can do NTLM? I didn't. The internal connection class now uses requests 65 | instead of the clunky urllib2. 66 | 67 | There's a backwards incompatible change if you're subclassing the connection object. Requests doesn't 68 | need nearly the crud that urllib2 did, so I changed some of the methods and properties. 69 | 70 | Almost nobody should use this feature, but beware if you do. 71 | 72 | * You can get a list of events between two dates. This was a big limitation of the library before, so a huge 73 | thank you to Eric Matthews (@ematthews)) 74 | 75 | * Fixed bug causing retrieved events to not be in UTC. (Thanks to Alejandro Ramirez (@got-root)) 76 | 77 | * Integrated with travis (finally). 78 | 79 | 0.5.1 (Nov 17, 2014) 80 | -------------------- 81 | 82 | * Bugfix release because we broke stuff :( 83 | 84 | 85 | 0.6 (January 20, 2015) 86 | ---------------------- 87 | 88 | * Python 3 conversion complete! yaaaaaaaaaay 89 | 90 | 91 | 92 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | Contributing 2 | ============ 3 | 4 | Hi! Thanks so much for wanting to contribute. 5 | 6 | Setting up for development 7 | -------------------------- 8 | 9 | There's a few extra steps to set up for development. 10 | 11 | Installing from source 12 | `````````````````````` 13 | 14 | To install in development mode from source, download the source code, then run this:: 15 | 16 | python setup.py develop 17 | 18 | Installing libraries 19 | ```````````````````` 20 | 21 | To do development work, you'll need a few more libraries:: 22 | 23 | pip install -r dev_requirements.txt 24 | 25 | Running the tests 26 | ````````````````` 27 | 28 | Make sure you have the development libraries installed, then run:: 29 | 30 | py.test tests 31 | 32 | Building documentation 33 | `````````````````````` 34 | 35 | Make sure you have the development libraries installed, then do:: 36 | 37 | cd docs 38 | make html 39 | 40 | The generated documentation will be in ``docs/_build/html/``. 41 | 42 | Guidelines 43 | ---------- 44 | 45 | Style guide 46 | ``````````` 47 | 48 | The code follows `PEP8 49 | `_ with the following exceptions: 50 | 51 | * Indentation is 2 spaces (no tabs) 52 | * Line length: use your best judgment. (We all have big monitors now, no need to limit to 80 columns.) 53 | 54 | Your code should pass `flake8 55 | `_ unless readability is hurt. Configuration is in ``setup.cfg``. 56 | 57 | Python versions 58 | ``````````````` 59 | 60 | Your code should work with all versions of Python 2.6 and 2.7. If possible, your code should be compatible with Python 3.3+. 61 | Travis will check that for you automatically. 62 | 63 | 64 | Tests 65 | ````` 66 | 67 | Submitted code should have tests covering the code submitted, and your code should pass the Travis build. 68 | 69 | All fixture data should be unicode, following the guidelines in Ned Batchelder's fantastic `Pragmatic Unicode `_. 70 | 71 | For example, instead of using the string ``"Test string"``, use ``u"tëst strïnġ"``. This will catch unicode 72 | problems up front, saving a world of pain later. 73 | 74 | Google "weirdmaker" for many, many obnoxious sites where you can do this conversion 75 | automatically. 76 | 77 | Ideas for how to contribute 78 | --------------------------- 79 | 80 | If you don't know where to start, documentation is always welcome. 81 | 82 | Microsoft has fantastic documentation on Exchange Web Services (EWS) SOAP request/responses. To add new functionality, 83 | you'll need to find the action you want to add in their documentation. 84 | 85 | Start here: `Exchange Web Services Operations `_ 86 | 87 | The existing codebase and that should get you started. Feel free to contact us for help. 88 | 89 | General areas for improvement are: 90 | 91 | * Python 3 support (updating python-ntlm would be great) 92 | * Support for more versions of Exchange 93 | * Extend calendar functionality 94 | - More fields 95 | - More actions 96 | - Add the ability to output events as JSON 97 | * Add mail functionality 98 | * Add contacts functionality 99 | 100 | 101 | 102 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright © 2013 LinkedIn, Inc All rights reserved. 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /NOTICE: -------------------------------------------------------------------------------- 1 | This product includes the libraries: 2 | * lxml 3 | 4 | A big thank you to Stefan Behnel and contributors for a fantastic XML library. 5 | 6 | (c) 2013 LinkedIn, Inc. All rights reserved. 7 | 8 | Licensed under the Apache License, Version 2.0 (the "License"); 9 | you may not use this file except in compliance with the License. 10 | You may obtain a copy of the License at 11 | 12 | http://www.apache.org/licenses/LICENSE-2.0 13 | 14 | Unless required by applicable law or agreed to in writing, software 15 | distributed under the License is distributed on an "AS IS" BASIS, 16 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 17 | See the License for the specific language governing permissions and 18 | limitations under the License. 19 | 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | PyExchange 2 | =================== 3 | 4 | [![Build Status](https://travis-ci.org/linkedin/pyexchange.svg)](https://travis-ci.org/linkedin/pyexchange) [![Coverage Status](https://coveralls.io/repos/linkedin/pyexchange/badge.png?branch=master)](https://coveralls.io/r/linkedin/pyexchange?branch=master) 5 | 6 | PyExchange is a library for Microsoft Exchange. 7 | 8 | It's incomplete at the moment - it only handles calendar events. We've open sourced it because we found it useful and hope others will, too. 9 | 10 | If you're interested, please see the CONTRIBUTING notes in the repo for hints on where to get started. 11 | 12 | Documentation 13 | ------------- 14 | 15 | Go to https://pyexchange.readthedocs.org for the most recent, up-to-date version of documentation. 16 | 17 | Installation 18 | ------------ 19 | 20 | PyExchange supports Python 2.6 and 2.7, and as of 0.6, is Python 3 compatible. Non CPython implementations may work but are not tested. 21 | 22 | We support Exchange Server version 2010. Others will likely work but are not tested. 23 | 24 | To install, use pip: 25 | 26 | pip install pyexchange 27 | 28 | About 29 | ----- 30 | 31 | Once upon a time there was a beautiful princess, who wanted to connect her web application to the royal Microsoft Exchange server. 32 | 33 | The princess first tried all manner of SOAP libraries, but found them broken, or slow, or not unicode compliant, or plain just didn't work with Exchange. 34 | 35 | "This totally bites," said the princess. "I need like four commands and I don't want to make my own SOAP library." 36 | 37 | She then discovered Microsoft had excellent documentation on its Exchange services with full XML samples. 38 | 39 | "Bitchin," said the princess, who had watched too many 80s movies recently. "I'll just write XML instead." 40 | 41 | So she did, and it worked, and there was much feasting and celebration, followed by a royal battle with accounting over what constituted reasonable mead expenses. 42 | 43 | And everybody lived happily ever after. 44 | 45 | THE END 46 | 47 | -------------------------------------------------------------------------------- /dev_requirements.txt: -------------------------------------------------------------------------------- 1 | sphinx 2 | pytest 3 | pytest-cov 4 | httpretty 5 | flake8 6 | mock 7 | requests_ntlm 8 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # Internal variables. 11 | PAPEROPT_a4 = -D latex_paper_size=a4 12 | PAPEROPT_letter = -D latex_paper_size=letter 13 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 14 | # the i18n builder cannot share the environment and doctrees with the others 15 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 16 | 17 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 18 | 19 | help: 20 | @echo "Please use \`make ' where is one of" 21 | @echo " html to make standalone HTML files" 22 | @echo " dirhtml to make HTML files named index.html in directories" 23 | @echo " singlehtml to make a single large HTML file" 24 | @echo " pickle to make pickle files" 25 | @echo " json to make JSON files" 26 | @echo " htmlhelp to make HTML files and a HTML help project" 27 | @echo " qthelp to make HTML files and a qthelp project" 28 | @echo " devhelp to make HTML files and a Devhelp project" 29 | @echo " epub to make an epub" 30 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 31 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 32 | @echo " text to make text files" 33 | @echo " man to make manual pages" 34 | @echo " texinfo to make Texinfo files" 35 | @echo " info to make Texinfo files and run them through makeinfo" 36 | @echo " gettext to make PO message catalogs" 37 | @echo " changes to make an overview of all changed/added/deprecated items" 38 | @echo " linkcheck to check all external links for integrity" 39 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 40 | 41 | clean: 42 | -rm -rf $(BUILDDIR)/* 43 | 44 | html: 45 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 46 | @echo 47 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 48 | 49 | dirhtml: 50 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 51 | @echo 52 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 53 | 54 | singlehtml: 55 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 56 | @echo 57 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 58 | 59 | pickle: 60 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 61 | @echo 62 | @echo "Build finished; now you can process the pickle files." 63 | 64 | json: 65 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 66 | @echo 67 | @echo "Build finished; now you can process the JSON files." 68 | 69 | htmlhelp: 70 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 71 | @echo 72 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 73 | ".hhp project file in $(BUILDDIR)/htmlhelp." 74 | 75 | qthelp: 76 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 77 | @echo 78 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 79 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 80 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/PyExchange.qhcp" 81 | @echo "To view the help file:" 82 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/PyExchange.qhc" 83 | 84 | devhelp: 85 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 86 | @echo 87 | @echo "Build finished." 88 | @echo "To view the help file:" 89 | @echo "# mkdir -p $$HOME/.local/share/devhelp/PyExchange" 90 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/PyExchange" 91 | @echo "# devhelp" 92 | 93 | epub: 94 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 95 | @echo 96 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 97 | 98 | latex: 99 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 100 | @echo 101 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 102 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 103 | "(use \`make latexpdf' here to do that automatically)." 104 | 105 | latexpdf: 106 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 107 | @echo "Running LaTeX files through pdflatex..." 108 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 109 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 110 | 111 | text: 112 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 113 | @echo 114 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 115 | 116 | man: 117 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 118 | @echo 119 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 120 | 121 | texinfo: 122 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 123 | @echo 124 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 125 | @echo "Run \`make' in that directory to run these through makeinfo" \ 126 | "(use \`make info' here to do that automatically)." 127 | 128 | info: 129 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 130 | @echo "Running Texinfo files through makeinfo..." 131 | make -C $(BUILDDIR)/texinfo info 132 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 133 | 134 | gettext: 135 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 136 | @echo 137 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 138 | 139 | changes: 140 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 141 | @echo 142 | @echo "The overview file is in $(BUILDDIR)/changes." 143 | 144 | linkcheck: 145 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 146 | @echo 147 | @echo "Link check complete; look for any errors in the above output " \ 148 | "or in $(BUILDDIR)/linkcheck/output.txt." 149 | 150 | doctest: 151 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 152 | @echo "Testing of doctests in the sources finished, look at the " \ 153 | "results in $(BUILDDIR)/doctest/output.txt." 154 | -------------------------------------------------------------------------------- /docs/attendee.rst: -------------------------------------------------------------------------------- 1 | Attendee 2 | ======== 3 | 4 | These are helper classes (`named tuples `_, actually) to store data 5 | about event organizers and attendee information. 6 | 7 | .. toctree:: 8 | :maxdepth: 2 9 | 10 | .. class:: ExchangeEventAttendee 11 | 12 | When setting up attendees for an event, you can use this instead of an email address. 13 | 14 | .. attribute:: name 15 | 16 | The name of the person to invite. 17 | 18 | .. attribute:: email 19 | 20 | The email of the person to invite. **Required.** 21 | 22 | .. attribute:: required 23 | 24 | Boolean. True if this person is required, false if they are optional. 25 | 26 | 27 | .. class:: ExchangeEventResponse 28 | 29 | This is returned when you iterate over attendees or resources of an event. It's populated from Exchange's information. 30 | 31 | .. attribute:: name 32 | 33 | The name of the person attending this event, if Exchange knows it. 34 | 35 | .. attribute:: email 36 | 37 | The attendee's email address. This will always be populated. 38 | 39 | .. attribute:: response 40 | 41 | The person's response. Will be one of:: 42 | 43 | RESPONSE_ACCEPTED = u'Accept' 44 | RESPONSE_DECLINED = u'Decline' 45 | RESPONSE_TENTATIVE = u'Tentative' 46 | RESPONSE_UNKNOWN = u'Unknown' # they have not yet replied 47 | 48 | The list of all possible values is in ``pyexchange.calendar.RESPONSES``. 49 | 50 | .. attribute:: last_response 51 | 52 | The datetime (UTC) of when they last responded to the invitation. 53 | 54 | .. attribute:: required 55 | 56 | Boolean. True if this person is required, false if they are optional. 57 | 58 | 59 | .. class:: ExchangeEventOrganizer 60 | 61 | This is returned when you request the organizer of the event. It's populated from Exchange's information. 62 | 63 | .. attribute:: name 64 | 65 | The name of the person who created this event, if Exchange knows it. 66 | 67 | .. attribute:: email 68 | 69 | The email of the event organizer. 70 | -------------------------------------------------------------------------------- /docs/changelog.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../CHANGES.rst 2 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # PyExchange documentation build configuration file, created by 4 | # sphinx-quickstart on Tue Jun 18 14:08:48 2013. 5 | # 6 | # This file is execfile()d with the current directory set to its containing dir. 7 | # 8 | # Note that not all possible configuration values are present in this 9 | # autogenerated file. 10 | # 11 | # All configuration values have a default; values that are commented out 12 | # serve to show the default. 13 | 14 | import sys, os 15 | 16 | # If extensions (or modules to document with autodoc) are in another directory, 17 | # add these directories to sys.path here. If the directory is relative to the 18 | # documentation root, use os.path.abspath to make it absolute, like shown here. 19 | sys.path.append(os.path.abspath('.')) 20 | sys.path.append(os.path.abspath('..')) 21 | 22 | # -- General configuration ----------------------------------------------------- 23 | 24 | # If your documentation needs a minimal Sphinx version, state it here. 25 | #needs_sphinx = '1.0' 26 | 27 | # Add any Sphinx extension module names here, as strings. They can be extensions 28 | # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 29 | extensions = ['sphinx.ext.viewcode','sphinx.ext.autodoc', 'sphinx.ext.intersphinx'] 30 | 31 | # Add any paths that contain templates here, relative to this directory. 32 | templates_path = ['_templates'] 33 | 34 | # The suffix of source filenames. 35 | source_suffix = '.rst' 36 | 37 | # The encoding of source files. 38 | #source_encoding = 'utf-8-sig' 39 | 40 | # The master toctree document. 41 | master_doc = 'index' 42 | 43 | # General information about the project. 44 | project = u'PyExchange' 45 | copyright = u'2013, LinkedIn, Inc' 46 | 47 | # The version info for the project you're documenting, acts as replacement for 48 | # |version| and |release|, also used in various other places throughout the 49 | # built documents. 50 | # 51 | # The short X.Y version. 52 | version = '0.3' 53 | # The full version, including alpha/beta/rc tags. 54 | release = '0.3' 55 | 56 | # The language for content autogenerated by Sphinx. Refer to documentation 57 | # for a list of supported languages. 58 | #language = None 59 | 60 | # There are two options for replacing |today|: either, you set today to some 61 | # non-false value, then it is used: 62 | #today = '' 63 | # Else, today_fmt is used as the format for a strftime call. 64 | #today_fmt = '%B %d, %Y' 65 | 66 | # List of patterns, relative to source directory, that match files and 67 | # directories to ignore when looking for source files. 68 | exclude_patterns = ['_build'] 69 | 70 | # The reST default role (used for this markup: `text`) to use for all documents. 71 | #default_role = None 72 | 73 | # If true, '()' will be appended to :func: etc. cross-reference text. 74 | #add_function_parentheses = True 75 | 76 | # If true, the current module name will be prepended to all description 77 | # unit titles (such as .. function::). 78 | #add_module_names = True 79 | 80 | # If true, sectionauthor and moduleauthor directives will be shown in the 81 | # output. They are ignored by default. 82 | #show_authors = False 83 | 84 | # The name of the Pygments (syntax highlighting) style to use. 85 | pygments_style = 'sphinx' 86 | 87 | # A list of ignored prefixes for module index sorting. 88 | #modindex_common_prefix = [] 89 | 90 | 91 | # -- Options for HTML output --------------------------------------------------- 92 | 93 | # The theme to use for HTML and HTML Help pages. See the documentation for 94 | # a list of builtin themes. 95 | #html_theme = 'flask' 96 | 97 | # Add any paths that contain custom themes here, relative to this directory. 98 | #html_theme_path = ['_themes'] 99 | 100 | # Theme options are theme-specific and customize the look and feel of a theme 101 | # further. For a list of options available for each theme, see the 102 | # documentation. 103 | #html_theme_options = {} 104 | 105 | # Add any paths that contain custom themes here, relative to this directory. 106 | #html_theme_path = [] 107 | 108 | # The name for this set of Sphinx documents. If None, it defaults to 109 | # " v documentation". 110 | #html_title = None 111 | 112 | # A shorter title for the navigation bar. Default is the same as html_title. 113 | #html_short_title = None 114 | 115 | # The name of an image file (relative to this directory) to place at the top 116 | # of the sidebar. 117 | #html_logo = None 118 | 119 | # The name of an image file (within the static path) to use as favicon of the 120 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 121 | # pixels large. 122 | #html_favicon = None 123 | 124 | # Add any paths that contain custom static files (such as style sheets) here, 125 | # relative to this directory. They are copied after the builtin static files, 126 | # so a file named "default.css" will overwrite the builtin "default.css". 127 | html_static_path = ['_static'] 128 | 129 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 130 | # using the given strftime format. 131 | #html_last_updated_fmt = '%b %d, %Y' 132 | 133 | # If true, SmartyPants will be used to convert quotes and dashes to 134 | # typographically correct entities. 135 | #html_use_smartypants = True 136 | 137 | # Custom sidebar templates, maps document names to template names. 138 | #html_sidebars = {} 139 | 140 | # Additional templates that should be rendered to pages, maps page names to 141 | # template names. 142 | #html_additional_pages = {} 143 | 144 | # If false, no module index is generated. 145 | #html_domain_indices = True 146 | 147 | # If false, no index is generated. 148 | #html_use_index = True 149 | 150 | # If true, the index is split into individual pages for each letter. 151 | #html_split_index = False 152 | 153 | # If true, links to the reST sources are added to the pages. 154 | #html_show_sourcelink = True 155 | 156 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 157 | #html_show_sphinx = True 158 | 159 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 160 | #html_show_copyright = True 161 | 162 | # If true, an OpenSearch description file will be output, and all pages will 163 | # contain a tag referring to it. The value of this option must be the 164 | # base URL from which the finished HTML is served. 165 | #html_use_opensearch = '' 166 | 167 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 168 | #html_file_suffix = None 169 | 170 | # Output file base name for HTML help builder. 171 | htmlhelp_basename = 'PyExchangedoc' 172 | 173 | 174 | # -- Options for LaTeX output -------------------------------------------------- 175 | 176 | latex_elements = { 177 | # The paper size ('letterpaper' or 'a4paper'). 178 | #'papersize': 'letterpaper', 179 | 180 | # The font size ('10pt', '11pt' or '12pt'). 181 | #'pointsize': '10pt', 182 | 183 | # Additional stuff for the LaTeX preamble. 184 | #'preamble': '', 185 | } 186 | 187 | # Grouping the document tree into LaTeX files. List of tuples 188 | # (source start file, target name, title, author, documentclass [howto/manual]). 189 | latex_documents = [ 190 | ('index', 'PyExchange.tex', u'PyExchange Documentation', 191 | u'LinkedIn, Inc', 'manual'), 192 | ] 193 | 194 | # The name of an image file (relative to this directory) to place at the top of 195 | # the title page. 196 | #latex_logo = None 197 | 198 | # For "manual" documents, if this is true, then toplevel headings are parts, 199 | # not chapters. 200 | #latex_use_parts = False 201 | 202 | # If true, show page references after internal links. 203 | #latex_show_pagerefs = False 204 | 205 | # If true, show URL addresses after external links. 206 | #latex_show_urls = False 207 | 208 | # Documents to append as an appendix to all manuals. 209 | #latex_appendices = [] 210 | 211 | # If false, no module index is generated. 212 | #latex_domain_indices = True 213 | 214 | 215 | # -- Options for manual page output -------------------------------------------- 216 | 217 | # One entry per manual page. List of tuples 218 | # (source start file, name, description, authors, manual section). 219 | man_pages = [ 220 | ('index', 'pyexchange', u'PyExchange Documentation', 221 | [u'LinkedIn, Inc'], 1) 222 | ] 223 | 224 | # If true, show URL addresses after external links. 225 | #man_show_urls = False 226 | 227 | 228 | # -- Options for Texinfo output ------------------------------------------------ 229 | 230 | # Grouping the document tree into Texinfo files. List of tuples 231 | # (source start file, target name, title, author, 232 | # dir menu entry, description, category) 233 | texinfo_documents = [ 234 | ('index', 'PyExchange', u'PyExchange Documentation', 235 | u'LinkedIn, Inc', 'PyExchange', 'A simple library to connect to Microsoft Exchange.', 236 | 'Miscellaneous'), 237 | ] 238 | 239 | # Documents to append as an appendix to all manuals. 240 | #texinfo_appendices = [] 241 | 242 | # If false, no module index is generated. 243 | #texinfo_domain_indices = True 244 | 245 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 246 | #texinfo_show_urls = 'footnote' 247 | 248 | 249 | 250 | -------------------------------------------------------------------------------- /docs/contributing.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../CONTRIBUTING.rst 2 | -------------------------------------------------------------------------------- /docs/exchange2010.rst: -------------------------------------------------------------------------------- 1 | .. automodule:: pyexchange.exchange2010 2 | 3 | .. toctree:: 4 | :maxdepth: 2 5 | 6 | Exchange2010CalendarEvent 7 | ========================= 8 | 9 | .. autoclass:: Exchange2010CalendarEvent 10 | :members: create, update, cancel, resend_invitations, move_to, conflicting_events, get_occurrence, get_master 11 | 12 | .. attribute:: id 13 | 14 | **Read-only.** The internal id Exchange uses to refer to this event. 15 | 16 | .. attribute:: subject 17 | 18 | The subject or title of the event. 19 | 20 | .. attribute:: start 21 | 22 | The start time and date of the event. Should be a non-naive (i.e. with timezone) datetime object. 23 | 24 | .. attribute:: end 25 | 26 | The end time and date of the event. Should be a non-naive (i.e. with timezone) datetime object. 27 | 28 | .. attribute:: location 29 | 30 | The location of the event. This is a string. 31 | 32 | .. attribute:: html_body 33 | 34 | The HTML version of the message. Either this or :attr:`text_body` must be set. 35 | 36 | .. attribute:: text_body 37 | 38 | The text version of the message. Either this or :attr:`html_body` must be set. 39 | 40 | .. attribute:: body 41 | 42 | **Read-only.** Returns either :attr:`html_body` or :attr:`text_body`, whichever is set. If both are set, :attr:`html_body` is returned. 43 | 44 | .. attribute:: organizer 45 | 46 | **Read-only.** The organizer of the event. 47 | 48 | This returns a :class:`ExchangeEventOrganizer` object. 49 | 50 | .. attribute:: attendees 51 | 52 | All attendees invited to this event. 53 | 54 | Iterating over this property yields a list of :class:`ExchangeEventResponse` objects:: 55 | 56 | for person in event.attendees: 57 | print person.name, person.response 58 | 59 | 60 | You can set the attendee list by assigning to this property:: 61 | 62 | event.attendees = [u'somebody@company.foo', 63 | u'somebodyelse@company.foo'] 64 | 65 | If you add attendees this way, they will be required for the event. 66 | 67 | To add optional attendees, either use :attr:`optional_attendees` or add people using the :class:`ExchangeEventAttendee` object:: 68 | 69 | from pyexchange.base import ExchangeEventAttendee 70 | 71 | attendee = ExchangeEventAttendee(name="Jane Doe", 72 | email="jane@her.email", 73 | required=False) 74 | 75 | event.attendees = attendee 76 | 77 | Attendees must have an email address defined. 78 | 79 | .. attribute:: required_attendees 80 | 81 | Required attendees for this event. :: 82 | 83 | event.required_attendees = [u'important_person@company.foo', 84 | u'admin@company.foo'] 85 | 86 | for person in event.required_attendees: 87 | print person.email 88 | 89 | This property otherwise behaves like :attr:`attendees`. 90 | 91 | .. attribute:: optional_attendees 92 | 93 | Optional attendees for this event. :: 94 | 95 | event.optional_attendees = [u'maybe@company.foo', 96 | u'other_optional@company.foo'] 97 | 98 | for person in event.optional_attendees: 99 | print person.email 100 | 101 | This property otherwise behaves like :attr:`attendees`. 102 | 103 | .. attribute:: resources 104 | 105 | Resources (aka conference rooms) for this event. :: 106 | 107 | event.resources = [u'conferenceroom@company.foo'] 108 | 109 | for room in event.resources: 110 | print room.email 111 | 112 | This property otherwise behaves like :attr:`attendees`. 113 | 114 | .. attribute:: conference_room 115 | 116 | **Read-only.** A property to return the first resource, since the most common use case is a meeting 117 | with one resource (the conference room). :: 118 | 119 | event.resources = [u'conferenceroom@company.foo'] 120 | 121 | print event.conference_room.email # u'conferenceroom@company.foo' 122 | 123 | Returns a :class:`ExchangeEventAttendee` object. 124 | 125 | .. attribute:: recurrence 126 | 127 | A property to set the recurrence type for the event. Possible values are: 'daily', 'weekly', 'monthly', 'yearly'. 128 | 129 | .. attribute:: recurrence_interval 130 | 131 | A property to set the recurrence interval for the event. This should be an int and applies to the following types of recurring events: 'daily', 'weekly', 'monthly'. 132 | It should be a value between 1 and 999 for 'daily'. 133 | It should be a value between 1 and 99 for 'weekly' and 'monthly'. 134 | 135 | .. attribute:: recurrence_end_date 136 | 137 | Should be a datetime.date() object which specifies the end of the recurrence. 138 | 139 | .. attribute:: recurrence_days 140 | 141 | Used in a weekly recurrence to specify which days of the week to schedule the event. This should be a 142 | string of days separated by spaces. ex. "Monday Wednesday" 143 | 144 | .. attribute:: conflicting_event_ids 145 | 146 | **Read-only.** The internal id Exchange uses to refer to conflicting events. 147 | 148 | .. method:: add_attendee(attendees, required=True) 149 | 150 | Adds new attendees to the event. 151 | 152 | *attendees* can be a list of email addresses or :class:`ExchangeEventAttendee` objects. :: 153 | 154 | event.attendees = [u'jane@company.foo', 155 | u'jack@company.foo'] 156 | event.add_attendee([u'chrissie@company.foo']) 157 | 158 | print len(event.attendees) # prints 3 159 | 160 | If *required* is true, attendees will be marked as required. Otherwise, they'll be optional. 161 | 162 | .. method:: remove_attendees(attendees) 163 | 164 | Removes attendees from the event. :: 165 | 166 | event.attendees = [u'jane@company.foo', 167 | u'jack@company.foo', 168 | u'chrissie@company.foo'] 169 | event.remove_attendees([u'jack@company.foo', u'chrissie@company.foo']) 170 | 171 | print len(event.attendees) # prints 1 172 | 173 | *attendees* can be a list of email addresses or :class:`ExchangeEventAttendee` objects. 174 | 175 | .. method:: add_resources(resources) 176 | 177 | Adds new resources to the event. :: 178 | 179 | event.resources = [u'room@company.foo'] 180 | event.add_resources([u'projector@company.foo']) 181 | 182 | print len(event.attendees) # prints 2 183 | 184 | *resources* can be a list of email addresses or :class:`ExchangeEventAttendee` objects. 185 | 186 | .. method:: remove_resources(resources) 187 | 188 | Removes resources from the event. :: 189 | 190 | event.resources = [u'room@company.foo', 191 | u'projector@company.foo'] 192 | event.remove_resources(u'projector@company.foo') 193 | 194 | print len(event.attendees) # prints 1 195 | 196 | *resources* can be a list of email addresses or :class:`ExchangeEventAttendee` objects. 197 | 198 | 199 | Exchange2010FolderService 200 | ========================= 201 | 202 | .. autoclass:: Exchange2010FolderService() 203 | :members: get_folder, new_folder, find_folder 204 | 205 | 206 | Exchange2010Folder 207 | ========================= 208 | 209 | .. autoclass:: Exchange2010Folder() 210 | :members: create, delete, move_to 211 | 212 | .. attribute:: id 213 | 214 | **Read-only.** The internal id Exchange uses to refer to this folder. 215 | 216 | .. attribute:: parent_id 217 | 218 | **Read-only.** The internal id Exchange uses to refer to the parent folder. 219 | 220 | .. attribute:: folder_type 221 | 222 | The type of folder this is. Can be one of the following:: 223 | 224 | 'Folder', 'CalendarFolder', 'ContactsFolder', 'SearchFolder', 'TasksFolder' 225 | 226 | .. attribute:: display_name 227 | 228 | The name of the folder. 229 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | PyExchange 2 | ========== 3 | 4 | .. module:: pyexchange 5 | 6 | PyExchange is a library for Microsoft Exchange. 7 | 8 | It's incomplete at the moment - it only handles calendar events. We've open sourced it because we found it useful and hope others will, too. 9 | 10 | If you're interested in helping us extend it, please see the :doc:`notes on contributing ` for hints on where to get started, or contact us. 11 | 12 | Installation 13 | ------------ 14 | 15 | PyExchange supports Python 2.6 and 2.7. Our code is compatible with 3.3+, but see the notes below on getting it working. Non CPython implementations may work but are not tested. 16 | 17 | We support Exchange Server version 2010. Others will likely work but are not tested. 18 | 19 | To install, use pip:: 20 | 21 | pip install pyexchange 22 | 23 | PyExchange requires `lxml `_ for XML handling. This will be installed by pip on most systems. If you run into problems, please see lxml's `installation instructions `_. 24 | 25 | To install from source, download the source code, then run:: 26 | 27 | python setup.py install 28 | 29 | Python 3 30 | ```````` 31 | 32 | We depend on the library `python-ntlm `_ for authentication. As of July 2013, they have an experimental Python 3 port but it's not in PyPI for easy download. 33 | 34 | Help in this area would be appreciated. 35 | 36 | Introduction 37 | ------------ 38 | 39 | Once upon a time there was a beautiful princess, who wanted to connect her web application to the royal Microsoft Exchange server. 40 | 41 | The princess first tried all manner of SOAP libraries, but found them broken, or slow, or not unicode compliant, or plain just didn't work with Exchange. 42 | 43 | "This totally bites," said the princess. "I need like four commands and I don't want to make my own SOAP library." 44 | 45 | She then discovered Microsoft had excellent documentation on its Exchange services with full XML samples. 46 | 47 | "Bitchin," said the princess, who had watched too many 80s movies recently. "I'll just write XML instead." 48 | 49 | So she did, and it worked, and there was much feasting and celebration, followed by a royal battle with accounting over what constituted reasonable mead expenses. 50 | 51 | And everybody lived happily ever after. 52 | 53 | THE END 54 | 55 | Quickstart 56 | ---------- 57 | 58 | You can get, create, modify, and delete calendar events. 59 | 60 | To work with any existing event, you must know its unique identifier in Exchange. For more information, see the `MSDN Exchange Web Services documentation `_. 61 | 62 | Setting up the connection 63 | ````````````````````````` 64 | 65 | To do anything in Exchange, you first need to create the Exchange service object:: 66 | 67 | from pyexchange import Exchange2010Service, ExchangeNTLMAuthConnection 68 | 69 | URL = u'https://your.email.server.com.here/EWS/Exchange.asmx' 70 | USERNAME = u'YOURDOMAIN\\yourusername' 71 | PASSWORD = u"12345? That's what I have on my luggage!" 72 | 73 | # Set up the connection to Exchange 74 | connection = ExchangeNTLMAuthConnection(url=URL, 75 | username=USERNAME, 76 | password=PASSWORD) 77 | 78 | service = Exchange2010Service(connection) 79 | 80 | Creating an event 81 | ````````````````` 82 | To create an event, use the ``new_event`` method:: 83 | 84 | from datetime import datetime 85 | from pytz import timezone 86 | 87 | # You can set event properties when you instantiate the event... 88 | event = service.calendar().new_event( 89 | subject=u"80s Movie Night", 90 | attendees=[u'your_friend@friendme.domain', u'your_other_friend@their.domain'], 91 | location = u"My house", 92 | ) 93 | 94 | # ...or afterwards 95 | event.start=timezone("US/Pacific").localize(datetime(2013,1,1,15,0,0)) 96 | event.end=timezone("US/Pacific").localize(datetime(2013,1,1,21,0,0)) 97 | 98 | event.html_body = u""" 99 | 100 |

80s Movie night

101 |

We're watching Spaceballs, Wayne's World, and 102 | Bill and Ted's Excellent Adventure.

103 |

PARTY ON DUDES!

104 | 105 | """ 106 | 107 | # Connect to Exchange and create the event 108 | event.create() 109 | 110 | For a full list of fields, see the :class:`.Exchange2010CalendarEvent` documentation. 111 | 112 | When you create an event, Exchange creates a unique identifier for it. You need this to get the event later. 113 | 114 | After you create the object, the ``id`` attribute is populated with this identifier:: 115 | 116 | print event.id # prints None 117 | 118 | # Create the event 119 | event.create() 120 | 121 | print event.id # prints Exchange key 122 | 123 | If you save this key, be warned they're quite long - easily 130+ characters. 124 | 125 | If we could not create the event, a ``pyexchange.exceptions.FailedExchangeException`` exception is thrown. 126 | 127 | Getting an event 128 | ```````````````` 129 | 130 | To work with any existing event, you must know its unique identifier in Exchange. For more information, see the `MSDN Exchange Web Services documentation `_. 131 | 132 | Once you have the id, get the event using the ``get_event`` method:: 133 | 134 | EXCHANGE_ID = u'3123132131231231' 135 | 136 | event = service.calendar().get_event(id=EXCHANGE_ID) 137 | 138 | print event.id # the same as EXCHANGE_ID 139 | print event.subject 140 | print event.location 141 | 142 | print event.start # datetime object 143 | print event.end # datetime object 144 | 145 | print event.body 146 | 147 | for person in event.attendees: 148 | print person.name 149 | print person.email 150 | print person.response # Accepted/Declined 151 | 152 | For a full list of fields, see the :class:`.Exchange2010CalendarEvent` documentation. 153 | 154 | If the id doesn't match anything in Exchange, a ``pyexchange.exceptions.ExchangeItemNotFoundException`` exception is thrown. 155 | 156 | For all other errors, we throw a ``pyexchange.exceptions.FailedExchangeException``. 157 | 158 | Modifying an event 159 | `````````````````` 160 | 161 | To modify an event, first get the event:: 162 | 163 | EXCHANGE_ID = u'3123132131231231' 164 | 165 | event = service.calendar().get_event(id=EXCHANGE_ID) 166 | 167 | Then simply assign to the properties you want to change and use ``update``:: 168 | 169 | event.location = u'New location' 170 | event.attendees = [u'thing1@dr.suess', u'thing2@dr.suess'] 171 | 172 | event.update() 173 | 174 | If the id doesn't match anything in Exchange, a ``pyexchange.exceptions.ExchangeItemNotFoundException`` exception is thrown. 175 | 176 | For all other errors, we throw a ``pyexchange.exceptions.FailedExchangeException``. 177 | 178 | Listing events 179 | `````````````` 180 | 181 | To list events between two dates, simply do:: 182 | 183 | events = my_calendar.list_events( 184 | start=timezone("US/Eastern").localize(datetime(2014, 10, 1, 11, 0, 0)), 185 | end=timezone("US/Eastern").localize(datetime(2014, 10, 29, 11, 0, 0)), 186 | details=True 187 | ) 188 | 189 | This will return a list of Event objects that are between start and end. If no results are found, it will return an empty list (it intentionally will not throw an Exception.):: 190 | 191 | for event in calendar_list.events: 192 | print "{start} {stop} - {subject}".format( 193 | start=event.start, 194 | stop=event.end, 195 | subject=event.subject 196 | ) 197 | 198 | The third argument, 'details', is optional. By default (if details is not specified, or details=False), it will return most of the fields within an event. The full details for the Organizer or Attendees field are not populated by default by Exchange. If these fields are required in your usage, then pass details=True with the request to make a second lookup for these values. The further details can also be loaded after the fact using the load_all_details() function, as below:: 199 | 200 | events = my_calendar.list_events(start, end) 201 | events.load_all_details() 202 | 203 | Cancelling an event 204 | ``````````````````` 205 | 206 | To cancel an event, simply do:: 207 | 208 | event = my_calendar.get_event(id=EXCHANGE_ID) 209 | 210 | event.cancel() 211 | 212 | If the id doesn't match anything in Exchange, a ``pyexchange.exceptions.ExchangeItemNotFoundException`` exception is thrown. 213 | 214 | For all other errors, we throw a ``pyexchange.exceptions.FailedExchangeException``. 215 | 216 | Resending invitations 217 | ````````````````````` 218 | 219 | To resend invitations to all participants, do:: 220 | 221 | event = my_calendar.get_event(id=EXCHANGE_ID) 222 | 223 | event.resend_invitations() 224 | 225 | Creating a new calendar 226 | ``````````````````````` 227 | 228 | To create a new exchange calendar, do:: 229 | 230 | calendar = service.folder().new_folder( 231 | display_name="New Name", # This will be the display name for the new calendar. Can be set to whatever you want. 232 | folder_type="CalendarFolder", # This MUST be set to the value "CalendarFolder". It tells exchange what type of folder to create. 233 | parent_id='calendar', # This does not have to be 'calendar' but is recommended. The value 'calendar' will resolve to the base Calendar folder. 234 | ) 235 | calendar.create() 236 | 237 | By creating a folder of the type "CalendarFolder", you are creating a new calendar. 238 | 239 | Other tips and tricks 240 | ````````````````````` 241 | 242 | You can pickle events if you need to serialize them. (We do this to send invites asynchronously.) :: 243 | 244 | import pickle 245 | 246 | # create event 247 | event = service.calendar().new_event() 248 | 249 | event.subject = u"80s Movie Night" 250 | event.start=timezone("US/Pacific").localize(datetime(2013,1,1,15,0,0)) 251 | event.end=timezone("US/Pacific").localize(datetime(2013,1,1,21,0,0)) 252 | 253 | # Pickle event 254 | pickled_event = pickle.dumps(event) 255 | 256 | # Unpickle 257 | rehydrated_event = pickle.loads(pickled_event) 258 | print rehydrated_event.subject # "80s Movie Night" 259 | 260 | 261 | Changelog 262 | --------- 263 | 264 | * :doc:`changelog ` 265 | 266 | Support 267 | ------- 268 | 269 | To report bugs or get support, please use the `Github issue tracker `_. 270 | 271 | Indices and tables 272 | ================== 273 | 274 | * :ref:`genindex` 275 | * :ref:`modindex` 276 | * :ref:`search` 277 | 278 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | REM Command file for Sphinx documentation 4 | 5 | if "%SPHINXBUILD%" == "" ( 6 | set SPHINXBUILD=sphinx-build 7 | ) 8 | set BUILDDIR=_build 9 | set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . 10 | set I18NSPHINXOPTS=%SPHINXOPTS% . 11 | if NOT "%PAPER%" == "" ( 12 | set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% 13 | set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% 14 | ) 15 | 16 | if "%1" == "" goto help 17 | 18 | if "%1" == "help" ( 19 | :help 20 | echo.Please use `make ^` where ^ is one of 21 | echo. html to make standalone HTML files 22 | echo. dirhtml to make HTML files named index.html in directories 23 | echo. singlehtml to make a single large HTML file 24 | echo. pickle to make pickle files 25 | echo. json to make JSON files 26 | echo. htmlhelp to make HTML files and a HTML help project 27 | echo. qthelp to make HTML files and a qthelp project 28 | echo. devhelp to make HTML files and a Devhelp project 29 | echo. epub to make an epub 30 | echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter 31 | echo. text to make text files 32 | echo. man to make manual pages 33 | echo. texinfo to make Texinfo files 34 | echo. gettext to make PO message catalogs 35 | echo. changes to make an overview over all changed/added/deprecated items 36 | echo. linkcheck to check all external links for integrity 37 | echo. doctest to run all doctests embedded in the documentation if enabled 38 | goto end 39 | ) 40 | 41 | if "%1" == "clean" ( 42 | for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i 43 | del /q /s %BUILDDIR%\* 44 | goto end 45 | ) 46 | 47 | if "%1" == "html" ( 48 | %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html 49 | if errorlevel 1 exit /b 1 50 | echo. 51 | echo.Build finished. The HTML pages are in %BUILDDIR%/html. 52 | goto end 53 | ) 54 | 55 | if "%1" == "dirhtml" ( 56 | %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml 57 | if errorlevel 1 exit /b 1 58 | echo. 59 | echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. 60 | goto end 61 | ) 62 | 63 | if "%1" == "singlehtml" ( 64 | %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml 65 | if errorlevel 1 exit /b 1 66 | echo. 67 | echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. 68 | goto end 69 | ) 70 | 71 | if "%1" == "pickle" ( 72 | %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle 73 | if errorlevel 1 exit /b 1 74 | echo. 75 | echo.Build finished; now you can process the pickle files. 76 | goto end 77 | ) 78 | 79 | if "%1" == "json" ( 80 | %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json 81 | if errorlevel 1 exit /b 1 82 | echo. 83 | echo.Build finished; now you can process the JSON files. 84 | goto end 85 | ) 86 | 87 | if "%1" == "htmlhelp" ( 88 | %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp 89 | if errorlevel 1 exit /b 1 90 | echo. 91 | echo.Build finished; now you can run HTML Help Workshop with the ^ 92 | .hhp project file in %BUILDDIR%/htmlhelp. 93 | goto end 94 | ) 95 | 96 | if "%1" == "qthelp" ( 97 | %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp 98 | if errorlevel 1 exit /b 1 99 | echo. 100 | echo.Build finished; now you can run "qcollectiongenerator" with the ^ 101 | .qhcp project file in %BUILDDIR%/qthelp, like this: 102 | echo.^> qcollectiongenerator %BUILDDIR%\qthelp\PyExchange.qhcp 103 | echo.To view the help file: 104 | echo.^> assistant -collectionFile %BUILDDIR%\qthelp\PyExchange.ghc 105 | goto end 106 | ) 107 | 108 | if "%1" == "devhelp" ( 109 | %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp 110 | if errorlevel 1 exit /b 1 111 | echo. 112 | echo.Build finished. 113 | goto end 114 | ) 115 | 116 | if "%1" == "epub" ( 117 | %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub 118 | if errorlevel 1 exit /b 1 119 | echo. 120 | echo.Build finished. The epub file is in %BUILDDIR%/epub. 121 | goto end 122 | ) 123 | 124 | if "%1" == "latex" ( 125 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 126 | if errorlevel 1 exit /b 1 127 | echo. 128 | echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. 129 | goto end 130 | ) 131 | 132 | if "%1" == "text" ( 133 | %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text 134 | if errorlevel 1 exit /b 1 135 | echo. 136 | echo.Build finished. The text files are in %BUILDDIR%/text. 137 | goto end 138 | ) 139 | 140 | if "%1" == "man" ( 141 | %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man 142 | if errorlevel 1 exit /b 1 143 | echo. 144 | echo.Build finished. The manual pages are in %BUILDDIR%/man. 145 | goto end 146 | ) 147 | 148 | if "%1" == "texinfo" ( 149 | %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo 150 | if errorlevel 1 exit /b 1 151 | echo. 152 | echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. 153 | goto end 154 | ) 155 | 156 | if "%1" == "gettext" ( 157 | %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale 158 | if errorlevel 1 exit /b 1 159 | echo. 160 | echo.Build finished. The message catalogs are in %BUILDDIR%/locale. 161 | goto end 162 | ) 163 | 164 | if "%1" == "changes" ( 165 | %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes 166 | if errorlevel 1 exit /b 1 167 | echo. 168 | echo.The overview file is in %BUILDDIR%/changes. 169 | goto end 170 | ) 171 | 172 | if "%1" == "linkcheck" ( 173 | %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck 174 | if errorlevel 1 exit /b 1 175 | echo. 176 | echo.Link check complete; look for any errors in the above output ^ 177 | or in %BUILDDIR%/linkcheck/output.txt. 178 | goto end 179 | ) 180 | 181 | if "%1" == "doctest" ( 182 | %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest 183 | if errorlevel 1 exit /b 1 184 | echo. 185 | echo.Testing of doctests in the sources finished, look at the ^ 186 | results in %BUILDDIR%/doctest/output.txt. 187 | goto end 188 | ) 189 | 190 | :end 191 | -------------------------------------------------------------------------------- /pyexchange/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | (c) 2013 LinkedIn Corp. All rights reserved. 3 | Licensed under the Apache License, Version 2.0 (the "License");?you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 4 | 5 | Unless required by applicable law or agreed to in writing, software?distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 6 | """ 7 | import logging 8 | from .exchange2010 import Exchange2010Service # noqa 9 | from .connection import ExchangeNTLMAuthConnection # noqa 10 | 11 | # Silence notification of no default logging handler 12 | log = logging.getLogger("pyexchange") 13 | 14 | 15 | class NullHandler(logging.Handler): 16 | def emit(self, record): 17 | pass 18 | 19 | log.addHandler(NullHandler()) 20 | -------------------------------------------------------------------------------- /pyexchange/base/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | (c) 2013 LinkedIn Corp. All rights reserved. 3 | Licensed under the Apache License, Version 2.0 (the "License");?you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 4 | 5 | Unless required by applicable law or agreed to in writing, software?distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 6 | """ 7 | # placeholder 8 | -------------------------------------------------------------------------------- /pyexchange/base/calendar.py: -------------------------------------------------------------------------------- 1 | """ 2 | (c) 2013 LinkedIn Corp. All rights reserved. 3 | Licensed under the Apache License, Version 2.0 (the "License");?you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 4 | 5 | Unless required by applicable law or agreed to in writing, software?distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 6 | """ 7 | from collections import namedtuple 8 | 9 | ExchangeEventOrganizer = namedtuple('ExchangeEventOrganizer', ['name', 'email']) 10 | ExchangeEventAttendee = namedtuple('ExchangeEventAttendee', ['name', 'email', 'required']) 11 | ExchangeEventResponse = namedtuple('ExchangeEventResponse', ['name', 'email', 'response', 'last_response', 'required']) 12 | 13 | 14 | RESPONSE_ACCEPTED = u'Accept' 15 | RESPONSE_DECLINED = u'Decline' 16 | RESPONSE_TENTATIVE = u'Tentative' 17 | RESPONSE_UNKNOWN = u'Unknown' 18 | 19 | RESPONSES = [RESPONSE_ACCEPTED, RESPONSE_DECLINED, RESPONSE_TENTATIVE, RESPONSE_UNKNOWN] 20 | 21 | 22 | class BaseExchangeCalendarService(object): 23 | 24 | def __init__(self, service, calendar_id): 25 | self.service = service 26 | self.calendar_id = calendar_id 27 | 28 | def event(self, id, *args, **kwargs): 29 | raise NotImplementedError 30 | 31 | def get_event(self, id): 32 | raise NotImplementedError 33 | 34 | def new_event(self, **properties): 35 | raise NotImplementedError 36 | 37 | 38 | class BaseExchangeCalendarEvent(object): 39 | 40 | _id = None # Exchange identifier for the event 41 | _change_key = None # Exchange requires a second key when updating/deleting the event 42 | 43 | service = None 44 | calendar_id = None 45 | 46 | subject = u'' 47 | start = None 48 | end = None 49 | location = None 50 | html_body = None 51 | text_body = None 52 | attachments = None 53 | organizer = None 54 | reminder_minutes_before_start = None 55 | is_all_day = None 56 | 57 | recurrence = None 58 | recurrence_end_date = None 59 | recurrence_days = None 60 | recurrence_interval = None 61 | 62 | _type = None 63 | 64 | _attendees = {} # people attending 65 | _resources = {} # conference rooms attending 66 | 67 | _conflicting_event_ids = [] 68 | 69 | _track_dirty_attributes = False 70 | _dirty_attributes = set() # any attributes that have changed, and we need to update in Exchange 71 | 72 | # these attributes can be pickled, or output as JSON 73 | DATA_ATTRIBUTES = [ 74 | u'_id', u'subject', u'start', u'end', u'location', u'html_body', u'text_body', u'organizer', 75 | u'_attendees', u'_resources', u'reminder_minutes_before_start', u'is_all_day', 76 | 'recurrence', 'recurrence_interval', 'recurrence_days', 'recurrence_day', 77 | ] 78 | 79 | RECURRENCE_ATTRIBUTES = [ 80 | 'recurrence', 'recurrence_end_date', 'recurrence_days', 'recurrence_interval', 81 | ] 82 | 83 | WEEKLY_DAYS = [u'Sunday', u'Monday', u'Tuesday', u'Wednesday', u'Thursday', u'Friday', u'Saturday'] 84 | 85 | def __init__(self, service, id=None, calendar_id=u'calendar', xml=None, **kwargs): 86 | self.service = service 87 | self.calendar_id = calendar_id 88 | 89 | if xml is not None: 90 | self._init_from_xml(xml) 91 | elif id is None: 92 | self._update_properties(kwargs) 93 | else: 94 | self._init_from_service(id) 95 | 96 | self._track_dirty_attributes = True # magically look for changed attributes 97 | 98 | def _init_from_service(self, id): 99 | """ Connect to the Exchange service and grab all the properties out of it. """ 100 | raise NotImplementedError 101 | 102 | def _init_from_xml(self, xml): 103 | """ Using already retrieved XML from Exchange, extract properties out of it. """ 104 | raise NotImplementedError 105 | 106 | @property 107 | def id(self): 108 | """ **Read-only.** The internal id Exchange uses to refer to this event. """ 109 | return self._id 110 | 111 | @property 112 | def conflicting_event_ids(self): 113 | """ **Read-only.** The internal id Exchange uses to refer to conflicting events. """ 114 | return self._conflicting_event_ids 115 | 116 | @property 117 | def change_key(self): 118 | """ **Read-only.** When you change an event, Exchange makes you pass a change key to prevent overwriting a previous version. """ 119 | return self._change_key 120 | 121 | @property 122 | def body(self): 123 | """ **Read-only.** Returns either the html_body or the text_body property, whichever is set. """ 124 | return self.html_body or self.text_body or None 125 | 126 | @property 127 | def type(self): 128 | """ **Read-only.** This is an attribute pulled from an event in the exchange store. """ 129 | return self._type 130 | 131 | @property 132 | def attendees(self): 133 | """ 134 | All attendees invited to this event. 135 | 136 | Iterating over this property yields a list of :class:`ExchangeEventResponse` objects:: 137 | 138 | for person in event.attendees: 139 | print person.name, person.response 140 | 141 | 142 | You can set the attendee list by assigning to this property:: 143 | 144 | event.attendees = [u'somebody@somebody.foo', 145 | u'somebodyelse@somebody.foo'] 146 | 147 | event.update() 148 | 149 | If you add attendees this way, they will be required for the event. 150 | 151 | To add optional attendees, either use :attr:`optional_attendees` or add people using the :class:`ExchangeEventAttendee` object:: 152 | 153 | from pyexchange.base import ExchangeEventAttendee 154 | 155 | attendee = ExchangeEventAttendee(name="Jane Doe", 156 | email="jane@her.email", 157 | required=False) 158 | 159 | event.attendees = attendee 160 | 161 | event.update() 162 | 163 | 164 | Attendees must have an email address defined. 165 | 166 | """ 167 | 168 | # this is redundant in python 2, but necessary in python 3 - .values() returns dict_values, not a list 169 | return [attendee for attendee in self._attendees.values()] 170 | 171 | @attendees.setter 172 | def attendees(self, attendees): 173 | self._attendees = self._build_resource_dictionary(attendees) 174 | self._dirty_attributes.add(u'attendees') 175 | 176 | @property 177 | def required_attendees(self): 178 | """ 179 | Required attendees for this event. 180 | 181 | This property otherwise behaves like :attr:`attendees`. 182 | """ 183 | return [attendee for attendee in self._attendees.values() if attendee.required] 184 | 185 | @required_attendees.setter 186 | def required_attendees(self, attendees): 187 | required = self._build_resource_dictionary(attendees, required=True) 188 | 189 | # TODO rsanders medium. This is clunky - have to get around python 2/3 inconsistences :/ 190 | # must be a better way to do it. 191 | 192 | # Diff the list of required people and drop anybody who wasn't included 193 | for attendee in self.required_attendees: 194 | if attendee.email not in required.keys(): 195 | del self._attendees[attendee.email] 196 | 197 | # then add everybody to the list 198 | for email in required: 199 | self._attendees[email] = required[email] 200 | 201 | self._dirty_attributes.add(u'attendees') 202 | 203 | @property 204 | def optional_attendees(self): 205 | """ 206 | Optional attendees for this event. 207 | 208 | This property otherwise behaves like :attr:`attendees`. 209 | """ 210 | return [attendee for attendee in self._attendees.values() if not attendee.required] 211 | 212 | @optional_attendees.setter 213 | def optional_attendees(self, attendees): 214 | optional = self._build_resource_dictionary(attendees, required=False) 215 | 216 | # TODO rsanders medium. This is clunky - have to get around python 2/3 inconsistences :/ 217 | # must be a better way to do it. 218 | 219 | # Diff the list of required people and drop anybody who wasn't included 220 | for attendee in self.optional_attendees: 221 | if attendee.email not in optional.keys(): 222 | del self._attendees[attendee.email] 223 | 224 | # then add everybody to the list 225 | for email in optional: 226 | self._attendees[email] = optional[email] 227 | 228 | self._dirty_attributes.add(u'attendees') 229 | 230 | def add_attendees(self, attendees, required=True): 231 | """ 232 | Adds new attendees to the event. 233 | 234 | *attendees* can be a list of email addresses or :class:`ExchangeEventAttendee` objects. 235 | """ 236 | 237 | new_attendees = self._build_resource_dictionary(attendees, required=required) 238 | 239 | for email in new_attendees: 240 | self._attendees[email] = new_attendees[email] 241 | 242 | self._dirty_attributes.add(u'attendees') 243 | 244 | def remove_attendees(self, attendees): 245 | """ 246 | Removes attendees from the event. 247 | 248 | *attendees* can be a list of email addresses or :class:`ExchangeEventAttendee` objects. 249 | """ 250 | 251 | attendees_to_delete = self._build_resource_dictionary(attendees) 252 | for email in attendees_to_delete.keys(): 253 | if email in self._attendees: 254 | del self._attendees[email] 255 | 256 | self._dirty_attributes.add(u'attendees') 257 | 258 | @property 259 | def resources(self): 260 | """ 261 | Resources (aka conference rooms) for this event. 262 | 263 | This property otherwise behaves like :attr:`attendees`. 264 | """ 265 | # this is redundant in python 2, but necessary in python 3 - .values() returns dict_values, not a list 266 | return [resource for resource in self._resources.values()] 267 | 268 | @resources.setter 269 | def resources(self, resources): 270 | self._resources = self._build_resource_dictionary(resources) 271 | self._dirty_attributes.add(u'resources') 272 | 273 | def add_resources(self, resources): 274 | """ 275 | Adds new resources to the event. 276 | 277 | *resources* can be a list of email addresses or :class:`ExchangeEventAttendee` objects. 278 | """ 279 | new_resources = self._build_resource_dictionary(resources) 280 | 281 | for key in new_resources: 282 | self._resources[key] = new_resources[key] 283 | self._dirty_attributes.add(u'resources') 284 | 285 | def remove_resources(self, resources): 286 | """ 287 | Removes resources from the event. 288 | 289 | *resources* can be a list of email addresses or :class:`ExchangeEventAttendee` objects. 290 | """ 291 | 292 | resources_to_delete = self._build_resource_dictionary(resources) 293 | for email in resources_to_delete.keys(): 294 | if email in self._resources: 295 | del self._resources[email] 296 | 297 | self._dirty_attributes.add(u'resources') 298 | 299 | @property 300 | def conference_room(self): 301 | """ Alias to resources - Exchange calls 'em resources, but this is clearer""" 302 | if self.resources and len(self.resources) == 1: 303 | return self.resources[0] 304 | 305 | def validate(self): 306 | """ Validates that all required fields are present """ 307 | if not self.start: 308 | raise ValueError("Event has no start date") 309 | 310 | if not self.end: 311 | raise ValueError("Event has no end date") 312 | 313 | if self.end < self.start: 314 | raise ValueError("Start date is after end date") 315 | 316 | if self.reminder_minutes_before_start and not isinstance(self.reminder_minutes_before_start, int): 317 | raise TypeError("reminder_minutes_before_start must be of type int") 318 | 319 | if self.is_all_day and not isinstance(self.is_all_day, bool): 320 | raise TypeError("is_all_day must be of type bool") 321 | 322 | def create(self): 323 | raise NotImplementedError 324 | 325 | def update(self, send_only_to_changed_attendees=True): 326 | raise NotImplementedError 327 | 328 | def cancel(self): 329 | raise NotImplementedError 330 | 331 | def resend_invitations(self): 332 | raise NotImplementedError 333 | 334 | def get_master(self): 335 | raise NotImplementedError 336 | 337 | def get_occurrance(self, instance_index): 338 | raise NotImplementedError 339 | 340 | def conflicting_events(self): 341 | raise NotImplementedError 342 | 343 | def as_json(self): 344 | """ Output ourselves as JSON """ 345 | raise NotImplementedError 346 | 347 | def __getstate__(self): 348 | """ Implemented so pickle.dumps() and pickle.loads() work """ 349 | state = {} 350 | for attribute in self.DATA_ATTRIBUTES: 351 | state[attribute] = getattr(self, attribute, None) 352 | return state 353 | 354 | def _build_resource_dictionary(self, resources, required=True): 355 | result = {} 356 | if resources: 357 | item_list = resources if isinstance(resources, list) else [resources] 358 | for item in item_list: 359 | if isinstance(item, ExchangeEventAttendee): 360 | if item.email is None: 361 | raise ValueError(u"You tried to add a resource or attendee with a blank email: {0}".format(item)) 362 | 363 | result[item.email] = ExchangeEventResponse(email=item.email, required=item.required, name=item.name, response=None, last_response=None) 364 | elif isinstance(item, ExchangeEventResponse): 365 | if item.email is None: 366 | raise ValueError(u"You tried to add a resource or attendee with a blank email: {0}".format(item)) 367 | 368 | result[item.email] = item 369 | else: 370 | if item is None: 371 | raise ValueError(u"You tried to add a resource or attendee with a blank email.") 372 | 373 | result[item] = ExchangeEventResponse(email=item, required=required, name=None, response=None, last_response=None) 374 | 375 | return result 376 | 377 | def _update_properties(self, properties): 378 | self._track_dirty_attributes = False 379 | for key in properties: 380 | setattr(self, key, properties[key]) 381 | self._track_dirty_attributes = True 382 | 383 | def __setattr__(self, key, value): 384 | """ Magically track public attributes, so we can track what we need to flush to the Exchange store """ 385 | if self._track_dirty_attributes and not key.startswith(u"_"): 386 | self._dirty_attributes.add(key) 387 | 388 | object.__setattr__(self, key, value) 389 | 390 | def _reset_dirty_attributes(self): 391 | self._dirty_attributes = set() 392 | -------------------------------------------------------------------------------- /pyexchange/base/folder.py: -------------------------------------------------------------------------------- 1 | class BaseExchangeFolderService(object): 2 | 3 | def __init__(self, service): 4 | self.service = service 5 | 6 | def get_folder(self, id, *args, **kwargs): 7 | raise NotImplementedError 8 | 9 | 10 | class BaseExchangeFolder(object): 11 | 12 | _id = None 13 | _change_key = None 14 | _parent_id = None 15 | _parent_change_key = None 16 | _folder_type = u'Folder' 17 | 18 | _service = None 19 | 20 | _display_name = u'' 21 | _child_folder_count = None 22 | _total_count = None 23 | 24 | _track_dirty_attributes = False 25 | _dirty_attributes = set() # any attributes that have changed, and we need to update in Exchange 26 | 27 | FOLDER_TYPES = (u'Folder', u'CalendarFolder', u'ContactsFolder', u'SearchFolder', u'TasksFolder') 28 | 29 | def __init__(self, service, id=None, xml=None, **kwargs): 30 | self.service = service 31 | 32 | if xml is not None: 33 | self._init_from_xml(xml) 34 | elif id is None: 35 | self._update_properties(kwargs) 36 | else: 37 | self._init_from_service(id) 38 | 39 | def _init_from_xml(self, xml): 40 | raise NotImplementedError 41 | 42 | def _init_from_service(self, id): 43 | raise NotImplementedError 44 | 45 | def create(self): 46 | raise NotImplementedError 47 | 48 | def update(self): 49 | raise NotImplementedError 50 | 51 | def delete(self): 52 | raise NotImplementedError 53 | 54 | @property 55 | def id(self): 56 | """ **Read-only.** The internal id Exchange uses to refer to this folder. """ 57 | return self._id 58 | 59 | @property 60 | def change_key(self): 61 | """ **Read-only.** When you change an folder, Exchange makes you pass a change key to prevent overwriting a previous version. """ 62 | return self._change_key 63 | 64 | @property 65 | def parent_id(self): 66 | """ **Read-only.** The internal id Exchange uses to refer to the parent folder. """ 67 | return self._parent_id 68 | 69 | @parent_id.setter 70 | def parent_id(self, value): 71 | self._parent_id = value 72 | 73 | @property 74 | def folder_type(self): 75 | return self._folder_type 76 | 77 | @folder_type.setter 78 | def folder_type(self, value): 79 | if value in self.FOLDER_TYPES: 80 | self._folder_type = value 81 | 82 | def _update_properties(self, properties): 83 | self._track_dirty_attributes = False 84 | for key in properties: 85 | setattr(self, key, properties[key]) 86 | self._track_dirty_attributes = True 87 | 88 | def __setattr__(self, key, value): 89 | """ Magically track public attributes, so we can track what we need to flush to the Exchange store """ 90 | if self._track_dirty_attributes and not key.startswith(u"_"): 91 | self._dirty_attributes.add(key) 92 | 93 | object.__setattr__(self, key, value) 94 | 95 | def _reset_dirty_attributes(self): 96 | self._dirty_attributes = set() 97 | 98 | def validate(self): 99 | """ Validates that all required fields are present """ 100 | if not self.display_name: 101 | raise ValueError("Folder has no display_name") 102 | 103 | if not self.parent_id: 104 | raise ValueError("Folder has no parent_id") 105 | -------------------------------------------------------------------------------- /pyexchange/base/soap.py: -------------------------------------------------------------------------------- 1 | """ 2 | (c) 2013 LinkedIn Corp. All rights reserved. 3 | Licensed under the Apache License, Version 2.0 (the "License");?you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 4 | 5 | Unless required by applicable law or agreed to in writing, software?distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 6 | """ 7 | import logging 8 | 9 | from lxml import etree 10 | from lxml.builder import ElementMaker 11 | from datetime import datetime 12 | from pytz import utc 13 | 14 | from ..exceptions import FailedExchangeException 15 | 16 | SOAP_NS = u'http://schemas.xmlsoap.org/soap/envelope/' 17 | 18 | SOAP_NAMESPACES = {u's': SOAP_NS} 19 | S = ElementMaker(namespace=SOAP_NS, nsmap=SOAP_NAMESPACES) 20 | 21 | log = logging.getLogger('pyexchange') 22 | 23 | 24 | class ExchangeServiceSOAP(object): 25 | 26 | EXCHANGE_DATE_FORMAT = u"%Y-%m-%dT%H:%M:%SZ" 27 | 28 | def __init__(self, connection): 29 | self.connection = connection 30 | 31 | def send(self, xml, headers=None, retries=4, timeout=30, encoding="utf-8"): 32 | request_xml = self._wrap_soap_xml_request(xml) 33 | log.info(etree.tostring(request_xml, encoding=encoding, pretty_print=True)) 34 | response = self._send_soap_request(request_xml, headers=headers, retries=retries, timeout=timeout, encoding=encoding) 35 | return self._parse(response, encoding=encoding) 36 | 37 | def _parse(self, response, encoding="utf-8"): 38 | 39 | try: 40 | tree = etree.XML(response.encode(encoding)) 41 | except (etree.XMLSyntaxError, TypeError) as err: 42 | raise FailedExchangeException(u"Unable to parse response from Exchange - check your login information. Error: %s" % err) 43 | 44 | self._check_for_errors(tree) 45 | 46 | log.info(etree.tostring(tree, encoding=encoding, pretty_print=True)) 47 | return tree 48 | 49 | def _check_for_errors(self, xml_tree): 50 | self._check_for_SOAP_fault(xml_tree) 51 | 52 | def _check_for_SOAP_fault(self, xml_tree): 53 | # Check for SOAP errors. if is anywhere in the response, flip out 54 | 55 | fault_nodes = xml_tree.xpath(u'//s:Fault', namespaces=SOAP_NAMESPACES) 56 | 57 | if fault_nodes: 58 | fault = fault_nodes[0] 59 | log.debug(etree.tostring(fault, pretty_print=True)) 60 | raise FailedExchangeException(u"SOAP Fault from Exchange server", fault.text) 61 | 62 | def _send_soap_request(self, xml, headers=None, retries=2, timeout=30, encoding="utf-8"): 63 | body = etree.tostring(xml, encoding=encoding) 64 | 65 | response = self.connection.send(body, headers, retries, timeout) 66 | return response 67 | 68 | def _wrap_soap_xml_request(self, exchange_xml): 69 | root = S.Envelope(S.Body(exchange_xml)) 70 | return root 71 | 72 | def _parse_date(self, date_string): 73 | date = datetime.strptime(date_string, self.EXCHANGE_DATE_FORMAT) 74 | date = date.replace(tzinfo=utc) 75 | 76 | return date 77 | 78 | def _parse_date_only_naive(self, date_string): 79 | date = datetime.strptime(date_string[0:10], self.EXCHANGE_DATE_FORMAT[0:8]) 80 | 81 | return date.date() 82 | 83 | def _xpath_to_dict(self, element, property_map, namespace_map): 84 | """ 85 | property_map = { 86 | u'name' : { u'xpath' : u't:Mailbox/t:Name'}, 87 | u'email' : { u'xpath' : u't:Mailbox/t:EmailAddress'}, 88 | u'response' : { u'xpath' : u't:ResponseType'}, 89 | u'last_response': { u'xpath' : u't:LastResponseTime', u'cast': u'datetime'}, 90 | } 91 | 92 | This runs the given xpath on the node and returns a dictionary 93 | 94 | """ 95 | 96 | result = {} 97 | 98 | log.info(etree.tostring(element, pretty_print=True)) 99 | 100 | for key in property_map: 101 | item = property_map[key] 102 | log.info(u'Pulling xpath {xpath} into key {key}'.format(key=key, xpath=item[u'xpath'])) 103 | nodes = element.xpath(item[u'xpath'], namespaces=namespace_map) 104 | 105 | if nodes: 106 | result_for_node = [] 107 | 108 | for node in nodes: 109 | cast_as = item.get(u'cast', None) 110 | 111 | if cast_as == u'datetime': 112 | result_for_node.append(self._parse_date(node.text)) 113 | elif cast_as == u'date_only_naive': 114 | result_for_node.append(self._parse_date_only_naive(node.text)) 115 | elif cast_as == u'int': 116 | result_for_node.append(int(node.text)) 117 | elif cast_as == u'bool': 118 | if node.text.lower() == u'true': 119 | result_for_node.append(True) 120 | else: 121 | result_for_node.append(False) 122 | else: 123 | result_for_node.append(node.text) 124 | 125 | if not result_for_node: 126 | result[key] = None 127 | elif len(result_for_node) == 1: 128 | result[key] = result_for_node[0] 129 | else: 130 | result[key] = result_for_node 131 | 132 | return result 133 | -------------------------------------------------------------------------------- /pyexchange/compat.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | IS_PYTHON3 = sys.version_info >= (3, 0) 4 | 5 | if IS_PYTHON3: 6 | BASESTRING_TYPES = str 7 | else: 8 | BASESTRING_TYPES = (str, unicode) 9 | 10 | def _unicode(item): 11 | if IS_PYTHON3: 12 | return str(item) 13 | else: 14 | return unicode(item) -------------------------------------------------------------------------------- /pyexchange/connection.py: -------------------------------------------------------------------------------- 1 | """ 2 | (c) 2013 LinkedIn Corp. All rights reserved. 3 | Licensed under the Apache License, Version 2.0 (the "License");?you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 4 | 5 | Unless required by applicable law or agreed to in writing, software?distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 6 | """ 7 | import requests 8 | from requests_ntlm import HttpNtlmAuth 9 | 10 | import logging 11 | 12 | from .exceptions import FailedExchangeException 13 | 14 | log = logging.getLogger('pyexchange') 15 | 16 | 17 | class ExchangeBaseConnection(object): 18 | """ Base class for Exchange connections.""" 19 | 20 | def send(self, body, headers=None, retries=2, timeout=30, encoding="utf-8"): 21 | raise NotImplementedError 22 | 23 | 24 | class ExchangeNTLMAuthConnection(ExchangeBaseConnection): 25 | """ Connection to Exchange that uses NTLM authentication """ 26 | 27 | def __init__(self, url, username, password, verify_certificate=True, **kwargs): 28 | self.url = url 29 | self.username = username 30 | self.password = password 31 | self.verify_certificate = verify_certificate 32 | self.handler = None 33 | self.session = None 34 | self.password_manager = None 35 | 36 | def build_password_manager(self): 37 | if self.password_manager: 38 | return self.password_manager 39 | 40 | log.debug(u'Constructing password manager') 41 | 42 | self.password_manager = HttpNtlmAuth(self.username, self.password) 43 | 44 | return self.password_manager 45 | 46 | def build_session(self): 47 | if self.session: 48 | return self.session 49 | 50 | log.debug(u'Constructing opener') 51 | 52 | self.password_manager = self.build_password_manager() 53 | 54 | self.session = requests.Session() 55 | self.session.auth = self.password_manager 56 | 57 | return self.session 58 | 59 | def send(self, body, headers=None, retries=2, timeout=30, encoding=u"utf-8"): 60 | if not self.session: 61 | self.session = self.build_session() 62 | 63 | try: 64 | response = self.session.post(self.url, data=body, headers=headers, verify = self.verify_certificate) 65 | response.raise_for_status() 66 | except requests.exceptions.RequestException as err: 67 | log.debug(err.response.content) 68 | raise FailedExchangeException(u'Unable to connect to Exchange: %s' % err) 69 | 70 | log.info(u'Got response: {code}'.format(code=response.status_code)) 71 | log.debug(u'Got response headers: {headers}'.format(headers=response.headers)) 72 | log.debug(u'Got body: {body}'.format(body=response.text)) 73 | 74 | return response.text 75 | -------------------------------------------------------------------------------- /pyexchange/exceptions.py: -------------------------------------------------------------------------------- 1 | """ 2 | (c) 2013 LinkedIn Corp. All rights reserved. 3 | Licensed under the Apache License, Version 2.0 (the "License");?you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 4 | 5 | Unless required by applicable law or agreed to in writing, software?distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 6 | """ 7 | 8 | 9 | class FailedExchangeException(Exception): 10 | """Raised when the Microsoft Exchange Server returns an error via SOAP for a request.""" 11 | pass 12 | 13 | 14 | class ExchangeInvalidIdMalformedException(FailedExchangeException): 15 | """Raised when we ask for an event key that doesn't exist.""" 16 | pass 17 | 18 | 19 | class ExchangeStaleChangeKeyException(FailedExchangeException): 20 | """Raised when a edit event fails due to a stale change key. Exchange requires a change token for all edit events, 21 | and they change every time an object is updated""" 22 | pass 23 | 24 | 25 | class ExchangeItemNotFoundException(FailedExchangeException): 26 | """ 27 | Raised when an item is not found on the Exchange server 28 | """ 29 | pass 30 | 31 | 32 | class ExchangeIrresolvableConflictException(FailedExchangeException): 33 | """Raised when attempting to update an item that has changed since the the current change key was obtained.""" 34 | pass 35 | 36 | 37 | class ExchangeInternalServerTransientErrorException(FailedExchangeException): 38 | """Raised when an internal server error occurs in Exchange and the request can actually be retried.""" 39 | pass 40 | 41 | 42 | class InvalidEventType(Exception): 43 | """Raised when a method for an event gets called on the wrong type of event.""" 44 | pass 45 | -------------------------------------------------------------------------------- /pyexchange/exchange2010/soap_request.py: -------------------------------------------------------------------------------- 1 | """ 2 | (c) 2013 LinkedIn Corp. All rights reserved. 3 | Licensed under the Apache License, Version 2.0 (the "License");?you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 4 | 5 | Unless required by applicable law or agreed to in writing, software?distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 6 | """ 7 | from lxml.builder import ElementMaker 8 | from ..utils import convert_datetime_to_utc 9 | from ..compat import _unicode 10 | 11 | MSG_NS = u'http://schemas.microsoft.com/exchange/services/2006/messages' 12 | TYPE_NS = u'http://schemas.microsoft.com/exchange/services/2006/types' 13 | SOAP_NS = u'http://schemas.xmlsoap.org/soap/envelope/' 14 | 15 | NAMESPACES = {u'm': MSG_NS, u't': TYPE_NS, u's': SOAP_NS} 16 | 17 | M = ElementMaker(namespace=MSG_NS, nsmap=NAMESPACES) 18 | T = ElementMaker(namespace=TYPE_NS, nsmap=NAMESPACES) 19 | 20 | EXCHANGE_DATETIME_FORMAT = u"%Y-%m-%dT%H:%M:%SZ" 21 | EXCHANGE_DATE_FORMAT = u"%Y-%m-%d" 22 | 23 | DISTINGUISHED_IDS = ( 24 | 'calendar', 'contacts', 'deleteditems', 'drafts', 'inbox', 'journal', 'notes', 'outbox', 'sentitems', 25 | 'tasks', 'msgfolderroot', 'root', 'junkemail', 'searchfolders', 'voicemail', 'recoverableitemsroot', 26 | 'recoverableitemsdeletions', 'recoverableitemsversions', 'recoverableitemspurges', 'archiveroot', 27 | 'archivemsgfolderroot', 'archivedeleteditems', 'archiverecoverableitemsroot', 28 | 'Archiverecoverableitemsdeletions', 'Archiverecoverableitemsversions', 'Archiverecoverableitemspurges', 29 | ) 30 | 31 | 32 | def exchange_header(): 33 | 34 | return T.RequestServerVersion({u'Version': u'Exchange2010'}) 35 | 36 | 37 | def resource_node(element, resources): 38 | """ 39 | Helper function to generate a person/conference room node from an email address 40 | 41 | 42 | 43 | 44 | {{ attendee_email }} 45 | 46 | 47 | 48 | """ 49 | 50 | for attendee in resources: 51 | element.append( 52 | T.Attendee( 53 | T.Mailbox( 54 | T.EmailAddress(attendee.email) 55 | ) 56 | ) 57 | ) 58 | 59 | return element 60 | 61 | 62 | def delete_field(field_uri): 63 | """ 64 | Helper function to request deletion of a field. This is necessary when you want to overwrite values instead of 65 | appending. 66 | 67 | 68 | 69 | 70 | """ 71 | 72 | root = T.DeleteItemField( 73 | T.FieldURI(FieldURI=field_uri) 74 | ) 75 | 76 | return root 77 | 78 | 79 | def get_item(exchange_id, format=u"Default"): 80 | """ 81 | Requests a calendar item from the store. 82 | 83 | exchange_id is the id for this event in the Exchange store. 84 | 85 | format controls how much data you get back from Exchange. Full docs are here, but acceptible values 86 | are IdOnly, Default, and AllProperties. 87 | 88 | http://msdn.microsoft.com/en-us/library/aa564509(v=exchg.140).aspx 89 | 90 | 92 | 93 | {format} 94 | 95 | 96 | 97 | 98 | 99 | 100 | """ 101 | 102 | elements = list() 103 | if type(exchange_id) == list: 104 | for item in exchange_id: 105 | elements.append(T.ItemId(Id=item)) 106 | else: 107 | elements = [T.ItemId(Id=exchange_id)] 108 | 109 | root = M.GetItem( 110 | M.ItemShape( 111 | T.BaseShape(format) 112 | ), 113 | M.ItemIds( 114 | *elements 115 | ) 116 | ) 117 | return root 118 | 119 | def get_calendar_items(format=u"Default", calendar_id=u'calendar', start=None, end=None, max_entries=999999, delegate_for=None): 120 | start = start.strftime(EXCHANGE_DATETIME_FORMAT) 121 | end = end.strftime(EXCHANGE_DATETIME_FORMAT) 122 | 123 | if calendar_id == u'calendar': 124 | if delegate_for is None: 125 | target = M.ParentFolderIds(T.DistinguishedFolderId(Id=calendar_id)) 126 | else: 127 | target = M.ParentFolderIds( 128 | T.DistinguishedFolderId( 129 | {'Id': 'calendar'}, 130 | T.Mailbox(T.EmailAddress(delegate_for)) 131 | ) 132 | ) 133 | else: 134 | target = M.ParentFolderIds(T.FolderId(Id=calendar_id)) 135 | 136 | root = M.FindItem( 137 | {u'Traversal': u'Shallow'}, 138 | M.ItemShape( 139 | T.BaseShape(format) 140 | ), 141 | M.CalendarView({ 142 | u'MaxEntriesReturned': _unicode(max_entries), 143 | u'StartDate': start, 144 | u'EndDate': end, 145 | }), 146 | target, 147 | ) 148 | 149 | return root 150 | 151 | 152 | def get_master(exchange_id, format=u"Default"): 153 | """ 154 | Requests a calendar item from the store. 155 | 156 | exchange_id is the id for this event in the Exchange store. 157 | 158 | format controls how much data you get back from Exchange. Full docs are here, but acceptible values 159 | are IdOnly, Default, and AllProperties. 160 | 161 | http://msdn.microsoft.com/en-us/library/aa564509(v=exchg.140).aspx 162 | 163 | 165 | 166 | {format} 167 | 168 | 169 | 170 | 171 | 172 | 173 | """ 174 | 175 | root = M.GetItem( 176 | M.ItemShape( 177 | T.BaseShape(format) 178 | ), 179 | M.ItemIds( 180 | T.RecurringMasterItemId(OccurrenceId=exchange_id) 181 | ) 182 | ) 183 | return root 184 | 185 | 186 | def get_occurrence(exchange_id, instance_index, format=u"Default"): 187 | """ 188 | Requests one or more calendar items from the store matching the master & index. 189 | 190 | exchange_id is the id for the master event in the Exchange store. 191 | 192 | format controls how much data you get back from Exchange. Full docs are here, but acceptible values 193 | are IdOnly, Default, and AllProperties. 194 | 195 | GetItem Doc: 196 | http://msdn.microsoft.com/en-us/library/aa564509(v=exchg.140).aspx 197 | OccurrenceItemId Doc: 198 | http://msdn.microsoft.com/en-us/library/office/aa580744(v=exchg.150).aspx 199 | 200 | 202 | 203 | {format} 204 | 205 | 206 | {% for index in instance_index %} 207 | 208 | {% endfor %} 209 | 210 | 211 | """ 212 | 213 | root = M.GetItem( 214 | M.ItemShape( 215 | T.BaseShape(format) 216 | ), 217 | M.ItemIds() 218 | ) 219 | 220 | items_node = root.xpath("//m:ItemIds", namespaces=NAMESPACES)[0] 221 | for index in instance_index: 222 | items_node.append(T.OccurrenceItemId(RecurringMasterId=exchange_id, InstanceIndex=str(index))) 223 | return root 224 | 225 | 226 | def get_folder(folder_id, format=u"Default"): 227 | 228 | id = T.DistinguishedFolderId(Id=folder_id) if folder_id in DISTINGUISHED_IDS else T.FolderId(Id=folder_id) 229 | 230 | root = M.GetFolder( 231 | M.FolderShape( 232 | T.BaseShape(format) 233 | ), 234 | M.FolderIds(id) 235 | ) 236 | return root 237 | 238 | 239 | def new_folder(folder): 240 | 241 | id = T.DistinguishedFolderId(Id=folder.parent_id) if folder.parent_id in DISTINGUISHED_IDS else T.FolderId(Id=folder.parent_id) 242 | 243 | if folder.folder_type == u'Folder': 244 | folder_node = T.Folder(T.DisplayName(folder.display_name)) 245 | elif folder.folder_type == u'CalendarFolder': 246 | folder_node = T.CalendarFolder(T.DisplayName(folder.display_name)) 247 | 248 | root = M.CreateFolder( 249 | M.ParentFolderId(id), 250 | M.Folders(folder_node) 251 | ) 252 | return root 253 | 254 | 255 | def find_folder(parent_id, format=u"Default"): 256 | 257 | id = T.DistinguishedFolderId(Id=parent_id) if parent_id in DISTINGUISHED_IDS else T.FolderId(Id=parent_id) 258 | 259 | root = M.FindFolder( 260 | {u'Traversal': u'Shallow'}, 261 | M.FolderShape( 262 | T.BaseShape(format) 263 | ), 264 | M.ParentFolderIds(id) 265 | ) 266 | return root 267 | 268 | 269 | def delete_folder(folder): 270 | 271 | root = M.DeleteFolder( 272 | {u'DeleteType': 'HardDelete'}, 273 | M.FolderIds( 274 | T.FolderId(Id=folder.id) 275 | ) 276 | ) 277 | return root 278 | 279 | 280 | def new_event(event): 281 | """ 282 | Requests a new event be created in the store. 283 | 284 | http://msdn.microsoft.com/en-us/library/aa564690(v=exchg.140).aspx 285 | 286 | 289 | 290 | 291 | 292 | 293 | 294 | {event.subject} 295 | {event.subject} 296 | 297 | 298 | 299 | 300 | {% for attendee_email in meeting.required_attendees %} 301 | 302 | 303 | {{ attendee_email }} 304 | 305 | 306 | HTTPretty {% endfor %} 307 | 308 | {% if meeting.optional_attendees %} 309 | 310 | {% for attendee_email in meeting.optional_attendees %} 311 | 312 | 313 | {{ attendee_email }} 314 | 315 | 316 | {% endfor %} 317 | 318 | {% endif %} 319 | {% if meeting.conference_room %} 320 | 321 | 322 | 323 | {{ meeting.conference_room.email }} 324 | 325 | 326 | 327 | {% endif %} 328 | 329 | 330 | 331 | """ 332 | 333 | id = T.DistinguishedFolderId(Id=event.calendar_id) if event.calendar_id in DISTINGUISHED_IDS else T.FolderId(Id=event.calendar_id) 334 | 335 | start = convert_datetime_to_utc(event.start) 336 | end = convert_datetime_to_utc(event.end) 337 | 338 | root = M.CreateItem( 339 | M.SavedItemFolderId(id), 340 | M.Items( 341 | T.CalendarItem( 342 | T.Subject(event.subject), 343 | T.Body(event.body or u'', BodyType="HTML"), 344 | ) 345 | ), 346 | SendMeetingInvitations="SendToAllAndSaveCopy" 347 | ) 348 | 349 | calendar_node = root.xpath(u'/m:CreateItem/m:Items/t:CalendarItem', namespaces=NAMESPACES)[0] 350 | 351 | if event.reminder_minutes_before_start: 352 | calendar_node.append(T.ReminderIsSet('true')) 353 | calendar_node.append(T.ReminderMinutesBeforeStart(str(event.reminder_minutes_before_start))) 354 | else: 355 | calendar_node.append(T.ReminderIsSet('false')) 356 | 357 | calendar_node.append(T.Start(start.strftime(EXCHANGE_DATETIME_FORMAT))) 358 | calendar_node.append(T.End(end.strftime(EXCHANGE_DATETIME_FORMAT))) 359 | 360 | if event.is_all_day: 361 | calendar_node.append(T.IsAllDayEvent('true')) 362 | 363 | calendar_node.append(T.Location(event.location or u'')) 364 | 365 | if event.required_attendees: 366 | calendar_node.append(resource_node(element=T.RequiredAttendees(), resources=event.required_attendees)) 367 | 368 | if event.optional_attendees: 369 | calendar_node.append(resource_node(element=T.OptionalAttendees(), resources=event.optional_attendees)) 370 | 371 | if event.resources: 372 | calendar_node.append(resource_node(element=T.Resources(), resources=event.resources)) 373 | 374 | if event.recurrence: 375 | 376 | if event.recurrence == u'daily': 377 | recurrence = T.DailyRecurrence( 378 | T.Interval(str(event.recurrence_interval)), 379 | ) 380 | elif event.recurrence == u'weekly': 381 | recurrence = T.WeeklyRecurrence( 382 | T.Interval(str(event.recurrence_interval)), 383 | T.DaysOfWeek(event.recurrence_days), 384 | ) 385 | elif event.recurrence == u'monthly': 386 | recurrence = T.AbsoluteMonthlyRecurrence( 387 | T.Interval(str(event.recurrence_interval)), 388 | T.DayOfMonth(str(event.start.day)), 389 | ) 390 | elif event.recurrence == u'yearly': 391 | recurrence = T.AbsoluteYearlyRecurrence( 392 | T.DayOfMonth(str(event.start.day)), 393 | T.Month(event.start.strftime("%B")), 394 | ) 395 | 396 | calendar_node.append( 397 | T.Recurrence( 398 | recurrence, 399 | T.EndDateRecurrence( 400 | T.StartDate(event.start.strftime(EXCHANGE_DATE_FORMAT)), 401 | T.EndDate(event.recurrence_end_date.strftime(EXCHANGE_DATE_FORMAT)), 402 | ) 403 | ) 404 | ) 405 | 406 | return root 407 | 408 | 409 | def delete_event(event): 410 | """ 411 | 412 | Requests an item be deleted from the store. 413 | 414 | 415 | 421 | 422 | 423 | 424 | 425 | 426 | """ 427 | root = M.DeleteItem( 428 | M.ItemIds( 429 | T.ItemId(Id=event.id, ChangeKey=event.change_key) 430 | ), 431 | DeleteType="HardDelete", 432 | SendMeetingCancellations="SendToAllAndSaveCopy", 433 | AffectedTaskOccurrences="AllOccurrences" 434 | ) 435 | 436 | return root 437 | 438 | 439 | def move_event(event, folder_id): 440 | 441 | id = T.DistinguishedFolderId(Id=folder_id) if folder_id in DISTINGUISHED_IDS else T.FolderId(Id=folder_id) 442 | 443 | root = M.MoveItem( 444 | M.ToFolderId(id), 445 | M.ItemIds( 446 | T.ItemId(Id=event.id, ChangeKey=event.change_key) 447 | ) 448 | ) 449 | return root 450 | 451 | 452 | def move_folder(folder, folder_id): 453 | 454 | id = T.DistinguishedFolderId(Id=folder_id) if folder_id in DISTINGUISHED_IDS else T.FolderId(Id=folder_id) 455 | 456 | root = M.MoveFolder( 457 | M.ToFolderId(id), 458 | M.FolderIds( 459 | T.FolderId(Id=folder.id) 460 | ) 461 | ) 462 | return root 463 | 464 | 465 | def update_property_node(node_to_insert, field_uri): 466 | """ Helper function - generates a SetItemField which tells Exchange you want to overwrite the contents of a field.""" 467 | root = T.SetItemField( 468 | T.FieldURI(FieldURI=field_uri), 469 | T.CalendarItem(node_to_insert) 470 | ) 471 | return root 472 | 473 | 474 | def update_item(event, updated_attributes, calendar_item_update_operation_type): 475 | """ Saves updates to an event in the store. Only request changes for attributes that have actually changed.""" 476 | 477 | root = M.UpdateItem( 478 | M.ItemChanges( 479 | T.ItemChange( 480 | T.ItemId(Id=event.id, ChangeKey=event.change_key), 481 | T.Updates() 482 | ) 483 | ), 484 | ConflictResolution=u"AlwaysOverwrite", 485 | MessageDisposition=u"SendAndSaveCopy", 486 | SendMeetingInvitationsOrCancellations=calendar_item_update_operation_type 487 | ) 488 | 489 | update_node = root.xpath(u'/m:UpdateItem/m:ItemChanges/t:ItemChange/t:Updates', namespaces=NAMESPACES)[0] 490 | 491 | # if not send_only_to_changed_attendees: 492 | # # We want to resend invites, which you do by setting an attribute to the same value it has. Right now, events 493 | # # are always scheduled as Busy time, so we just set that again. 494 | # update_node.append( 495 | # update_property_node(field_uri="calendar:LegacyFreeBusyStatus", node_to_insert=T.LegacyFreeBusyStatus("Busy")) 496 | # ) 497 | 498 | if u'html_body' in updated_attributes: 499 | update_node.append( 500 | update_property_node(field_uri="item:Body", node_to_insert=T.Body(event.html_body, BodyType="HTML")) 501 | ) 502 | 503 | if u'text_body' in updated_attributes: 504 | update_node.append( 505 | update_property_node(field_uri="item:Body", node_to_insert=T.Body(event.text_body, BodyType="Text")) 506 | ) 507 | 508 | if u'subject' in updated_attributes: 509 | update_node.append( 510 | update_property_node(field_uri="item:Subject", node_to_insert=T.Subject(event.subject)) 511 | ) 512 | 513 | if u'start' in updated_attributes: 514 | start = convert_datetime_to_utc(event.start) 515 | 516 | update_node.append( 517 | update_property_node(field_uri="calendar:Start", node_to_insert=T.Start(start.strftime(EXCHANGE_DATETIME_FORMAT))) 518 | ) 519 | 520 | if u'end' in updated_attributes: 521 | end = convert_datetime_to_utc(event.end) 522 | 523 | update_node.append( 524 | update_property_node(field_uri="calendar:End", node_to_insert=T.End(end.strftime(EXCHANGE_DATETIME_FORMAT))) 525 | ) 526 | 527 | if u'location' in updated_attributes: 528 | update_node.append( 529 | update_property_node(field_uri="calendar:Location", node_to_insert=T.Location(event.location)) 530 | ) 531 | 532 | if u'attendees' in updated_attributes: 533 | 534 | if event.required_attendees: 535 | required = resource_node(element=T.RequiredAttendees(), resources=event.required_attendees) 536 | 537 | update_node.append( 538 | update_property_node(field_uri="calendar:RequiredAttendees", node_to_insert=required) 539 | ) 540 | else: 541 | update_node.append(delete_field(field_uri="calendar:RequiredAttendees")) 542 | 543 | if event.optional_attendees: 544 | optional = resource_node(element=T.OptionalAttendees(), resources=event.optional_attendees) 545 | 546 | update_node.append( 547 | update_property_node(field_uri="calendar:OptionalAttendees", node_to_insert=optional) 548 | ) 549 | else: 550 | update_node.append(delete_field(field_uri="calendar:OptionalAttendees")) 551 | 552 | if u'resources' in updated_attributes: 553 | if event.resources: 554 | resources = resource_node(element=T.Resources(), resources=event.resources) 555 | 556 | update_node.append( 557 | update_property_node(field_uri="calendar:Resources", node_to_insert=resources) 558 | ) 559 | else: 560 | update_node.append(delete_field(field_uri="calendar:Resources")) 561 | 562 | if u'reminder_minutes_before_start' in updated_attributes: 563 | if event.reminder_minutes_before_start: 564 | update_node.append( 565 | update_property_node(field_uri="item:ReminderIsSet", node_to_insert=T.ReminderIsSet('true')) 566 | ) 567 | update_node.append( 568 | update_property_node( 569 | field_uri="item:ReminderMinutesBeforeStart", 570 | node_to_insert=T.ReminderMinutesBeforeStart(str(event.reminder_minutes_before_start)) 571 | ) 572 | ) 573 | else: 574 | update_node.append( 575 | update_property_node(field_uri="item:ReminderIsSet", node_to_insert=T.ReminderIsSet('false')) 576 | ) 577 | 578 | if u'is_all_day' in updated_attributes: 579 | update_node.append( 580 | update_property_node(field_uri="calendar:IsAllDayEvent", node_to_insert=T.IsAllDayEvent(str(event.is_all_day).lower())) 581 | ) 582 | 583 | for attr in event.RECURRENCE_ATTRIBUTES: 584 | if attr in updated_attributes: 585 | 586 | recurrence_node = T.Recurrence() 587 | 588 | if event.recurrence == 'daily': 589 | recurrence_node.append( 590 | T.DailyRecurrence( 591 | T.Interval(str(event.recurrence_interval)), 592 | ) 593 | ) 594 | elif event.recurrence == 'weekly': 595 | recurrence_node.append( 596 | T.WeeklyRecurrence( 597 | T.Interval(str(event.recurrence_interval)), 598 | T.DaysOfWeek(event.recurrence_days), 599 | ) 600 | ) 601 | elif event.recurrence == 'monthly': 602 | recurrence_node.append( 603 | T.AbsoluteMonthlyRecurrence( 604 | T.Interval(str(event.recurrence_interval)), 605 | T.DayOfMonth(str(event.start.day)), 606 | ) 607 | ) 608 | elif event.recurrence == 'yearly': 609 | recurrence_node.append( 610 | T.AbsoluteYearlyRecurrence( 611 | T.DayOfMonth(str(event.start.day)), 612 | T.Month(event.start.strftime("%B")), 613 | ) 614 | ) 615 | 616 | recurrence_node.append( 617 | T.EndDateRecurrence( 618 | T.StartDate(event.start.strftime(EXCHANGE_DATE_FORMAT)), 619 | T.EndDate(event.recurrence_end_date.strftime(EXCHANGE_DATE_FORMAT)), 620 | ) 621 | ) 622 | 623 | update_node.append( 624 | update_property_node(field_uri="calendar:Recurrence", node_to_insert=recurrence_node) 625 | ) 626 | 627 | return root 628 | -------------------------------------------------------------------------------- /pyexchange/utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | (c) 2013 LinkedIn Corp. All rights reserved. 3 | Licensed under the Apache License, Version 2.0 (the "License");?you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 4 | 5 | Unless required by applicable law or agreed to in writing, software?distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 6 | """ 7 | from pytz import utc 8 | 9 | 10 | def convert_datetime_to_utc(datetime_to_convert): 11 | if datetime_to_convert is None: 12 | return None 13 | 14 | if datetime_to_convert.tzinfo: 15 | return datetime_to_convert.astimezone(utc) 16 | else: 17 | return utc.localize(datetime_to_convert) 18 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | lxml 2 | pytz 3 | requests 4 | requests-ntlm -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [nosetests] 2 | cover-package=pyexchange 3 | where=tests/ 4 | nologcapture=True 5 | 6 | [flake8] 7 | ignore = E111,E121,E126,E501 8 | 9 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | from setuptools import setup, find_packages 3 | 4 | here = os.path.abspath(os.path.dirname(__file__)) 5 | 6 | try: 7 | README = open(os.path.join(here, 'README.md')).read() 8 | CHANGES = open(os.path.join(here, 'CHANGES.rst')).read() 9 | except: 10 | README = '' 11 | CHANGES = '' 12 | 13 | setup( 14 | name='pyexchange', 15 | version='0.7-dev', 16 | url='https://github.com/linkedin/pyexchange', 17 | license='Apache', 18 | author='Rachel Sanders', 19 | author_email='rsanders@linkedin.com', 20 | maintainer='Rachel Sanders', 21 | maintainer_email='rsanders@linkedin.com', 22 | description='A simple library to talk to Microsoft Exchange', 23 | long_description=README + '\n\n' + CHANGES, 24 | zip_safe=False, 25 | test_suite="tests", 26 | platforms='any', 27 | include_package_data=True, 28 | packages=find_packages('.', exclude=['test*']), 29 | install_requires=['lxml', 'pytz', 'requests', 'requests-ntlm'], 30 | classifiers=[ 31 | 'Development Status :: 4 - Beta', 32 | 'Intended Audience :: Developers', 33 | 'License :: OSI Approved :: Apache Software License', 34 | 'Operating System :: OS Independent', 35 | 'Programming Language :: Python', 36 | 'Programming Language :: Python', 37 | 'Programming Language :: Python :: 2', 38 | 'Programming Language :: Python :: 2.6', 39 | 'Programming Language :: Python :: 2.7', 40 | 'Programming Language :: Python :: 3', 41 | 'Programming Language :: Python :: 3.3', 42 | 'Programming Language :: Python :: 3.4', 43 | 'Topic :: Software Development :: Libraries :: Python Modules' 44 | ] 45 | ) 46 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | (c) 2013 LinkedIn Corp. All rights reserved. 3 | Licensed under the Apache License, Version 2.0 (the "License");?you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 4 | 5 | Unless required by applicable law or agreed to in writing, software?distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 6 | """ 7 | 8 | 9 | -------------------------------------------------------------------------------- /tests/exchange2010/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | (c) 2013 LinkedIn Corp. All rights reserved. 3 | Licensed under the Apache License, Version 2.0 (the "License");?you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 4 | 5 | Unless required by applicable law or agreed to in writing, software?distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 6 | """ 7 | -------------------------------------------------------------------------------- /tests/exchange2010/test_create_event.py: -------------------------------------------------------------------------------- 1 | """ 2 | (c) 2013 LinkedIn Corp. All rights reserved. 3 | Licensed under the Apache License, Version 2.0 (the "License");?you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 4 | 5 | Unless required by applicable law or agreed to in writing, software?distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 6 | """ 7 | import pickle 8 | import unittest 9 | from httpretty import HTTPretty, httprettified 10 | from pytest import raises 11 | from pyexchange import Exchange2010Service 12 | 13 | from pyexchange.connection import ExchangeNTLMAuthConnection 14 | from pyexchange.base.calendar import ExchangeEventAttendee 15 | from pyexchange.exceptions import * # noqa 16 | 17 | from .fixtures import * # noqa 18 | 19 | 20 | class Test_PopulatingANewEvent(unittest.TestCase): 21 | """ Tests all the attribute setting works when creating a new event """ 22 | calendar = None 23 | 24 | @classmethod 25 | def setUpClass(cls): 26 | 27 | cls.calendar = Exchange2010Service( 28 | connection=ExchangeNTLMAuthConnection( 29 | url=FAKE_EXCHANGE_URL, 30 | username=FAKE_EXCHANGE_USERNAME, 31 | password=FAKE_EXCHANGE_PASSWORD, 32 | ) 33 | ).calendar() 34 | 35 | def test_canary(self): 36 | event = self.calendar.event() 37 | assert event is not None 38 | 39 | def test_events_created_dont_have_an_id(self): 40 | event = self.calendar.event() 41 | assert event.id is None 42 | 43 | def test_can_add_a_subject(self): 44 | event = self.calendar.event(subject=TEST_EVENT.subject) 45 | assert event.subject == TEST_EVENT.subject 46 | 47 | def test_can_add_a_location(self): 48 | event = self.calendar.event(location=TEST_EVENT.location) 49 | assert event.location == TEST_EVENT.location 50 | 51 | def test_can_add_an_html_body(self): 52 | event = self.calendar.event(html_body=TEST_EVENT.body) 53 | assert event.html_body == TEST_EVENT.body 54 | assert event.text_body is None 55 | assert event.body == TEST_EVENT.body 56 | 57 | def test_can_add_a_text_body(self): 58 | event = self.calendar.event(text_body=TEST_EVENT.body) 59 | assert event.text_body == TEST_EVENT.body 60 | assert event.html_body is None 61 | assert event.body == TEST_EVENT.body 62 | 63 | def test_can_add_a_start_time(self): 64 | event = self.calendar.event(start=TEST_EVENT.start) 65 | assert event.start == TEST_EVENT.start 66 | 67 | def test_can_add_an_end_time(self): 68 | event = self.calendar.event(end=TEST_EVENT.end) 69 | assert event.end == TEST_EVENT.end 70 | 71 | def test_can_add_attendees_via_email(self): 72 | event = self.calendar.event(attendees=PERSON_REQUIRED_ACCEPTED.email) 73 | assert len(event.attendees) == 1 74 | assert len(event.required_attendees) == 1 75 | assert len(event.optional_attendees) == 0 76 | assert event.attendees[0].email == PERSON_REQUIRED_ACCEPTED.email 77 | 78 | def test_can_add_multiple_attendees_via_email(self): 79 | event = self.calendar.event(attendees=[PERSON_REQUIRED_ACCEPTED.email, PERSON_REQUIRED_TENTATIVE.email]) 80 | assert len(event.attendees) == 2 81 | assert len(event.required_attendees) == 2 82 | assert len(event.optional_attendees) == 0 83 | 84 | def test_can_add_attendees_via_named_tuple(self): 85 | 86 | person = ExchangeEventAttendee(name=PERSON_OPTIONAL_ACCEPTED.name, email=PERSON_OPTIONAL_ACCEPTED.email, required=PERSON_OPTIONAL_ACCEPTED.required) 87 | 88 | event = self.calendar.event(attendees=person) 89 | assert len(event.attendees) == 1 90 | assert len(event.required_attendees) == 0 91 | assert len(event.optional_attendees) == 1 92 | assert event.attendees[0].email == PERSON_OPTIONAL_ACCEPTED.email 93 | 94 | def test_can_assign_to_required_attendees(self): 95 | 96 | event = self.calendar.event(attendees=PERSON_REQUIRED_ACCEPTED.email) 97 | event.required_attendees = [PERSON_REQUIRED_ACCEPTED.email, PERSON_OPTIONAL_ACCEPTED.email] 98 | 99 | assert len(event.attendees) == 2 100 | assert len(event.required_attendees) == 2 101 | assert len(event.optional_attendees) == 0 102 | 103 | def test_can_assign_to_optional_attendees(self): 104 | 105 | event = self.calendar.event(attendees=PERSON_REQUIRED_ACCEPTED.email) 106 | event.optional_attendees = PERSON_OPTIONAL_ACCEPTED.email 107 | 108 | assert len(event.attendees) == 2 109 | assert len(event.required_attendees) == 1 110 | assert len(event.optional_attendees) == 1 111 | assert event.required_attendees[0].email == PERSON_REQUIRED_ACCEPTED.email 112 | assert event.optional_attendees[0].email == PERSON_OPTIONAL_ACCEPTED.email 113 | 114 | def test_can_add_resources(self): 115 | event = self.calendar.event(resources=[RESOURCE.email]) 116 | assert len(event.resources) == 1 117 | assert event.resources[0].email == RESOURCE.email 118 | assert event.conference_room.email == RESOURCE.email 119 | 120 | 121 | class Test_CreatingANewEvent(unittest.TestCase): 122 | service = None 123 | event = None 124 | 125 | @classmethod 126 | def setUpClass(cls): 127 | cls.service = Exchange2010Service(connection=ExchangeNTLMAuthConnection(url=FAKE_EXCHANGE_URL, username=FAKE_EXCHANGE_USERNAME, password=FAKE_EXCHANGE_PASSWORD)) 128 | 129 | def setUp(self): 130 | self.event = self.service.calendar().event(start=TEST_EVENT.start, end=TEST_EVENT.end) 131 | 132 | def test_events_must_have_a_start_date(self): 133 | self.event.start = None 134 | 135 | with raises(ValueError): 136 | self.event.create() 137 | 138 | def test_events_must_have_an_end_date(self): 139 | self.event.end = None 140 | 141 | with raises(ValueError): 142 | self.event.create() 143 | 144 | def test_event_end_date_must_come_after_start_date(self): 145 | self.event.start, self.event.end = self.event.end, self.event.start 146 | 147 | with raises(ValueError): 148 | self.event.create() 149 | 150 | def test_attendees_must_have_an_email_address_take1(self): 151 | 152 | with raises(ValueError): 153 | self.event.add_attendees(ExchangeEventAttendee(name="Bomb", email=None, required=True)) 154 | self.event.create() 155 | 156 | def test_attendees_must_have_an_email_address_take2(self): 157 | 158 | with raises(ValueError): 159 | self.event.add_attendees([None]) 160 | self.event.create() 161 | 162 | def test_event_reminder_must_be_int(self): 163 | self.event.reminder_minutes_before_start = "not an integer" 164 | 165 | with raises(TypeError): 166 | self.event.create() 167 | 168 | def test_event_all_day_must_be_bool(self): 169 | self.event.is_all_day = "not a bool" 170 | 171 | with raises(TypeError): 172 | self.event.create() 173 | 174 | def cant_delete_a_newly_created_event(self): 175 | 176 | with raises(ValueError): 177 | self.event.delete() 178 | 179 | def cant_update_a_newly_created_event(self): 180 | 181 | with raises(ValueError): 182 | self.event.update() 183 | 184 | def cant_resend_invites_for_a_newly_created_event(self): 185 | 186 | with raises(ValueError): 187 | self.event.resend_invitations() 188 | 189 | @httprettified 190 | def test_can_set_subject(self): 191 | 192 | HTTPretty.register_uri( 193 | HTTPretty.POST, FAKE_EXCHANGE_URL, 194 | body=CREATE_ITEM_RESPONSE.encode('utf-8'), 195 | content_type='text/xml; charset=utf-8', 196 | ) 197 | 198 | self.event.subject = TEST_EVENT.subject 199 | self.event.create() 200 | 201 | assert TEST_EVENT.subject in HTTPretty.last_request.body.decode('utf-8') 202 | 203 | @httprettified 204 | def test_can_set_location(self): 205 | 206 | HTTPretty.register_uri( 207 | HTTPretty.POST, FAKE_EXCHANGE_URL, 208 | body=CREATE_ITEM_RESPONSE.encode('utf-8'), 209 | content_type='text/xml; charset=utf-8', 210 | ) 211 | 212 | self.event.location = TEST_EVENT.location 213 | self.event.create() 214 | 215 | assert TEST_EVENT.location in HTTPretty.last_request.body.decode('utf-8') 216 | 217 | @httprettified 218 | def test_can_set_html_body(self): 219 | 220 | HTTPretty.register_uri( 221 | HTTPretty.POST, FAKE_EXCHANGE_URL, 222 | body=CREATE_ITEM_RESPONSE.encode('utf-8'), 223 | content_type='text/xml; charset=utf-8' 224 | ) 225 | 226 | self.event.html_body = TEST_EVENT.body 227 | self.event.create() 228 | 229 | assert TEST_EVENT.body in HTTPretty.last_request.body.decode('utf-8') 230 | 231 | @httprettified 232 | def test_can_set_text_body(self): 233 | 234 | HTTPretty.register_uri( 235 | HTTPretty.POST, FAKE_EXCHANGE_URL, 236 | body=CREATE_ITEM_RESPONSE.encode('utf-8'), 237 | content_type='text/xml; charset=utf-8', 238 | ) 239 | 240 | self.event.text_body = TEST_EVENT.body 241 | self.event.create() 242 | 243 | assert TEST_EVENT.body in HTTPretty.last_request.body.decode('utf-8') 244 | 245 | @httprettified 246 | def test_start_time(self): 247 | 248 | HTTPretty.register_uri( 249 | HTTPretty.POST, FAKE_EXCHANGE_URL, 250 | body=CREATE_ITEM_RESPONSE.encode('utf-8'), 251 | content_type='text/xml; charset=utf-8', 252 | ) 253 | 254 | self.event.create() 255 | 256 | assert TEST_EVENT.start.strftime(EXCHANGE_DATE_FORMAT) in HTTPretty.last_request.body.decode('utf-8') 257 | 258 | @httprettified 259 | def test_end_time(self): 260 | 261 | HTTPretty.register_uri( 262 | HTTPretty.POST, FAKE_EXCHANGE_URL, 263 | body=CREATE_ITEM_RESPONSE.encode('utf-8'), 264 | content_type='text/xml; charset=utf-8', 265 | ) 266 | 267 | self.event.create() 268 | 269 | assert TEST_EVENT.end.strftime(EXCHANGE_DATE_FORMAT) in HTTPretty.last_request.body.decode('utf-8') 270 | 271 | @httprettified 272 | def test_attendees(self): 273 | 274 | HTTPretty.register_uri( 275 | HTTPretty.POST, FAKE_EXCHANGE_URL, 276 | body=CREATE_ITEM_RESPONSE.encode('utf-8'), 277 | content_type='text/xml; charset=utf-8', 278 | ) 279 | 280 | attendees = [PERSON_REQUIRED_ACCEPTED.email, PERSON_REQUIRED_TENTATIVE.email] 281 | 282 | self.event.attendees = attendees 283 | self.event.create() 284 | 285 | for email in attendees: 286 | assert email in HTTPretty.last_request.body.decode('utf-8') 287 | 288 | def test_resources_must_have_an_email_address(self): 289 | 290 | HTTPretty.register_uri( 291 | HTTPretty.POST, FAKE_EXCHANGE_URL, 292 | body=CREATE_ITEM_RESPONSE.encode('utf-8'), 293 | content_type='text/xml; charset=utf-8', 294 | ) 295 | 296 | attendees = [PERSON_WITH_NO_EMAIL_ADDRESS] 297 | 298 | with raises(ValueError): 299 | self.event.attendees = attendees 300 | self.event.create() 301 | 302 | @httprettified 303 | def test_resources(self): 304 | 305 | HTTPretty.register_uri( 306 | HTTPretty.POST, FAKE_EXCHANGE_URL, 307 | body=CREATE_ITEM_RESPONSE.encode('utf-8'), 308 | content_type='text/xml; charset=utf-8', 309 | ) 310 | 311 | self.event.resources = [RESOURCE.email] 312 | self.event.create() 313 | 314 | assert RESOURCE.email in HTTPretty.last_request.body.decode('utf-8') 315 | 316 | 317 | def test_events_can_be_pickled(self): 318 | 319 | self.event.subject = "events can be pickled" 320 | 321 | pickled_event = pickle.dumps(self.event) 322 | new_event = pickle.loads(pickled_event) 323 | 324 | assert new_event.subject == "events can be pickled" 325 | 326 | 327 | -------------------------------------------------------------------------------- /tests/exchange2010/test_create_folder.py: -------------------------------------------------------------------------------- 1 | """ 2 | (c) 2013 LinkedIn Corp. All rights reserved. 3 | Licensed under the Apache License, Version 2.0 (the "License");?you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 4 | 5 | Unless required by applicable law or agreed to in writing, software?distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 6 | """ 7 | import unittest 8 | from httpretty import HTTPretty, httprettified 9 | from pytest import raises 10 | from pyexchange import Exchange2010Service 11 | 12 | from pyexchange.connection import ExchangeNTLMAuthConnection 13 | from pyexchange.exceptions import * 14 | 15 | from .fixtures import * 16 | 17 | 18 | class Test_PopulatingANewFolder(unittest.TestCase): 19 | """ Tests all the attribute setting works when creating a new folder """ 20 | folder = None 21 | 22 | @classmethod 23 | def setUpClass(cls): 24 | 25 | cls.folder = Exchange2010Service( 26 | connection=ExchangeNTLMAuthConnection( 27 | url=FAKE_EXCHANGE_URL, 28 | username=FAKE_EXCHANGE_USERNAME, 29 | password=FAKE_EXCHANGE_PASSWORD 30 | ) 31 | ).folder() 32 | 33 | def test_canary(self): 34 | folder = self.folder.new_folder() 35 | assert folder is not None 36 | 37 | def test_folders_created_dont_have_an_id(self): 38 | folder = self.folder.new_folder() 39 | assert folder.id is None 40 | 41 | def test_folder_has_display_name(self): 42 | folder = self.folder.new_folder(display_name=u'Conference Room') 43 | assert folder.display_name == u'Conference Room' 44 | 45 | def test_folder_has_default_folder_type(self): 46 | folder = self.folder.new_folder() 47 | assert folder.folder_type == u'Folder' 48 | 49 | def test_folder_has_calendar_folder_type(self): 50 | folder = self.folder.new_folder(folder_type=u'CalendarFolder') 51 | assert folder.folder_type == u'CalendarFolder' 52 | 53 | 54 | class Test_CreatingANewFolder(unittest.TestCase): 55 | service = None 56 | folder = None 57 | 58 | @classmethod 59 | def setUpClass(cls): 60 | cls.service = Exchange2010Service( 61 | connection=ExchangeNTLMAuthConnection( 62 | url=FAKE_EXCHANGE_URL, 63 | username=FAKE_EXCHANGE_USERNAME, 64 | password=FAKE_EXCHANGE_PASSWORD, 65 | ) 66 | ) 67 | 68 | def setUp(self): 69 | self.folder = self.service.folder().new_folder() 70 | 71 | def test_folders_must_have_a_display_name(self): 72 | self.parent_id = u'AQASAGFyMTY2AUB0eHN0YXRlLmVkdQAuAAADXToP9jZJ50ix6mBloAoUtQEAIXy9HV1hQUKHHMQm+PlY6QINNPfbUQAAAA==' 73 | 74 | with raises(AttributeError): 75 | self.folder.create() 76 | 77 | 78 | def test_folders_must_have_a_parent_id(self): 79 | self.folder.display_name = u'Conference Room' 80 | self.parent_id = None 81 | 82 | with raises(ValueError): 83 | self.folder.create() 84 | 85 | def cant_delete_an_uncreated_folder(self): 86 | with raises(TypeError): 87 | self.folder.delete() 88 | 89 | @httprettified 90 | def test_can_set_display_name(self): 91 | 92 | HTTPretty.register_uri( 93 | HTTPretty.POST, 94 | FAKE_EXCHANGE_URL, 95 | body=CREATE_FOLDER_RESPONSE.encode('utf-8'), 96 | content_type='text/xml; charset=utf-8', 97 | ) 98 | 99 | self.folder.display_name = TEST_FOLDER.display_name 100 | self.folder.parent_id = TEST_FOLDER.parent_id 101 | self.folder.create() 102 | 103 | assert TEST_FOLDER.display_name in HTTPretty.last_request.body.decode('utf-8') 104 | 105 | @httprettified 106 | def test_can_set_parent_id(self): 107 | 108 | HTTPretty.register_uri( 109 | HTTPretty.POST, 110 | FAKE_EXCHANGE_URL, 111 | body=CREATE_FOLDER_RESPONSE.encode('utf-8'), 112 | content_type='text/xml; charset=utf-8', 113 | ) 114 | 115 | self.folder.display_name = TEST_FOLDER.display_name 116 | self.folder.parent_id = TEST_FOLDER.parent_id 117 | self.folder.create() 118 | 119 | assert TEST_FOLDER.display_name in HTTPretty.last_request.body.decode('utf-8') 120 | 121 | @httprettified 122 | def test_can_set_folder_type(self): 123 | 124 | HTTPretty.register_uri( 125 | HTTPretty.POST, 126 | FAKE_EXCHANGE_URL, 127 | body=CREATE_FOLDER_RESPONSE.encode('utf-8'), 128 | content_type='text/xml; charset=utf-8', 129 | ) 130 | 131 | self.folder.display_name = TEST_FOLDER.display_name 132 | self.folder.parent_id = TEST_FOLDER.parent_id 133 | self.folder.folder_type = TEST_FOLDER.folder_type 134 | self.folder.create() 135 | 136 | assert TEST_FOLDER.folder_type in HTTPretty.last_request.body.decode('utf-8') 137 | 138 | @httprettified 139 | def test_can_create(self): 140 | 141 | HTTPretty.register_uri( 142 | HTTPretty.POST, 143 | FAKE_EXCHANGE_URL, 144 | body=CREATE_FOLDER_RESPONSE.encode('utf-8'), 145 | content_type='text/html; charset=utf-8', 146 | ) 147 | 148 | self.folder.display_name = TEST_FOLDER.display_name 149 | self.folder.parent_id = TEST_FOLDER.parent_id 150 | self.folder.folder_type = TEST_FOLDER.folder_type 151 | self.folder.create() 152 | 153 | assert self.folder.id == TEST_FOLDER.id 154 | -------------------------------------------------------------------------------- /tests/exchange2010/test_create_recurring_event.py: -------------------------------------------------------------------------------- 1 | """ 2 | (c) 2013 LinkedIn Corp. All rights reserved. 3 | Licensed under the Apache License, Version 2.0 (the "License");?you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 4 | 5 | Unless required by applicable law or agreed to in writing, software?distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 6 | """ 7 | import unittest 8 | from pytest import raises 9 | from httpretty import HTTPretty, httprettified 10 | from pyexchange import Exchange2010Service 11 | 12 | from pyexchange.connection import ExchangeNTLMAuthConnection 13 | from pyexchange.exceptions import * # noqa 14 | 15 | from .fixtures import * # noqa 16 | 17 | 18 | class Test_PopulatingANewRecurringDailyEvent(unittest.TestCase): 19 | """ Tests all the attribute setting works when creating a new event """ 20 | calendar = None 21 | 22 | @classmethod 23 | def setUpClass(cls): 24 | 25 | cls.calendar = Exchange2010Service( 26 | connection=ExchangeNTLMAuthConnection( 27 | url=FAKE_EXCHANGE_URL, 28 | username=FAKE_EXCHANGE_USERNAME, 29 | password=FAKE_EXCHANGE_PASSWORD, 30 | ) 31 | ).calendar() 32 | 33 | def test_can_set_recurring(self): 34 | event = self.calendar.event( 35 | recurrence_interval=TEST_RECURRING_EVENT_DAILY.recurrence_interval, 36 | recurrence_end_date=TEST_RECURRING_EVENT_DAILY.recurrence_end_date, 37 | ) 38 | assert event.recurrence_interval == TEST_RECURRING_EVENT_DAILY.recurrence_interval 39 | assert event.recurrence_end_date == TEST_RECURRING_EVENT_DAILY.recurrence_end_date 40 | 41 | 42 | class Test_CreatingANewRecurringDailyEvent(unittest.TestCase): 43 | service = None 44 | event = None 45 | 46 | @classmethod 47 | def setUpClass(cls): 48 | cls.service = Exchange2010Service( 49 | connection=ExchangeNTLMAuthConnection( 50 | url=FAKE_EXCHANGE_URL, 51 | username=FAKE_EXCHANGE_USERNAME, 52 | password=FAKE_EXCHANGE_PASSWORD 53 | ) 54 | ) 55 | 56 | def setUp(self): 57 | self.event = self.service.calendar().event( 58 | subject=TEST_RECURRING_EVENT_DAILY.subject, 59 | start=TEST_RECURRING_EVENT_DAILY.start, 60 | end=TEST_RECURRING_EVENT_DAILY.end, 61 | recurrence='daily', 62 | recurrence_interval=TEST_RECURRING_EVENT_DAILY.recurrence_interval, 63 | recurrence_end_date=TEST_RECURRING_EVENT_DAILY.recurrence_end_date, 64 | ) 65 | 66 | def test_recurrence_must_have_interval(self): 67 | self.event.recurrence_interval = None 68 | with raises(ValueError): 69 | self.event.create() 70 | 71 | def test_recurrence_interval_low_value(self): 72 | self.event.recurrence_interval = 0 73 | with raises(ValueError): 74 | self.event.create() 75 | 76 | def test_recurrence_interval_high_value(self): 77 | self.event.recurrence_interval = 1000 78 | with raises(ValueError): 79 | self.event.create() 80 | 81 | @httprettified 82 | def test_recurrence_interval_min_value(self): 83 | HTTPretty.register_uri( 84 | HTTPretty.POST, FAKE_EXCHANGE_URL, 85 | body=CREATE_ITEM_RESPONSE.encode('utf-8'), 86 | content_type='text/xml; charset=utf-8', 87 | ) 88 | self.event.recurrence_interval = 1 89 | self.event.create() 90 | assert self.event.id == TEST_RECURRING_EVENT_DAILY.id 91 | 92 | @httprettified 93 | def test_recurrence_interval_max_value(self): 94 | HTTPretty.register_uri( 95 | HTTPretty.POST, FAKE_EXCHANGE_URL, 96 | body=CREATE_ITEM_RESPONSE.encode('utf-8'), 97 | content_type='text/xml; charset=utf-8', 98 | ) 99 | self.event.recurrence_interval = 999 100 | self.event.create() 101 | assert self.event.id == TEST_RECURRING_EVENT_DAILY.id 102 | 103 | def test_recurrence_must_have_end_date(self): 104 | self.event.recurrence_end_date = None 105 | with raises(ValueError): 106 | self.event.create() 107 | 108 | def test_recurrence_end_before_start(self): 109 | self.event.recurrence_end_date = self.event.start.date() - timedelta(1) 110 | with raises(ValueError): 111 | self.event.create() 112 | 113 | @httprettified 114 | def test_create_recurrence_daily(self): 115 | HTTPretty.register_uri( 116 | HTTPretty.POST, FAKE_EXCHANGE_URL, 117 | body=CREATE_ITEM_RESPONSE.encode('utf-8'), 118 | content_type='text/xml; charset=utf-8', 119 | ) 120 | self.event.create() 121 | assert self.event.id == TEST_RECURRING_EVENT_DAILY.id 122 | 123 | 124 | class Test_PopulatingANewRecurringWeeklyEvent(unittest.TestCase): 125 | """ Tests all the attribute setting works when creating a new event """ 126 | calendar = None 127 | 128 | @classmethod 129 | def setUpClass(cls): 130 | 131 | cls.calendar = Exchange2010Service( 132 | connection=ExchangeNTLMAuthConnection( 133 | url=FAKE_EXCHANGE_URL, 134 | username=FAKE_EXCHANGE_USERNAME, 135 | password=FAKE_EXCHANGE_PASSWORD, 136 | ) 137 | ).calendar() 138 | 139 | def test_can_set_recurring(self): 140 | event = self.calendar.event( 141 | recurrence_interval=TEST_RECURRING_EVENT_WEEKLY.recurrence_interval, 142 | recurrence_end_date=TEST_RECURRING_EVENT_WEEKLY.recurrence_end_date, 143 | recurrence_days=TEST_RECURRING_EVENT_WEEKLY.recurrence_days, 144 | ) 145 | assert event.recurrence_interval == TEST_RECURRING_EVENT_WEEKLY.recurrence_interval 146 | assert event.recurrence_end_date == TEST_RECURRING_EVENT_WEEKLY.recurrence_end_date 147 | assert event.recurrence_days == TEST_RECURRING_EVENT_WEEKLY.recurrence_days 148 | 149 | 150 | class Test_CreatingANewRecurringWeeklyEvent(unittest.TestCase): 151 | service = None 152 | event = None 153 | 154 | @classmethod 155 | def setUpClass(cls): 156 | cls.service = Exchange2010Service( 157 | connection=ExchangeNTLMAuthConnection( 158 | url=FAKE_EXCHANGE_URL, 159 | username=FAKE_EXCHANGE_USERNAME, 160 | password=FAKE_EXCHANGE_PASSWORD 161 | ) 162 | ) 163 | 164 | def setUp(self): 165 | self.event = self.service.calendar().event( 166 | subject=TEST_RECURRING_EVENT_WEEKLY.subject, 167 | start=TEST_RECURRING_EVENT_WEEKLY.start, 168 | end=TEST_RECURRING_EVENT_WEEKLY.end, 169 | recurrence='weekly', 170 | recurrence_interval=TEST_RECURRING_EVENT_WEEKLY.recurrence_interval, 171 | recurrence_end_date=TEST_RECURRING_EVENT_WEEKLY.recurrence_end_date, 172 | recurrence_days=TEST_RECURRING_EVENT_WEEKLY.recurrence_days, 173 | ) 174 | 175 | def test_recurrence_must_have_interval(self): 176 | self.event.recurrence_interval = None 177 | with raises(ValueError): 178 | self.event.create() 179 | 180 | def test_recurrence_interval_low_value(self): 181 | self.event.recurrence_interval = 0 182 | with raises(ValueError): 183 | self.event.create() 184 | 185 | def test_recurrence_interval_high_value(self): 186 | self.event.recurrence_interval = 100 187 | with raises(ValueError): 188 | self.event.create() 189 | 190 | @httprettified 191 | def test_recurrence_interval_min_value(self): 192 | HTTPretty.register_uri( 193 | HTTPretty.POST, FAKE_EXCHANGE_URL, 194 | body=CREATE_ITEM_RESPONSE.encode('utf-8'), 195 | content_type='text/xml; charset=utf-8', 196 | ) 197 | self.event.recurrence_interval = 1 198 | self.event.create() 199 | assert self.event.id == TEST_RECURRING_EVENT_WEEKLY.id 200 | 201 | @httprettified 202 | def test_recurrence_interval_max_value(self): 203 | HTTPretty.register_uri( 204 | HTTPretty.POST, FAKE_EXCHANGE_URL, 205 | body=CREATE_ITEM_RESPONSE.encode('utf-8'), 206 | content_type='text/xml; charset=utf-8', 207 | ) 208 | self.event.recurrence_interval = 99 209 | self.event.create() 210 | assert self.event.id == TEST_RECURRING_EVENT_WEEKLY.id 211 | 212 | def test_recurrence_must_have_end_date(self): 213 | self.event.recurrence_end_date = None 214 | with raises(ValueError): 215 | self.event.create() 216 | 217 | def test_recurrence_end_before_start(self): 218 | self.event.recurrence_end_date = self.event.start.date() - timedelta(1) 219 | with raises(ValueError): 220 | self.event.create() 221 | 222 | def test_recurrence_bad_days(self): 223 | self.event.recurrence_days = 'Mondays' 224 | with raises(ValueError): 225 | self.event.create() 226 | 227 | def test_recurrence_no_days(self): 228 | self.event.recurrence_days = None 229 | with raises(ValueError): 230 | self.event.create() 231 | 232 | @httprettified 233 | def test_create_recurrence_weekly(self): 234 | HTTPretty.register_uri( 235 | HTTPretty.POST, FAKE_EXCHANGE_URL, 236 | body=CREATE_ITEM_RESPONSE.encode('utf-8'), 237 | content_type='text/xml; charset=utf-8', 238 | ) 239 | self.event.create() 240 | assert self.event.id == TEST_RECURRING_EVENT_WEEKLY.id 241 | 242 | 243 | class Test_PopulatingANewRecurringMonthlyEvent(unittest.TestCase): 244 | """ Tests all the attribute setting works when creating a new event """ 245 | calendar = None 246 | 247 | @classmethod 248 | def setUpClass(cls): 249 | 250 | cls.calendar = Exchange2010Service( 251 | connection=ExchangeNTLMAuthConnection( 252 | url=FAKE_EXCHANGE_URL, 253 | username=FAKE_EXCHANGE_USERNAME, 254 | password=FAKE_EXCHANGE_PASSWORD, 255 | ) 256 | ).calendar() 257 | 258 | def test_can_set_recurring(self): 259 | event = self.calendar.event( 260 | recurrence='monthly', 261 | recurrence_interval=TEST_RECURRING_EVENT_MONTHLY.recurrence_interval, 262 | recurrence_end_date=TEST_RECURRING_EVENT_MONTHLY.recurrence_end_date, 263 | ) 264 | assert event.recurrence_interval == TEST_RECURRING_EVENT_MONTHLY.recurrence_interval 265 | assert event.recurrence_end_date == TEST_RECURRING_EVENT_MONTHLY.recurrence_end_date 266 | 267 | 268 | class Test_CreatingANewRecurringMonthlyEvent(unittest.TestCase): 269 | service = None 270 | event = None 271 | 272 | @classmethod 273 | def setUpClass(cls): 274 | cls.service = Exchange2010Service( 275 | connection=ExchangeNTLMAuthConnection( 276 | url=FAKE_EXCHANGE_URL, 277 | username=FAKE_EXCHANGE_USERNAME, 278 | password=FAKE_EXCHANGE_PASSWORD 279 | ) 280 | ) 281 | 282 | def setUp(self): 283 | self.event = self.service.calendar().event( 284 | subject=TEST_RECURRING_EVENT_MONTHLY.subject, 285 | start=TEST_RECURRING_EVENT_MONTHLY.start, 286 | end=TEST_RECURRING_EVENT_MONTHLY.end, 287 | recurrence='monthly', 288 | recurrence_interval=TEST_RECURRING_EVENT_MONTHLY.recurrence_interval, 289 | recurrence_end_date=TEST_RECURRING_EVENT_MONTHLY.recurrence_end_date, 290 | ) 291 | 292 | def test_recurrence_must_have_interval(self): 293 | self.event.recurrence_interval = None 294 | with raises(ValueError): 295 | self.event.create() 296 | 297 | def test_recurrence_interval_low_value(self): 298 | self.event.recurrence_interval = 0 299 | with raises(ValueError): 300 | self.event.create() 301 | 302 | def test_recurrence_interval_high_value(self): 303 | self.event.recurrence_interval = 100 304 | with raises(ValueError): 305 | self.event.create() 306 | 307 | @httprettified 308 | def test_recurrence_interval_min_value(self): 309 | HTTPretty.register_uri( 310 | HTTPretty.POST, FAKE_EXCHANGE_URL, 311 | body=CREATE_ITEM_RESPONSE.encode('utf-8'), 312 | content_type='text/xml; charset=utf-8', 313 | ) 314 | self.event.recurrence_interval = 1 315 | self.event.create() 316 | assert self.event.id == TEST_RECURRING_EVENT_MONTHLY.id 317 | 318 | @httprettified 319 | def test_recurrence_interval_max_value(self): 320 | HTTPretty.register_uri( 321 | HTTPretty.POST, FAKE_EXCHANGE_URL, 322 | body=CREATE_ITEM_RESPONSE.encode('utf-8'), 323 | content_type='text/xml; charset=utf-8', 324 | ) 325 | self.event.recurrence_interval = 99 326 | self.event.create() 327 | assert self.event.id == TEST_RECURRING_EVENT_MONTHLY.id 328 | 329 | def test_recurrence_must_have_end_date(self): 330 | self.event.recurrence_end_date = None 331 | with raises(ValueError): 332 | self.event.create() 333 | 334 | def test_recurrence_end_before_start(self): 335 | self.event.recurrence_end_date = self.event.start.date() - timedelta(1) 336 | with raises(ValueError): 337 | self.event.create() 338 | 339 | @httprettified 340 | def test_create_recurrence_monthly(self): 341 | HTTPretty.register_uri( 342 | HTTPretty.POST, FAKE_EXCHANGE_URL, 343 | body=CREATE_ITEM_RESPONSE.encode('utf-8'), 344 | content_type='text/xml; charset=utf-8', 345 | ) 346 | self.event.create() 347 | assert self.event.id == TEST_RECURRING_EVENT_MONTHLY.id 348 | 349 | 350 | class Test_PopulatingANewRecurringYearlyEvent(unittest.TestCase): 351 | """ Tests all the attribute setting works when creating a new event """ 352 | calendar = None 353 | 354 | @classmethod 355 | def setUpClass(cls): 356 | 357 | cls.calendar = Exchange2010Service( 358 | connection=ExchangeNTLMAuthConnection( 359 | url=FAKE_EXCHANGE_URL, 360 | username=FAKE_EXCHANGE_USERNAME, 361 | password=FAKE_EXCHANGE_PASSWORD, 362 | ) 363 | ).calendar() 364 | 365 | def test_can_set_recurring(self): 366 | event = self.calendar.event( 367 | recurrence='yearly', 368 | recurrence_end_date=TEST_RECURRING_EVENT_YEARLY.recurrence_end_date, 369 | ) 370 | event.recurrence_end_date == TEST_RECURRING_EVENT_YEARLY.recurrence_end_date 371 | 372 | 373 | class Test_CreatingANewRecurringYearlyEvent(unittest.TestCase): 374 | service = None 375 | event = None 376 | 377 | @classmethod 378 | def setUpClass(cls): 379 | cls.service = Exchange2010Service( 380 | connection=ExchangeNTLMAuthConnection( 381 | url=FAKE_EXCHANGE_URL, 382 | username=FAKE_EXCHANGE_USERNAME, 383 | password=FAKE_EXCHANGE_PASSWORD 384 | ) 385 | ) 386 | 387 | def setUp(self): 388 | self.event = self.service.calendar().event( 389 | subject=TEST_RECURRING_EVENT_YEARLY.subject, 390 | start=TEST_RECURRING_EVENT_YEARLY.start, 391 | end=TEST_RECURRING_EVENT_YEARLY.end, 392 | recurrence='yearly', 393 | recurrence_end_date=TEST_RECURRING_EVENT_YEARLY.recurrence_end_date, 394 | ) 395 | 396 | def test_recurrence_must_have_end_date(self): 397 | self.event.recurrence_end_date = None 398 | with raises(ValueError): 399 | self.event.create() 400 | 401 | def test_recurrence_end_before_start(self): 402 | self.event.recurrence_end_date = self.event.start.date() - timedelta(1) 403 | with raises(ValueError): 404 | self.event.create() 405 | 406 | @httprettified 407 | def test_create_recurrence_yearly(self): 408 | HTTPretty.register_uri( 409 | HTTPretty.POST, FAKE_EXCHANGE_URL, 410 | body=CREATE_ITEM_RESPONSE.encode('utf-8'), 411 | content_type='text/xml; charset=utf-8', 412 | ) 413 | self.event.create() 414 | assert self.event.id == TEST_RECURRING_EVENT_YEARLY.id 415 | -------------------------------------------------------------------------------- /tests/exchange2010/test_delete_event.py: -------------------------------------------------------------------------------- 1 | """ 2 | (c) 2013 LinkedIn Corp. All rights reserved. 3 | Licensed under the Apache License, Version 2.0 (the "License");?you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 4 | 5 | Unless required by applicable law or agreed to in writing, software?distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 6 | """ 7 | import unittest 8 | import httpretty 9 | from pytest import raises 10 | from pyexchange import Exchange2010Service 11 | from pyexchange.connection import ExchangeNTLMAuthConnection 12 | 13 | from .fixtures import * 14 | 15 | class Test_EventDeletion(unittest.TestCase): 16 | event = None 17 | 18 | @classmethod 19 | def setUpClass(cls): 20 | cls.service = Exchange2010Service(connection=ExchangeNTLMAuthConnection(url=FAKE_EXCHANGE_URL, username=FAKE_EXCHANGE_USERNAME, password=FAKE_EXCHANGE_PASSWORD)) 21 | 22 | 23 | cls.get_change_key_response = httpretty.Response(body=GET_ITEM_RESPONSE_ID_ONLY.encode('utf-8'), status=200, content_type='text/xml; charset=utf-8') 24 | cls.delete_event_response = httpretty.Response(body=DELETE_ITEM_RESPONSE.encode('utf-8'), status=200, content_type='text/xml; charset=utf-8') 25 | 26 | @httpretty.activate 27 | def setUp(self): 28 | 29 | httpretty.register_uri(httpretty.POST, FAKE_EXCHANGE_URL, 30 | body=GET_ITEM_RESPONSE.encode('utf-8'), 31 | content_type='text/xml; charset=utf-8') 32 | 33 | self.event = self.service.calendar().get_event(id=TEST_EVENT.id) 34 | 35 | 36 | @httpretty.activate 37 | def test_can_cancel_event(self): 38 | httpretty.register_uri(httpretty.POST, FAKE_EXCHANGE_URL, 39 | responses=[ 40 | self.get_change_key_response, 41 | self.delete_event_response, 42 | ]) 43 | 44 | response = self.event.cancel() 45 | assert response is None 46 | 47 | 48 | @httpretty.activate 49 | def test_cant_cancel_an_event_with_no_exchange_id(self): 50 | httpretty.register_uri(httpretty.POST, FAKE_EXCHANGE_URL, 51 | responses=[ 52 | self.get_change_key_response, 53 | self.delete_event_response, 54 | ]) 55 | unsaved_event = self.service.calendar().event() 56 | 57 | with raises(TypeError): 58 | unsaved_event.cancel() #bzzt - can't do this 59 | 60 | -------------------------------------------------------------------------------- /tests/exchange2010/test_delete_folder.py: -------------------------------------------------------------------------------- 1 | """ 2 | (c) 2013 LinkedIn Corp. All rights reserved. 3 | Licensed under the Apache License, Version 2.0 (the "License");?you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 4 | 5 | Unless required by applicable law or agreed to in writing, software?distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 6 | """ 7 | import unittest 8 | import httpretty 9 | from pytest import raises 10 | from pyexchange import Exchange2010Service 11 | from pyexchange.connection import ExchangeNTLMAuthConnection 12 | 13 | from .fixtures import * 14 | 15 | 16 | class Test_FolderDeletion(unittest.TestCase): 17 | folder = None 18 | 19 | @classmethod 20 | def setUpClass(cls): 21 | cls.service = Exchange2010Service( 22 | connection=ExchangeNTLMAuthConnection( 23 | url=FAKE_EXCHANGE_URL, 24 | username=FAKE_EXCHANGE_USERNAME, 25 | password=FAKE_EXCHANGE_PASSWORD, 26 | ) 27 | ) 28 | 29 | cls.get_change_key_response = httpretty.Response( 30 | body=GET_FOLDER_RESPONSE.encode('utf-8'), 31 | status=200, 32 | content_type='text/xml; charset=utf-8' 33 | ) 34 | cls.delete_folder_response = httpretty.Response( 35 | body=DELETE_FOLDER_RESPONSE.encode('utf-8'), 36 | status=200, 37 | content_type='text/xml; charset=utf-8' 38 | ) 39 | 40 | @httpretty.activate 41 | def setUp(self): 42 | 43 | httpretty.register_uri( 44 | httpretty.POST, 45 | FAKE_EXCHANGE_URL, 46 | body=GET_FOLDER_RESPONSE.encode('utf-8'), 47 | content_type='text/xml; charset=utf-8', 48 | ) 49 | 50 | self.folder = self.service.folder().get_folder(id=TEST_FOLDER.id) 51 | 52 | @httpretty.activate 53 | def test_can_delete_folder(self): 54 | httpretty.register_uri( 55 | httpretty.POST, 56 | FAKE_EXCHANGE_URL, 57 | responses=[ 58 | self.get_change_key_response, 59 | self.delete_folder_response, 60 | ] 61 | ) 62 | 63 | response = self.folder.delete() 64 | assert response is None 65 | 66 | @httpretty.activate 67 | def test_cant_delete_a_uncreated_folder(self): 68 | httpretty.register_uri( 69 | httpretty.POST, 70 | FAKE_EXCHANGE_URL, 71 | responses=[ 72 | self.get_change_key_response, 73 | self.delete_folder_response, 74 | ] 75 | ) 76 | unsaved_folder = self.service.folder().new_folder() 77 | 78 | with raises(TypeError): 79 | unsaved_folder.delete() # bzzt - can't do this 80 | -------------------------------------------------------------------------------- /tests/exchange2010/test_event_actions.py: -------------------------------------------------------------------------------- 1 | """ 2 | (c) 2013 LinkedIn Corp. All rights reserved. 3 | Licensed under the Apache License, Version 2.0 (the "License");?you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 4 | 5 | Unless required by applicable law or agreed to in writing, software?distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 6 | """ 7 | import unittest 8 | from httpretty import HTTPretty, httprettified 9 | from pytest import raises 10 | from pyexchange import Exchange2010Service 11 | from pyexchange.connection import ExchangeNTLMAuthConnection 12 | from pyexchange.exceptions import * 13 | 14 | from .fixtures import * 15 | 16 | 17 | class Test_EventActions(unittest.TestCase): 18 | event = None 19 | 20 | @classmethod 21 | def setUpClass(cls): 22 | cls.service = Exchange2010Service(connection=ExchangeNTLMAuthConnection(url=FAKE_EXCHANGE_URL, username=FAKE_EXCHANGE_USERNAME, password=FAKE_EXCHANGE_PASSWORD)) 23 | cls.get_change_key_response = HTTPretty.Response(body=GET_ITEM_RESPONSE_ID_ONLY.encode('utf-8'), status=200, content_type='text/xml; charset=utf-8') 24 | cls.update_event_response = HTTPretty.Response(body=UPDATE_ITEM_RESPONSE.encode('utf-8'), status=200, content_type='text/xml; charset=utf-8') 25 | 26 | 27 | @httprettified 28 | def setUp(self): 29 | HTTPretty.register_uri(HTTPretty.POST, FAKE_EXCHANGE_URL, 30 | body=GET_ITEM_RESPONSE.encode('utf-8'), 31 | content_type='text/xml; charset=utf-8') 32 | 33 | self.event = self.service.calendar().get_event(id=TEST_EVENT.id) 34 | 35 | 36 | @httprettified 37 | def test_resend_invites(self): 38 | HTTPretty.register_uri(HTTPretty.POST, FAKE_EXCHANGE_URL, 39 | responses=[ 40 | self.get_change_key_response, 41 | self.update_event_response, 42 | ]) 43 | self.event.resend_invitations() 44 | 45 | assert TEST_EVENT.change_key in HTTPretty.last_request.body.decode('utf-8') 46 | assert TEST_EVENT.subject not in HTTPretty.last_request.body.decode('utf-8') 47 | 48 | @httprettified 49 | def test_cant_resend_invites_on_a_modified_event(self): 50 | HTTPretty.register_uri(HTTPretty.POST, FAKE_EXCHANGE_URL, 51 | responses=[ 52 | self.get_change_key_response, 53 | self.update_event_response, 54 | ]) 55 | 56 | self.event.subject = u'New event thing' 57 | 58 | with raises(ValueError): 59 | self.event.resend_invitations() 60 | -------------------------------------------------------------------------------- /tests/exchange2010/test_exchange_service.py: -------------------------------------------------------------------------------- 1 | __author__ = 'rsanders' 2 | -------------------------------------------------------------------------------- /tests/exchange2010/test_find_folder.py: -------------------------------------------------------------------------------- 1 | """ 2 | (c) 2013 LinkedIn Corp. All rights reserved. 3 | Licensed under the Apache License, Version 2.0 (the "License");?you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 4 | 5 | Unless required by applicable law or agreed to in writing, software?distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 6 | """ 7 | import unittest 8 | import httpretty 9 | from pytest import raises 10 | from pyexchange import Exchange2010Service 11 | from pyexchange.connection import ExchangeNTLMAuthConnection 12 | from pyexchange.exceptions import * 13 | 14 | from .fixtures import * 15 | 16 | 17 | class Test_ParseFolderResponseData(unittest.TestCase): 18 | folder = None 19 | 20 | @classmethod 21 | def setUpClass(cls): 22 | 23 | @httpretty.activate # this decorator doesn't play nice with @classmethod 24 | def fake_folder_request(): 25 | 26 | service = Exchange2010Service( 27 | connection=ExchangeNTLMAuthConnection( 28 | url=FAKE_EXCHANGE_URL, 29 | username=FAKE_EXCHANGE_USERNAME, 30 | password=FAKE_EXCHANGE_PASSWORD, 31 | ) 32 | ) 33 | 34 | httpretty.register_uri( 35 | httpretty.POST, 36 | FAKE_EXCHANGE_URL, 37 | body=GET_FOLDER_RESPONSE.encode('utf-8'), 38 | content_type='text/xml; charset=utf-8', 39 | ) 40 | 41 | return service.folder().find_folder(parent_id=TEST_FOLDER.id) 42 | 43 | cls.folder = fake_folder_request() 44 | 45 | def test_canary(self): 46 | for folder in self.folder: 47 | assert folder is not None 48 | 49 | def test_folder_has_a_name(self): 50 | for folder in self.folder: 51 | assert folder is not None 52 | 53 | def test_folder_has_a_parent(self): 54 | for folder in self.folder: 55 | assert folder.parent_id == TEST_FOLDER.id 56 | 57 | def test_folder_type(self): 58 | for folder in self.folder: 59 | assert folder is not None 60 | 61 | 62 | class Test_FailingToGetFolders(unittest.TestCase): 63 | 64 | service = None 65 | 66 | @classmethod 67 | def setUpClass(cls): 68 | 69 | cls.service = Exchange2010Service( 70 | connection=ExchangeNTLMAuthConnection( 71 | url=FAKE_EXCHANGE_URL, 72 | username=FAKE_EXCHANGE_USERNAME, 73 | password=FAKE_EXCHANGE_PASSWORD 74 | ) 75 | ) 76 | 77 | @httpretty.activate 78 | def test_requesting_an_folder_id_that_doest_exist_throws_exception(self): 79 | 80 | httpretty.register_uri( 81 | httpretty.POST, FAKE_EXCHANGE_URL, 82 | body=FOLDER_DOES_NOT_EXIST.encode('utf-8'), 83 | content_type='text/xml; charset=utf-8', 84 | ) 85 | 86 | with raises(ExchangeItemNotFoundException): 87 | self.service.folder().find_folder(parent_id=TEST_FOLDER.id) 88 | 89 | @httpretty.activate 90 | def test_requesting_an_folder_and_getting_a_500_response_throws_exception(self): 91 | 92 | httpretty.register_uri( 93 | httpretty.POST, 94 | FAKE_EXCHANGE_URL, 95 | body=u"", 96 | status=500, 97 | content_type='text/xml; charset=utf-8', 98 | ) 99 | 100 | with raises(FailedExchangeException): 101 | self.service.folder().find_folder(parent_id=TEST_FOLDER.id) 102 | -------------------------------------------------------------------------------- /tests/exchange2010/test_get_event.py: -------------------------------------------------------------------------------- 1 | """ 2 | (c) 2013 LinkedIn Corp. All rights reserved. 3 | Licensed under the Apache License, Version 2.0 (the "License");?you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 4 | 5 | Unless required by applicable law or agreed to in writing, software?distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 6 | """ 7 | from httpretty import HTTPretty, httprettified, activate 8 | import unittest 9 | from pytest import raises 10 | from pyexchange import Exchange2010Service 11 | from pyexchange.connection import ExchangeNTLMAuthConnection 12 | from pyexchange.exceptions import * # noqa 13 | 14 | from .fixtures import * # noqa 15 | 16 | 17 | class Test_ParseEventResponseData(unittest.TestCase): 18 | event = None 19 | 20 | @classmethod 21 | def setUpClass(cls): 22 | 23 | @activate # this decorator doesn't play nice with @classmethod 24 | def fake_event_request(): 25 | 26 | service = Exchange2010Service( 27 | connection=ExchangeNTLMAuthConnection( 28 | url=FAKE_EXCHANGE_URL, username=FAKE_EXCHANGE_USERNAME, password=FAKE_EXCHANGE_PASSWORD 29 | ) 30 | ) 31 | 32 | HTTPretty.register_uri( 33 | HTTPretty.POST, FAKE_EXCHANGE_URL, 34 | body=GET_ITEM_RESPONSE.encode('utf-8'), 35 | content_type='text/xml; charset=utf-8', 36 | ) 37 | 38 | return service.calendar().get_event(id=TEST_EVENT.id) 39 | 40 | cls.event = fake_event_request() 41 | 42 | def test_canary(self): 43 | assert self.event is not None 44 | 45 | def test_event_id_was_not_changed(self): 46 | assert self.event.id == TEST_EVENT.id 47 | 48 | def test_event_has_a_subject(self): 49 | assert self.event.subject == TEST_EVENT.subject 50 | 51 | def test_event_has_a_location(self): 52 | assert self.event.location == TEST_EVENT.location 53 | 54 | def test_event_has_a_body(self): 55 | assert self.event.html_body == TEST_EVENT.body 56 | assert self.event.text_body == TEST_EVENT.body 57 | assert self.event.body == TEST_EVENT.body 58 | 59 | def test_event_starts_at_the_right_time(self): 60 | assert self.event.start == TEST_EVENT.start 61 | 62 | def test_event_ends_at_the_right_time(self): 63 | assert self.event.end == TEST_EVENT.end 64 | 65 | def test_event_has_an_organizer(self): 66 | assert self.event.organizer is not None 67 | assert self.event.organizer.name == ORGANIZER.name 68 | assert self.event.organizer.email == ORGANIZER.email 69 | 70 | def test_event_has_the_correct_attendees(self): 71 | assert len(self.event.attendees) > 0 72 | assert len(self.event.attendees) == len(ATTENDEE_LIST) 73 | 74 | def _test_person_values_are_correct(self, fixture): 75 | 76 | try: 77 | self.event.attendees.index(fixture) 78 | except ValueError as e: 79 | print(u"An attendee should be in the list but isn't:", fixture) 80 | raise e 81 | 82 | def test_all_attendees_are_present_and_accounted_for(self): 83 | 84 | # this is a nose test generator if you haven't seen one before 85 | # it creates one test for each attendee 86 | for attendee in ATTENDEE_LIST: 87 | yield self._test_person_values_are_correct, attendee 88 | 89 | def test_resources_are_correct(self): 90 | assert self.event.resources == [RESOURCE] 91 | 92 | def test_conference_room_alias(self): 93 | assert self.event.conference_room == RESOURCE 94 | 95 | def test_required_attendees_are_required(self): 96 | assert sorted(self.event.required_attendees) == sorted(REQUIRED_PEOPLE) 97 | 98 | def test_optional_attendees_are_optional(self): 99 | assert sorted(self.event.optional_attendees) == sorted(OPTIONAL_PEOPLE) 100 | 101 | def test_conflicting_event_ids(self): 102 | assert self.event.conflicting_event_ids[0] == TEST_CONFLICT_EVENT.id 103 | 104 | @httprettified 105 | def test_conflicting_events(self): 106 | HTTPretty.register_uri( 107 | HTTPretty.POST, FAKE_EXCHANGE_URL, 108 | body=CONFLICTING_EVENTS_RESPONSE.encode('utf-8'), 109 | content_type='text/xml; charset=utf-8', 110 | ) 111 | conflicting_events = self.event.conflicting_events() 112 | assert conflicting_events[0].id == TEST_CONFLICT_EVENT.id 113 | assert conflicting_events[0].calendar_id == TEST_CONFLICT_EVENT.calendar_id 114 | assert conflicting_events[0].subject == TEST_CONFLICT_EVENT.subject 115 | assert conflicting_events[0].location == TEST_CONFLICT_EVENT.location 116 | assert conflicting_events[0].start == TEST_CONFLICT_EVENT.start 117 | assert conflicting_events[0].end == TEST_CONFLICT_EVENT.end 118 | assert conflicting_events[0].body == TEST_CONFLICT_EVENT.body 119 | assert conflicting_events[0].conflicting_event_ids[0] == TEST_EVENT.id 120 | 121 | 122 | class Test_FailingToGetEvents(unittest.TestCase): 123 | 124 | service = None 125 | 126 | @classmethod 127 | def setUpClass(cls): 128 | 129 | cls.service = Exchange2010Service( 130 | connection=ExchangeNTLMAuthConnection( 131 | url=FAKE_EXCHANGE_URL, username=FAKE_EXCHANGE_USERNAME, password=FAKE_EXCHANGE_PASSWORD 132 | ) 133 | ) 134 | 135 | @activate 136 | def test_requesting_an_event_id_that_doest_exist_throws_exception(self): 137 | 138 | HTTPretty.register_uri( 139 | HTTPretty.POST, FAKE_EXCHANGE_URL, 140 | body=ITEM_DOES_NOT_EXIST.encode('utf-8'), 141 | content_type='text/xml; charset=utf-8', 142 | ) 143 | 144 | with raises(ExchangeItemNotFoundException): 145 | self.service.calendar().get_event(id=TEST_EVENT.id) 146 | 147 | @activate 148 | def test_requesting_an_event_and_getting_a_500_response_throws_exception(self): 149 | 150 | HTTPretty.register_uri( 151 | HTTPretty.POST, FAKE_EXCHANGE_URL, 152 | body=u"", 153 | status=500, 154 | content_type='text/xml; charset=utf-8', 155 | ) 156 | 157 | with raises(FailedExchangeException): 158 | self.service.calendar().get_event(id=TEST_EVENT.id) 159 | 160 | @activate 161 | def test_requesting_an_event_and_getting_garbage_xml_throws_exception(self): 162 | 163 | HTTPretty.register_uri( 164 | HTTPretty.POST, FAKE_EXCHANGE_URL, 165 | body=u"