Update upstream source from tag 'upstream/3.2.0'
Update to upstream version '3.2.0'
with Debian dir df9e947ecef4281a89a9f62643a72a4007212c8f
Michael Fladischer
3 years ago
14 | 14 | - openssl aes-256-cbc -K $encrypted_ae8487d57299_key -iv $encrypted_ae8487d57299_iv -in settings.yml.enc -out settings.yml -d |
15 | 15 | |
16 | 16 | install: |
17 | - python -m ensurepip --upgrade | |
17 | 18 | # Install master branches of Cython and Cython-built packages if we are testing on nightly since the C API of |
18 | 19 | # CPython changes often and fixes for Python nightly are slow to reach released versions. |
19 | - if [[ "$( python --version | grep '[a|b]' )" ]] ; then pip install git+https://github.com/cython/cython.git ; fi | |
20 | - if [[ "$( python --version | grep '[a|b]' )" ]] ; then pip install git+https://github.com/lxml/lxml.git ; fi | |
21 | - if [[ "$( python --version | grep '[a|b]' )" ]] ; then pip install git+https://github.com/yaml/pyyaml.git ; fi | |
22 | - pip install . | |
20 | - if [[ "$( python --version | grep '[a|b]' )" ]] ; then python -m pip install git+https://github.com/cython/cython.git ; fi | |
21 | - if [[ "$( python --version | grep '[a|b]' )" ]] ; then python -m pip install git+https://github.com/lxml/lxml.git ; fi | |
22 | - if [[ "$( python --version | grep '[a|b]' )" ]] ; then python -m pip install git+https://github.com/yaml/pyyaml.git ; fi | |
23 | - python -m pip install . | |
23 | 24 | # Install test dependencies manually since we're calling tests/__init__.py directly in the 'script' section |
24 | - pip install PyYAML requests_mock psutil coverage coveralls flake8 | |
25 | - python -m pip install PyYAML requests_mock psutil coverage coveralls flake8 | |
25 | 26 | |
26 | 27 | script: |
27 | 28 | - coverage run --source=exchangelib setup.py test |
2 | 2 | |
3 | 3 | HEAD |
4 | 4 | ---- |
5 | ||
6 | ||
7 | 3.2.0 | |
8 | ----- | |
9 | - Remove use of `ThreadPool` objects. Threads were used to implement async HTTP requests, but | |
10 | were creating massive memory leaks. Async requests should be reimplemented using a real async | |
11 | HTTP request package, so this is just an emergency fix. This also lowers the default | |
12 | `Protocol.SESSION_POOLSIZE` to 1 because no internal code is running multi-threaded anymore. | |
13 | - All-day calendar items (created as `CalendarItem(is_all_day=True, ...)`) now accept `EWSDate` | |
14 | instances for the `start` and `end` values. Similarly, all-day calendar items fetched from | |
15 | the server now return `start` and `end` values as `EWSDate` instances. In this case, start | |
16 | and end values are inclusive; a one-day event starts and ends on the same `EWSDate` value. | |
17 | - Add support for `RecurringMasterItemId` and `OccurrenceItemId` elements that allow to request | |
18 | the master recurrence from a `CalendarItem` occurrence, and to request a specific occurrence | |
19 | from a `CalendarItem` master recurrence. `CalendarItem.master_recurrence()` and | |
20 | `CalendarItem.occurrence(some_occurrence_index)` methods were added to aid this traversal. | |
21 | `some_occurrence_index` in the last method specifies which item in the list of occurrences to | |
22 | target; `CalendarItem.occurrence(3)` gets the third occurrence in the recurrence. | |
23 | - Change `Contact.birthday` and `Contact.wedding_anniversary` from `EWSDateTime` to `EWSDate` | |
24 | fields. EWS still expects and sends datetime values but has started to reset the time part to | |
25 | 11:59. Dates are a better match for these two fields anyway. | |
26 | - Remove support for `len(some_queryset)`. It had the nasty side-effect of forcing | |
27 | `list(some_queryset)` to run the query twice, once for pre-allocating the list via the result | |
28 | of `len(some_queryset)`, and then once more to fetch the results. All occurrences of | |
29 | `len(some_queryset)` can be replaced with `some_queryset.count()`. Unfortunately, there is | |
30 | no way to keep backwards-compatibility for this feature. | |
31 | - Added `Account.identity`, an attribute to contain extra information for impersonation. Setting | |
32 | `Account.identity.upn` or `Account.identity.sid` removes the need for an AD lookup on every request. | |
33 | `upn` will often be the same as `primary_smtp_address`, but it is not guaranteed. If you have | |
34 | access to your organization's AD servers, you can look up these values once and add them to your | |
35 | `Account` object to improve performance of the following requests. | |
36 | - Added support for CBA authentication | |
5 | 37 | |
6 | 38 | |
7 | 39 | 3.1.1 |
0 | Copyright (c) 2009-2018 Erik Cederstrand <erik@cederstrand.dk> | |
0 | Copyright (c) 2009 Erik Cederstrand <erik@cederstrand.dk> | |
1 | 1 | |
2 | 2 | Redistribution and use in source and binary forms, with or without modification, are |
3 | 3 | permitted provided that the following conditions are met: |
91 | 91 | ## Setup and connecting |
92 | 92 | |
93 | 93 | ```python |
94 | from exchangelib import DELEGATE, IMPERSONATION, Account, Credentials, OAuth2Credentials, \ | |
95 | OAuth2AuthorizationCodeCredentials, FaultTolerance, Configuration, NTLM, GSSAPI, SSPI, \ | |
96 | OAUTH2, Build, Version | |
97 | from exchangelib.autodiscover import AutodiscoverProtocol | |
94 | from exchangelib import DELEGATE, IMPERSONATION, Account, Credentials | |
98 | 95 | |
99 | 96 | # Specify your credentials. Username is usually in WINDOMAIN\username format, where WINDOMAIN is |
100 | 97 | # the name of the Windows Domain your username is connected to, but some servers also |
126 | 123 | still_marys_account = Account(primary_smtp_address='alias_for_mary@example.com', |
127 | 124 | credentials=credentials, autodiscover=True, access_type=DELEGATE) |
128 | 125 | |
129 | # Full autodiscover data is availale on the Account object: | |
126 | # Full autodiscover data is available on the Account object: | |
130 | 127 | my_account.ad_response |
131 | 128 | |
132 | 129 | # Set up a target account and do an autodiscover lookup to find the target EWS endpoint. |
137 | 134 | # different 'access_type': |
138 | 135 | account = Account(primary_smtp_address='john@example.com', credentials=credentials, |
139 | 136 | autodiscover=True, access_type=IMPERSONATION) |
137 | ``` | |
138 | ||
139 | ### Optimizing connections | |
140 | ```python | |
141 | from exchangelib import DELEGATE, Account, Configuration, Credentials, NTLM, Build, Version | |
142 | # According to MSDN docs, you can avoid a per-request AD lookup if you specify the UPN or SID | |
143 | # of the account when you are using impersonation. To do this, set one of these values. EWS cannot | |
144 | # provide you with these values - you have to fetch them by some other means, e.g. via AD lookup: | |
145 | account = Account(...) | |
146 | account.identity.sid = 'S-my-sid' | |
147 | account.identity.upn = 'john@subdomain.example.com' | |
140 | 148 | |
141 | 149 | # If the server doesn't support autodiscover, or you want to avoid the overhead of autodiscover, |
142 | 150 | # use a Configuration object to set the server location instead: |
151 | credentials = Credentials(...) | |
143 | 152 | config = Configuration(server='mail.example.com', credentials=credentials) |
144 | 153 | account = Account(primary_smtp_address='john@example.com', config=config, |
145 | 154 | autodiscover=False, access_type=DELEGATE) |
151 | 160 | config = Configuration( |
152 | 161 | server='example.com', credentials=credentials, version=version, auth_type=NTLM |
153 | 162 | ) |
154 | ||
163 | ``` | |
164 | ||
165 | ### Fault tolerance | |
166 | ```python | |
167 | from exchangelib import Account, FaultTolerance, Configuration, Credentials | |
168 | from exchangelib.autodiscover import Autodiscovery | |
155 | 169 | # By default, we fail on all exceptions from the server. If you want to enable fault |
156 | 170 | # tolerance, add a retry policy to your configuration. We will then retry on certain |
157 | 171 | # transient errors. By default, we back off exponentially and retry for up to an hour. |
158 | 172 | # This is configurable: |
173 | credentials = Credentials(...) | |
159 | 174 | config = Configuration(retry_policy=FaultTolerance(max_wait=3600), credentials=credentials) |
160 | 175 | account = Account(primary_smtp_address='john@example.com', config=config) |
161 | 176 | |
162 | 177 | # Autodiscovery will also use this policy, but only for the final autodiscover endpoint. |
163 | 178 | # Here's how to change the policy for connecting to autodiscover candidate servers. |
164 | # Old autodiscover implementation | |
165 | import exchangelib.autodiscover.legacy | |
166 | exchangelib.autodiscover.legacy.INITIAL_RETRY_POLICY = FaultTolerance(max_wait=30) | |
167 | # New autodiscover implementation | |
168 | from exchangelib.autodiscover import Autodiscovery | |
169 | 179 | Autodiscovery.INITIAL_RETRY_POLICY = FaultTolerance(max_wait=30) |
170 | ||
180 | ``` | |
181 | ||
182 | ### Kerberos and SSPI authentication | |
183 | ```python | |
184 | from exchangelib import Configuration, GSSAPI, SSPI | |
171 | 185 | # Kerberos and SSPI authentication are supported via the GSSAPI and SSPI auth types. |
172 | config = Configuration(server='example.com', auth_type=GSSAPI) | |
173 | config = Configuration(server='example.com', auth_type=SSPI) | |
174 | ||
186 | config = Configuration(auth_type=GSSAPI) | |
187 | config = Configuration(auth_type=SSPI) | |
188 | ``` | |
189 | ||
190 | ### Certificate Based Authentication (CBA) | |
191 | ```python | |
192 | from exchangelib import Configuration, BaseProtocol, CBA, TLSClientAuth | |
193 | TLSClientAuth.cert_file = '/path/to/client.pem' | |
194 | BaseProtocol.HTTP_ADAPTER_CLS = TLSClientAuth | |
195 | config = Configuration(auth_type=CBA) | |
196 | ``` | |
197 | ||
198 | ### OAuth authentication | |
199 | ```python | |
175 | 200 | # OAuth is supported via the OAUTH2 auth type and the OAuth2Credentials class. |
176 | 201 | # Use OAuth2AuthorizationCodeCredentials for the authorization code flow (useful |
177 | 202 | # for applications that access multiple accounts). |
203 | from exchangelib import Configuration, OAuth2Credentials, OAuth2AuthorizationCodeCredentials, \ | |
204 | Identity, OAUTH2 | |
205 | from oauthlib.oauth2 import OAuth2Token | |
178 | 206 | credentials = OAuth2Credentials(client_id='MY_ID', client_secret='MY_SECRET', tenant_id='TENANT_ID') |
207 | # The OAuth2Credentials flow may need to have impersonation headers set. If you get | |
208 | # impersonation errors, add information about the account that the OAuth2Credentials | |
209 | # was created for: | |
210 | credentials = OAuth2Credentials(..., identity=Identity(primary_smtp_address='svc_acct@example.com')) | |
211 | credentials = OAuth2Credentials(..., identity=Identity(upn='svc_acct@subdomain.example.com')) | |
212 | ||
179 | 213 | credentials = OAuth2AuthorizationCodeCredentials(client_id='MY_ID', client_secret='MY_SECRET', authorization_code='AUTH_CODE') |
180 | credentials = OAuth2AuthorizationCodeCredentials(client_id='MY_ID', client_secret='MY_SECRET', access_token='EXISTING_TOKEN') | |
214 | credentials = OAuth2AuthorizationCodeCredentials( | |
215 | client_id='MY_ID', client_secret='MY_SECRET', access_token=OAuth2Token(access_token='EXISTING_TOKEN') | |
216 | ) | |
181 | 217 | config = Configuration(credentials=credentials, auth_type=OAUTH2) |
182 | 218 | |
183 | 219 | # Applications using the authorization code flow that let exchangelib refresh |
197 | 233 | class MyCredentials(OAuth2AuthorizationCodeCredentials): |
198 | 234 | def refresh(self): |
199 | 235 | self.access_token = ... |
200 | ||
236 | ``` | |
237 | ||
238 | ### Caching autodiscover results | |
239 | ```python | |
240 | from exchangelib import Configuration, Credentials, Account, DELEGATE | |
201 | 241 | # If you're connecting to the same account very often, you can cache the autodiscover result for |
202 | 242 | # later so you can skip the autodiscover lookup: |
243 | account = Account(...) | |
203 | 244 | ews_url = account.protocol.service_endpoint |
204 | 245 | ews_auth_type = account.protocol.auth_type |
205 | 246 | primary_smtp_address = account.primary_smtp_address |
247 | # This one is optional. It is used as a hint to the initial connection and avoids one or more roundtrips | |
248 | # to guess the correct Exchange server version. | |
249 | version = account.version | |
206 | 250 | |
207 | 251 | # You can now create the Account without autodiscovering, using the cached values: |
208 | config = Configuration(service_endpoint=ews_url, credentials=credentials, auth_type=ews_auth_type) | |
252 | credentials = Credentials(...) | |
253 | config = Configuration(service_endpoint=ews_url, credentials=credentials, auth_type=ews_auth_type, version=version) | |
209 | 254 | account = Account( |
210 | 255 | primary_smtp_address=primary_smtp_address, |
211 | 256 | config=config, autodiscover=False, |
223 | 268 | clear_cache() |
224 | 269 | ``` |
225 | 270 | |
226 | ## Proxies and custom TLS validation | |
271 | ### Proxies and custom TLS validation | |
227 | 272 | |
228 | 273 | If you need proxy support or custom TLS validation, you can supply a |
229 | 274 | custom 'requests' transport adapter class, as described in |
318 | 363 | some_folder.children # A generator of child folders |
319 | 364 | some_folder.absolute # Returns the absolute path, as a string |
320 | 365 | some_folder.walk() # A generator returning all subfolders at arbitrary depth this level |
321 | # Globbing uses the normal UNIX globbing syntax | |
366 | # Globbing uses the normal UNIX globbing syntax, but case-insensitive | |
322 | 367 | some_folder.glob('foo*') # Return child folders matching the pattern |
323 | 368 | some_folder.glob('*/foo') # Return subfolders named 'foo' in any child folder |
324 | 369 | some_folder.glob('**/foo') # Return subfolders named 'foo' at any depth |
642 | 687 | |
643 | 688 | # The syntax for filter() is modeled after Django QuerySet filters. The following filter lookup |
644 | 689 | # types are supported. Some lookups only work with string attributes. Range and less/greater |
645 | # operators only work for date or numerical attributes. Some attributes are not searchable at all | |
646 | # via EWS: | |
647 | qs = a.calendar.all() | |
690 | # operators only work for date or numerical attributes. This is determined by the field type. | |
691 | # | |
692 | # Some attributes are not searchable at all via EWS. This is determined by the "is_searchable" | |
693 | # attribute on the field. | |
694 | ||
695 | # List the field name and field type of searchable fields for a certain item type | |
696 | for f in Message.FIELDS: | |
697 | if f.is_searchable: | |
698 | print(f.name, f) | |
699 | ||
700 | qs = a.calendar.all() # No restrictions. Return all items. | |
648 | 701 | qs.filter(subject='foo') # Returns items where subject is exactly 'foo'. Case-sensitive |
649 | 702 | qs.filter(start__range=(start, end)) # Returns items within range |
650 | 703 | qs.filter(subject__in=('foo', 'bar')) # Return items where subject is either 'foo' or 'bar' |
1049 | 1102 | a = Account(...) |
1050 | 1103 | start = a.default_timezone.localize(EWSDateTime(2017, 9, 1, 11)) |
1051 | 1104 | end = start + timedelta(hours=2) |
1052 | item = CalendarItem( | |
1105 | master_recurrence = CalendarItem( | |
1053 | 1106 | folder=a.calendar, |
1054 | 1107 | start=start, |
1055 | 1108 | end=end, |
1092 | 1145 | occurrence.save() |
1093 | 1146 | else: |
1094 | 1147 | occurrence.delete() |
1148 | ||
1149 | # If you want to access a specific occurrence any you oly have the master recurrence: | |
1150 | third_occurrence = master_recurrence.occurrence(index=3) | |
1151 | # Get all fields on this occurrence | |
1152 | third_occurrence.refresh() | |
1153 | # Change a field on the occurrence | |
1154 | third_occurrence.start += timedelta(hours=3) | |
1155 | # Delete occurrence | |
1156 | third_occurrence.save(update_fields=['start']) | |
1157 | ||
1158 | # Similarly, you can reach the master recurrence from the occurrence | |
1159 | master = third_occurrence.master_recurrence() | |
1160 | master.subject = 'An update' | |
1161 | master.save(update_fields=['subject']) | |
1095 | 1162 | ``` |
1096 | 1163 | |
1097 | 1164 | ## Message timestamp fields |
0 | from .account import Account | |
0 | from .account import Account, Identity | |
1 | 1 | from .attachments import FileAttachment, ItemAttachment |
2 | 2 | from .autodiscover import discover |
3 | 3 | from .configuration import Configuration |
9 | 9 | from .items import AcceptItem, TentativelyAcceptItem, DeclineItem, CalendarItem, CancelCalendarItem, Contact, \ |
10 | 10 | DistributionList, Message, PostItem, Task |
11 | 11 | from .properties import Body, HTMLBody, ItemId, Mailbox, Attendee, Room, RoomList, UID, DLMailbox |
12 | from .protocol import FaultTolerance, FailFast | |
12 | from .protocol import FaultTolerance, FailFast, BaseProtocol, NoVerifyHTTPAdapter, TLSClientAuth | |
13 | 13 | from .settings import OofSettings |
14 | 14 | from .restriction import Q |
15 | from .transport import BASIC, DIGEST, NTLM, GSSAPI, SSPI, OAUTH2 | |
15 | from .transport import BASIC, DIGEST, NTLM, GSSAPI, SSPI, OAUTH2, CBA | |
16 | 16 | from .version import Build, Version |
17 | 17 | |
18 | __version__ = '3.1.1' | |
18 | __version__ = '3.2.0' | |
19 | 19 | |
20 | 20 | __all__ = [ |
21 | 21 | '__version__', |
22 | 'Account', | |
22 | 'Account', 'Identity', | |
23 | 23 | 'FileAttachment', 'ItemAttachment', |
24 | 24 | 'discover', |
25 | 25 | 'Configuration', |
30 | 30 | 'AcceptItem', 'TentativelyAcceptItem', 'DeclineItem', 'CalendarItem', 'CancelCalendarItem', 'Contact', |
31 | 31 | 'DistributionList', 'Message', 'PostItem', 'Task', |
32 | 32 | 'ItemId', 'Mailbox', 'DLMailbox', 'Attendee', 'Room', 'RoomList', 'Body', 'HTMLBody', 'UID', |
33 | 'FailFast', 'FaultTolerance', | |
33 | 'FailFast', 'FaultTolerance', 'BaseProtocol', 'NoVerifyHTTPAdapter', 'TLSClientAuth', | |
34 | 34 | 'OofSettings', |
35 | 35 | 'Q', |
36 | 'BASIC', 'DIGEST', 'NTLM', 'GSSAPI', 'SSPI', 'OAUTH2', | |
36 | 'BASIC', 'DIGEST', 'NTLM', 'GSSAPI', 'SSPI', 'OAUTH2', 'CBA', | |
37 | 37 | 'Build', 'Version', |
38 | 38 | ] |
39 | ||
40 | # Set a default user agent, e.g. "exchangelib/3.1.1 (python-requests/2.22.0)" | |
41 | import requests.utils | |
42 | BaseProtocol.USERAGENT = "%s/%s (%s)" % (__name__, __version__, requests.utils.default_user_agent()) | |
39 | 43 | |
40 | 44 | |
41 | 45 | def close_connections(): |
14 | 14 | Directory, Drafts, Favorites, IMContactList, Inbox, Journal, JunkEmail, LocalFailures, MsgFolderRoot, MyContacts, \ |
15 | 15 | Notes, Outbox, PeopleConnect, PublicFoldersRoot, QuickContacts, RecipientCache, RecoverableItemsDeletions, \ |
16 | 16 | RecoverableItemsPurges, RecoverableItemsRoot, RecoverableItemsVersions, Root, SearchFolders, SentItems, \ |
17 | ServerFailures, SyncIssues, Tasks, ToDoSearch, VoiceMail, BaseFolder | |
18 | from .items import Item, BulkCreateResult, HARD_DELETE, \ | |
19 | AUTO_RESOLVE, SEND_TO_NONE, SAVE_ONLY, SEND_AND_SAVE_COPY, SEND_ONLY, ALL_OCCURRENCIES, \ | |
20 | DELETE_TYPE_CHOICES, MESSAGE_DISPOSITION_CHOICES, CONFLICT_RESOLUTION_CHOICES, AFFECTED_TASK_OCCURRENCES_CHOICES, \ | |
21 | SEND_MEETING_INVITATIONS_CHOICES, SEND_MEETING_INVITATIONS_AND_CANCELLATIONS_CHOICES, \ | |
22 | SEND_MEETING_CANCELLATIONS_CHOICES, ID_ONLY | |
23 | from .properties import Mailbox, SendingAs, FolderId, DistinguishedFolderId | |
17 | ServerFailures, SyncIssues, Tasks, ToDoSearch, VoiceMail | |
18 | from .items import Item, BulkCreateResult, HARD_DELETE, AUTO_RESOLVE, SEND_TO_NONE, SAVE_ONLY, ALL_OCCURRENCIES, ID_ONLY | |
19 | from .properties import Mailbox, SendingAs | |
24 | 20 | from .protocol import Protocol |
25 | 21 | from .queryset import QuerySet |
26 | 22 | from .services import ExportItems, UploadItems, GetItem, CreateItem, UpdateItem, DeleteItem, MoveItem, SendItem, \ |
27 | 23 | CopyItem, GetUserOofSettings, SetUserOofSettings, GetMailTips, ArchiveItem, GetDelegate |
28 | from .settings import OofSettings | |
29 | 24 | from .util import get_domain, peek |
30 | 25 | |
31 | 26 | log = getLogger(__name__) |
32 | 27 | |
33 | 28 | |
29 | class Identity: | |
30 | """Contains information that uniquely identifies an account. Currently only used for SOAP impersonation headers. | |
31 | """ | |
32 | def __init__(self, primary_smtp_address=None, smtp_address=None, upn=None, sid=None): | |
33 | """ | |
34 | :param primary_smtp_address: The primary email address associated with the account | |
35 | :param smtp_address: The (non-)primary email address associated with the account | |
36 | :param: upn: The User Principal Name (UPN) of this account | |
37 | :param: sid: The security identifier (SID) of this account, in security descriptor definition language (SDDL) | |
38 | form. | |
39 | """ | |
40 | self.primary_smtp_address = primary_smtp_address | |
41 | self.smtp_address = smtp_address | |
42 | self.upn = upn | |
43 | self.sid = sid | |
44 | ||
45 | def __eq__(self, other): | |
46 | for k in self.__dict__.keys(): | |
47 | if getattr(self, k) != getattr(other, k): | |
48 | return False | |
49 | return True | |
50 | ||
51 | def __hash__(self): | |
52 | return hash(repr(self)) | |
53 | ||
54 | def __repr__(self): | |
55 | return self.__class__.__name__ + repr((self.primary_smtp_address, self.smtp_address, self.upn, self.sid)) | |
56 | ||
57 | ||
34 | 58 | class Account: |
35 | """Models an Exchange server user account. The primary key for an account is its PrimarySMTPAddress | |
59 | """Models an Exchange server user account. | |
36 | 60 | """ |
37 | 61 | def __init__(self, primary_smtp_address, fullname=None, access_type=None, autodiscover=False, credentials=None, |
38 | 62 | config=None, locale=None, default_timezone=None): |
84 | 108 | self.ad_response, self.protocol = discover( |
85 | 109 | email=primary_smtp_address, credentials=credentials, auth_type=auth_type, retry_policy=retry_policy |
86 | 110 | ) |
87 | self.primary_smtp_address = self.ad_response.autodiscover_smtp_address | |
111 | primary_smtp_address = self.ad_response.autodiscover_smtp_address | |
88 | 112 | else: |
89 | 113 | if not config: |
90 | 114 | raise AttributeError('non-autodiscover requires a config') |
91 | self.primary_smtp_address = primary_smtp_address | |
115 | primary_smtp_address = primary_smtp_address | |
92 | 116 | self.ad_response = None |
93 | 117 | self.protocol = Protocol(config=config) |
118 | ||
119 | # Other ways of identifying the account can be added later | |
120 | self.identity = Identity(primary_smtp_address=primary_smtp_address) | |
121 | ||
94 | 122 | # We may need to override the default server version on a per-account basis because Microsoft may report one |
95 | 123 | # server version up-front but delegate account requests to an older backend server. |
96 | 124 | self.version = self.protocol.version |
97 | 125 | log.debug('Added account: %s', self) |
126 | ||
127 | @property | |
128 | def primary_smtp_address(self): | |
129 | return self.identity.primary_smtp_address | |
98 | 130 | |
99 | 131 | @threaded_cached_property |
100 | 132 | def admin_audit_logs(self): |
284 | 316 | |
285 | 317 | @oof_settings.setter |
286 | 318 | def oof_settings(self, value): |
287 | if not isinstance(value, OofSettings): | |
288 | raise ValueError("'value' %r must be an OofSettings instance" % value) | |
289 | SetUserOofSettings(account=self).call( | |
319 | SetUserOofSettings(account=self).get( | |
320 | oof_settings=value, | |
290 | 321 | mailbox=Mailbox(email_address=self.primary_smtp_address), |
291 | oof_settings=value, | |
292 | 322 | ) |
293 | 323 | |
294 | 324 | def _consume_item_service(self, service_cls, items, chunk_size, kwargs): |
333 | 363 | (account.calendar, "ABCXYZ...")]) |
334 | 364 | -> [("idA", "changekey"), ("idB", "changekey"), ("idC", "changekey")] |
335 | 365 | """ |
336 | is_empty, data = peek(data) | |
337 | if is_empty: | |
338 | # We accept generators, so it's not always convenient for caller to know up-front if 'upload_data' is empty. | |
339 | # Allow empty 'upload_data' and return early. | |
340 | return [] | |
341 | return list(UploadItems(account=self, chunk_size=chunk_size).call(data=data)) | |
366 | return list( | |
367 | self._consume_item_service(service_cls=UploadItems, items=data, chunk_size=chunk_size, kwargs=dict()) | |
368 | ) | |
342 | 369 | |
343 | 370 | def bulk_create(self, folder, items, message_disposition=SAVE_ONLY, send_meeting_invitations=SEND_TO_NONE, |
344 | 371 | chunk_size=None): |
355 | 382 | BulkCreateResult objects are normal Item objects except they only contain the 'id' and 'changekey' |
356 | 383 | of the created item, and the 'id' of any attachments that were also created. |
357 | 384 | """ |
358 | if message_disposition not in MESSAGE_DISPOSITION_CHOICES: | |
359 | raise ValueError("'message_disposition' %s must be one of %s" % ( | |
360 | message_disposition, MESSAGE_DISPOSITION_CHOICES | |
361 | )) | |
362 | if send_meeting_invitations not in SEND_MEETING_INVITATIONS_CHOICES: | |
363 | raise ValueError("'send_meeting_invitations' %s must be one of %s" % ( | |
364 | send_meeting_invitations, SEND_MEETING_INVITATIONS_CHOICES | |
365 | )) | |
366 | if folder is not None: | |
367 | if not isinstance(folder, BaseFolder): | |
368 | raise ValueError("'folder' %r must be a Folder instance" % folder) | |
369 | if folder.account != self: | |
370 | raise ValueError('"Folder must belong to this account') | |
371 | if message_disposition == SAVE_ONLY and folder is None: | |
372 | raise AttributeError("Folder must be supplied when in save-only mode") | |
373 | if message_disposition == SEND_AND_SAVE_COPY and folder is None: | |
374 | folder = self.sent # 'Sent' is default EWS behaviour | |
375 | if message_disposition == SEND_ONLY and folder is not None: | |
376 | raise AttributeError("Folder must be None in send-ony mode") | |
377 | 385 | if isinstance(items, QuerySet): |
378 | 386 | # bulk_create() on a queryset does not make sense because it returns items that have already been created |
379 | 387 | raise ValueError('Cannot bulk create items from a QuerySet') |
412 | 420 | |
413 | 421 | :return: a list of either (id, changekey) tuples or exception instances, in the same order as the input |
414 | 422 | """ |
415 | if conflict_resolution not in CONFLICT_RESOLUTION_CHOICES: | |
416 | raise ValueError("'conflict_resolution' %s must be one of %s" % ( | |
417 | conflict_resolution, CONFLICT_RESOLUTION_CHOICES | |
418 | )) | |
419 | if message_disposition not in MESSAGE_DISPOSITION_CHOICES: | |
420 | raise ValueError("'message_disposition' %s must be one of %s" % ( | |
421 | message_disposition, MESSAGE_DISPOSITION_CHOICES | |
422 | )) | |
423 | if send_meeting_invitations_or_cancellations not in SEND_MEETING_INVITATIONS_AND_CANCELLATIONS_CHOICES: | |
424 | raise ValueError("'send_meeting_invitations_or_cancellations' %s must be one of %s" % ( | |
425 | send_meeting_invitations_or_cancellations, SEND_MEETING_INVITATIONS_AND_CANCELLATIONS_CHOICES | |
426 | )) | |
427 | if suppress_read_receipts not in (True, False): | |
428 | raise ValueError("'suppress_read_receipts' %s must be True or False" % suppress_read_receipts) | |
429 | if message_disposition == SEND_ONLY: | |
430 | raise ValueError('Cannot send-only existing objects. Use SendItem service instead') | |
431 | 423 | # bulk_update() on a queryset does not make sense because there would be no opportunity to alter the items. In |
432 | 424 | # fact, it could be dangerous if the queryset contains an '.only()'. This would wipe out certain fields |
433 | 425 | # entirely. |
466 | 458 | |
467 | 459 | :return: a list of either True or exception instances, in the same order as the input |
468 | 460 | """ |
469 | if delete_type not in DELETE_TYPE_CHOICES: | |
470 | raise ValueError("'delete_type' %s must be one of %s" % ( | |
471 | delete_type, DELETE_TYPE_CHOICES | |
472 | )) | |
473 | if send_meeting_cancellations not in SEND_MEETING_CANCELLATIONS_CHOICES: | |
474 | raise ValueError("'send_meeting_cancellations' %s must be one of %s" % ( | |
475 | send_meeting_cancellations, SEND_MEETING_CANCELLATIONS_CHOICES | |
476 | )) | |
477 | if affected_task_occurrences not in AFFECTED_TASK_OCCURRENCES_CHOICES: | |
478 | raise ValueError("'affected_task_occurrences' %s must be one of %s" % ( | |
479 | affected_task_occurrences, AFFECTED_TASK_OCCURRENCES_CHOICES | |
480 | )) | |
481 | if suppress_read_receipts not in (True, False): | |
482 | raise ValueError("'suppress_read_receipts' %s must be True or False" % suppress_read_receipts) | |
483 | 461 | log.debug( |
484 | 462 | 'Deleting items for %s (delete_type: %s, send_meeting_invitations: %s, affected_task_occurences: %s)', |
485 | 463 | self, |
509 | 487 | raise AttributeError("'save_copy' must be True when 'copy_to_folder' is set") |
510 | 488 | if save_copy and not copy_to_folder: |
511 | 489 | copy_to_folder = self.sent # 'Sent' is default EWS behaviour |
512 | if copy_to_folder and not isinstance(copy_to_folder, BaseFolder): | |
513 | raise ValueError("'copy_to_folder' %r must be a Folder instance" % copy_to_folder) | |
514 | 490 | return list( |
515 | 491 | self._consume_item_service(service_cls=SendItem, items=ids, chunk_size=chunk_size, kwargs=dict( |
516 | 492 | saved_item_folder=copy_to_folder, |
525 | 501 | :param chunk_size: The number of items to send to the server in a single request |
526 | 502 | :return: Status for each send operation, in the same order as the input |
527 | 503 | """ |
528 | if not isinstance(to_folder, BaseFolder): | |
529 | raise ValueError("'to_folder' %r must be a Folder instance" % to_folder) | |
530 | 504 | return list( |
531 | 505 | i if isinstance(i, Exception) else Item.id_from_xml(i) |
532 | 506 | for i in self._consume_item_service(service_cls=CopyItem, items=ids, chunk_size=chunk_size, kwargs=dict( |
543 | 517 | :return: The new IDs of the moved items, in the same order as the input. If 'to_folder' is a public folder or a |
544 | 518 | folder in a different mailbox, an empty list is returned. |
545 | 519 | """ |
546 | if not isinstance(to_folder, BaseFolder): | |
547 | raise ValueError("'to_folder' %r must be a Folder instance" % to_folder) | |
548 | 520 | return list( |
549 | 521 | i if isinstance(i, Exception) else Item.id_from_xml(i) |
550 | 522 | for i in self._consume_item_service(service_cls=MoveItem, items=ids, chunk_size=chunk_size, kwargs=dict( |
561 | 533 | :param chunk_size: The number of items to send to the server in a single request |
562 | 534 | :return: A list containing True or an exception instance in stable order of the requested items |
563 | 535 | """ |
564 | if not isinstance(to_folder, (BaseFolder, FolderId, DistinguishedFolderId)): | |
565 | raise ValueError("'to_folder' %r must be a Folder or FolderId instance" % to_folder) | |
566 | 536 | return list(self._consume_item_service(service_cls=ArchiveItem, items=ids, chunk_size=chunk_size, kwargs=dict( |
567 | 537 | to_folder=to_folder, |
568 | 538 | )) |
589 | 559 | else: |
590 | 560 | for field in only_fields: |
591 | 561 | validation_folder.validate_item_field(field=field, version=self.version) |
592 | additional_fields = validation_folder.normalize_fields(fields=only_fields) | |
562 | # Remove ItemId and ChangeKey. We get them unconditionally | |
563 | additional_fields = {f for f in validation_folder.normalize_fields(fields=only_fields) | |
564 | if not f.field.is_attribute} | |
593 | 565 | # Always use IdOnly here, because AllProperties doesn't actually get *all* properties |
594 | 566 | for i in self._consume_item_service(service_cls=GetItem, items=ids, chunk_size=chunk_size, kwargs=dict( |
595 | 567 | additional_fields=additional_fields, |
606 | 578 | """See self.oof_settings about caching considerations |
607 | 579 | """ |
608 | 580 | # mail_tips_requested must be one of properties.MAIL_TIPS_TYPES |
609 | res = list(GetMailTips(protocol=self.protocol).call( | |
581 | return GetMailTips(protocol=self.protocol).get( | |
610 | 582 | sending_as=SendingAs(email_address=self.primary_smtp_address), |
611 | 583 | recipients=[Mailbox(email_address=self.primary_smtp_address)], |
612 | 584 | mail_tips_requested='All', |
613 | )) | |
614 | if len(res) != 1: | |
615 | raise ValueError('Expected result length 1, but got %s' % res) | |
616 | if isinstance(res[0], Exception): | |
617 | raise res[0] | |
618 | return res[0] | |
585 | ) | |
619 | 586 | |
620 | 587 | @property |
621 | 588 | def delegates(self): |
3 | 3 | |
4 | 4 | from .fields import BooleanField, TextField, IntegerField, URIField, DateTimeField, EWSElementField, Base64Field, \ |
5 | 5 | ItemField, IdField |
6 | from .properties import RootItemId, EWSElement | |
6 | from .properties import RootItemId, EWSElement, Fields | |
7 | 7 | from .services import GetAttachment, CreateAttachment, DeleteAttachment |
8 | 8 | |
9 | 9 | log = logging.getLogger(__name__) |
19 | 19 | ID_ATTR = 'Id' |
20 | 20 | ROOT_ID_ATTR = 'RootItemId' |
21 | 21 | ROOT_CHANGEKEY_ATTR = 'RootItemChangeKey' |
22 | FIELDS = [ | |
22 | FIELDS = Fields( | |
23 | 23 | IdField('id', field_uri=ID_ATTR, is_required=True), |
24 | 24 | IdField('root_id', field_uri=ROOT_ID_ATTR), |
25 | 25 | IdField('root_changekey', field_uri=ROOT_CHANGEKEY_ATTR), |
26 | ] | |
26 | ) | |
27 | 27 | |
28 | 28 | __slots__ = tuple(f.name for f in FIELDS) |
29 | 29 | |
31 | 31 | class Attachment(EWSElement): |
32 | 32 | """Base class for FileAttachment and ItemAttachment |
33 | 33 | """ |
34 | FIELDS = [ | |
34 | FIELDS = Fields( | |
35 | 35 | EWSElementField('attachment_id', value_cls=AttachmentId), |
36 | 36 | TextField('name', field_uri='Name'), |
37 | 37 | TextField('content_type', field_uri='ContentType'), |
40 | 40 | IntegerField('size', field_uri='Size', is_read_only=True), # Attachment size in bytes |
41 | 41 | DateTimeField('last_modified_time', field_uri='LastModifiedTime'), |
42 | 42 | BooleanField('is_inline', field_uri='IsInline'), |
43 | ] | |
43 | ) | |
44 | 44 | |
45 | 45 | __slots__ = tuple(f.name for f in FIELDS) + ('parent_item',) |
46 | 46 | |
123 | 123 | MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/fileattachment |
124 | 124 | """ |
125 | 125 | ELEMENT_NAME = 'FileAttachment' |
126 | FIELDS = Attachment.FIELDS + [ | |
126 | FIELDS = Attachment.FIELDS + Fields( | |
127 | 127 | BooleanField('is_contact_photo', field_uri='IsContactPhoto'), |
128 | 128 | Base64Field('_content', field_uri='Content'), |
129 | ] | |
129 | ) | |
130 | 130 | |
131 | 131 | __slots__ = ('is_contact_photo', '_content', '_fp') |
132 | 132 | |
199 | 199 | """ |
200 | 200 | ELEMENT_NAME = 'ItemAttachment' |
201 | 201 | # noinspection PyTypeChecker |
202 | FIELDS = Attachment.FIELDS + [ | |
202 | FIELDS = Attachment.FIELDS + Fields( | |
203 | 203 | ItemField('_item', field_uri='Item'), |
204 | ] | |
204 | ) | |
205 | 205 | |
206 | 206 | __slots__ = ('_item',) |
207 | 207 |
5 | 5 | import dns.resolver |
6 | 6 | |
7 | 7 | from ..configuration import Configuration |
8 | from ..credentials import OAuth2Credentials | |
8 | 9 | from ..errors import AutoDiscoverFailed, AutoDiscoverCircularRedirect, TransportError, RedirectError, UnauthorizedError |
9 | 10 | from ..protocol import Protocol, FailFast |
10 | from ..transport import get_auth_method_from_response, DEFAULT_HEADERS, NOAUTH | |
11 | from ..transport import get_auth_method_from_response, DEFAULT_HEADERS, NOAUTH, OAUTH2, CREDENTIALS_REQUIRED | |
11 | 12 | from ..util import post_ratelimited, get_domain, get_redirect_url, _back_off_if_needed, _may_retry_on_error, \ |
12 | 13 | is_valid_hostname, DummyResponse, CONNECTION_ERRORS, TLS_ERRORS |
13 | 14 | from ..version import Version |
74 | 75 | self._emails_visited = [] # Collects Autodiscover email redirects |
75 | 76 | |
76 | 77 | def discover(self): |
77 | self._emails_visited.append(self.email) | |
78 | self._emails_visited.append(self.email.lower()) | |
78 | 79 | |
79 | 80 | # Check the autodiscover cache to see if we already know the autodiscover service endpoint for this email |
80 | 81 | # domain. Use a lock to guard against multiple threads competing to cache information. |
135 | 136 | # Get the server version. Not all protocol entries have a server version so we cheat a bit and also look at the |
136 | 137 | # other ones that point to the same endpoint. |
137 | 138 | for protocol in ad_response.account.protocols: |
138 | if protocol.ews_url.lower() == ews_url.lower() and protocol.server_version: | |
139 | if not protocol.ews_url or not protocol.server_version: | |
140 | continue | |
141 | if protocol.ews_url.lower() == ews_url.lower(): | |
139 | 142 | version = Version(build=protocol.server_version) |
140 | 143 | break |
141 | 144 | else: |
227 | 230 | with AutodiscoverProtocol.raw_session() as s: |
228 | 231 | try: |
229 | 232 | r = getattr(s, method)(**kwargs) |
233 | r.close() # Release memory | |
230 | 234 | break |
231 | 235 | except TLS_ERRORS as e: |
232 | 236 | # Don't retry on TLS errors. They will most likely be persistent. |
267 | 271 | try: |
268 | 272 | session = protocol.get_session() |
269 | 273 | r, session = post_ratelimited(protocol=protocol, session=session, url=protocol.service_endpoint, |
270 | headers=headers, data=data, allow_redirects=False) | |
274 | headers=headers, data=data, allow_redirects=False, stream=False) | |
271 | 275 | protocol.release_session(session) |
272 | 276 | except UnauthorizedError as e: |
273 | 277 | # It's entirely possible for the endpoint to ask for login. We should continue if login fails because this |
285 | 289 | log.debug('Attempting to get a valid response from %s', url) |
286 | 290 | try: |
287 | 291 | auth_type, r = self._get_unauthenticated_response(url=url) |
292 | if isinstance(self.credentials, OAuth2Credentials): | |
293 | # This type of credentials *must* use the OAuth auth type | |
294 | auth_type = OAUTH2 | |
295 | elif self.credentials is None and auth_type in CREDENTIALS_REQUIRED: | |
296 | raise ValueError('Auth type %r was detected but no credentials were provided' % auth_type) | |
288 | 297 | ad_protocol = AutodiscoverProtocol( |
289 | 298 | config=Configuration( |
290 | 299 | service_endpoint=url, |
0 | 0 | from ..errors import ErrorNonExistentMailbox, AutoDiscoverFailed |
1 | 1 | from ..fields import TextField, EmailAddressField, ChoiceField, Choice, EWSElementField, OnOffField, BooleanField, \ |
2 | 2 | IntegerField, BuildField, ProtocolListField |
3 | from ..properties import EWSElement | |
3 | from ..properties import EWSElement, Fields | |
4 | 4 | from ..transport import DEFAULT_ENCODING |
5 | 5 | from ..util import create_element, add_xml_child, to_xml, is_xml, xml_to_str, AUTODISCOVER_REQUEST_NS, \ |
6 | 6 | AUTODISCOVER_BASE_NS, AUTODISCOVER_RESPONSE_NS as RNS, ParseError |
13 | 13 | class User(AutodiscoverBase): |
14 | 14 | """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/user-pox""" |
15 | 15 | ELEMENT_NAME = 'User' |
16 | FIELDS = [ | |
16 | FIELDS = Fields( | |
17 | 17 | TextField('display_name', field_uri='DisplayName', namespace=RNS), |
18 | 18 | TextField('legacy_dn', field_uri='LegacyDN', namespace=RNS), |
19 | 19 | TextField('deployment_id', field_uri='DeploymentId', namespace=RNS), # GUID format |
20 | 20 | EmailAddressField('autodiscover_smtp_address', field_uri='AutoDiscoverSMTPAddress', namespace=RNS), |
21 | ] | |
21 | ) | |
22 | 22 | __slots__ = tuple(f.name for f in FIELDS) |
23 | 23 | |
24 | 24 | |
25 | 25 | class IntExtUrlBase(AutodiscoverBase): |
26 | FIELDS = [ | |
26 | FIELDS = Fields( | |
27 | 27 | TextField('external_url', field_uri='ExternalUrl', namespace=RNS), |
28 | 28 | TextField('internal_url', field_uri='InternalUrl', namespace=RNS), |
29 | ] | |
29 | ) | |
30 | 30 | __slots__ = tuple(f.name for f in FIELDS) |
31 | 31 | |
32 | 32 | |
44 | 44 | class NetworkRequirements(AutodiscoverBase): |
45 | 45 | """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/networkrequirements-pox""" |
46 | 46 | ELEMENT_NAME = 'NetworkRequirements' |
47 | FIELDS = [ | |
47 | FIELDS = Fields( | |
48 | 48 | TextField('ipv4_start', field_uri='IPv4Start', namespace=RNS), |
49 | 49 | TextField('ipv4_end', field_uri='IPv4End', namespace=RNS), |
50 | 50 | TextField('ipv6_start', field_uri='IPv6Start', namespace=RNS), |
51 | 51 | TextField('ipv6_end', field_uri='IPv6End', namespace=RNS), |
52 | ] | |
52 | ) | |
53 | 53 | __slots__ = tuple(f.name for f in FIELDS) |
54 | 54 | |
55 | 55 | |
59 | 59 | Used for the 'Internal' and 'External' elements that may contain a stripped-down version of the Protocol element. |
60 | 60 | """ |
61 | 61 | ELEMENT_NAME = 'Protocol' |
62 | FIELDS = [ | |
62 | FIELDS = Fields( | |
63 | 63 | ChoiceField('type', field_uri='Type', choices={ |
64 | 64 | Choice('WEB'), Choice('EXCH'), Choice('EXPR'), Choice('EXHTTP') |
65 | 65 | }, namespace=RNS), |
66 | 66 | TextField('as_url', field_uri='ASUrl', namespace=RNS), |
67 | ] | |
67 | ) | |
68 | 68 | __slots__ = tuple(f.name for f in FIELDS) |
69 | 69 | |
70 | 70 | |
71 | 71 | class IntExtBase(AutodiscoverBase): |
72 | FIELDS = [ | |
72 | FIELDS = Fields( | |
73 | 73 | # TODO: 'OWAUrl' also has an AuthenticationMethod enum-style XML attribute |
74 | 74 | TextField('owa_url', field_uri='OWAUrl', namespace=RNS), |
75 | 75 | EWSElementField('protocol', value_cls=SimpleProtocol), |
76 | ] | |
76 | ) | |
77 | ||
77 | 78 | __slots__ = tuple(f.name for f in FIELDS) |
78 | 79 | |
79 | 80 | |
93 | 94 | """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/protocol-pox""" |
94 | 95 | ELEMENT_NAME = 'Protocol' |
95 | 96 | TYPES = ('WEB', 'EXCH', 'EXPR', 'EXHTTP') |
96 | FIELDS = [ | |
97 | FIELDS = Fields( | |
97 | 98 | # Attribute 'Type' is ignored here. Has a name conflict with the child element and does not seem useful. |
98 | 99 | TextField('version', field_uri='Version', is_attribute=True, namespace=RNS), |
99 | 100 | ChoiceField('type', field_uri='Type', namespace=RNS, choices={Choice(p) for p in TYPES}), |
144 | 145 | EWSElementField('network_requirements', value_cls=NetworkRequirements), |
145 | 146 | EWSElementField('address_book', value_cls=AddressBook), |
146 | 147 | EWSElementField('mail_store', value_cls=MailStore), |
147 | ] | |
148 | ) | |
149 | ||
148 | 150 | __slots__ = tuple(f.name for f in FIELDS) |
149 | 151 | |
150 | 152 | @property |
151 | 153 | def auth_type(self): |
152 | 154 | # Translates 'auth_package' value to our own 'auth_type' enum vals |
153 | from ..transport import NOAUTH, NTLM, BASIC, GSSAPI, SSPI | |
155 | from ..transport import NOAUTH, NTLM, BASIC, GSSAPI, SSPI, CBA | |
154 | 156 | if not self.auth_required: |
155 | 157 | return NOAUTH |
156 | 158 | return { |
159 | 161 | 'kerb': GSSAPI, |
160 | 162 | 'kerbntlm': NTLM, # Means client can chose between NTLM and GSSAPI |
161 | 163 | 'ntlm': NTLM, |
162 | # 'certificate' is not supported by us | |
164 | 'certificate': CBA, | |
163 | 165 | 'negotiate': SSPI, # Unsure about this one |
164 | 166 | 'nego2': GSSAPI, |
165 | 167 | 'anonymous': NOAUTH, # Seen in some docs even though it's not mentioned in MSDN |
170 | 172 | """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/error-pox""" |
171 | 173 | ELEMENT_NAME = 'Error' |
172 | 174 | NAMESPACE = AUTODISCOVER_BASE_NS |
173 | FIELDS = [ | |
175 | FIELDS = Fields( | |
174 | 176 | TextField('id', field_uri='Id', namespace=AUTODISCOVER_BASE_NS, is_attribute=True), |
175 | 177 | TextField('time', field_uri='Time', namespace=AUTODISCOVER_BASE_NS, is_attribute=True), |
176 | 178 | TextField('code', field_uri='ErrorCode', namespace=AUTODISCOVER_BASE_NS), |
177 | 179 | TextField('message', field_uri='Message', namespace=AUTODISCOVER_BASE_NS), |
178 | 180 | TextField('debug_data', field_uri='DebugData', namespace=AUTODISCOVER_BASE_NS), |
179 | ] | |
181 | ) | |
182 | ||
180 | 183 | __slots__ = tuple(f.name for f in FIELDS) |
181 | 184 | |
182 | 185 | |
187 | 190 | REDIRECT_ADDR = 'redirectAddr' |
188 | 191 | SETTINGS = 'settings' |
189 | 192 | ACTIONS = (REDIRECT_URL, REDIRECT_ADDR, SETTINGS) |
190 | FIELDS = [ | |
193 | FIELDS = Fields( | |
191 | 194 | ChoiceField('type', field_uri='AccountType', namespace=RNS, choices={Choice('email')}), |
192 | 195 | ChoiceField('action', field_uri='Action', namespace=RNS, choices={Choice(p) for p in ACTIONS}), |
193 | 196 | BooleanField('microsoft_online', field_uri='MicrosoftOnline', namespace=RNS), |
198 | 201 | ProtocolListField('protocols'), |
199 | 202 | # 'SmtpAddress' is inside the 'PublicFolderInformation' element |
200 | 203 | TextField('public_folder_smtp_address', field_uri='SmtpAddress', namespace=RNS), |
201 | ] | |
204 | ) | |
205 | ||
202 | 206 | __slots__ = tuple(f.name for f in FIELDS) |
203 | 207 | |
204 | 208 | @classmethod |
219 | 223 | class Response(AutodiscoverBase): |
220 | 224 | """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/response-pox""" |
221 | 225 | ELEMENT_NAME = 'Response' |
222 | FIELDS = [ | |
226 | FIELDS = Fields( | |
223 | 227 | EWSElementField('user', value_cls=User), |
224 | 228 | EWSElementField('account', value_cls=Account), |
225 | ] | |
229 | ) | |
230 | ||
226 | 231 | __slots__ = tuple(f.name for f in FIELDS) |
227 | 232 | |
228 | 233 | @property |
274 | 279 | """ |
275 | 280 | ELEMENT_NAME = 'Response' |
276 | 281 | NAMESPACE = AUTODISCOVER_BASE_NS |
277 | FIELDS = [ | |
282 | FIELDS = Fields( | |
278 | 283 | EWSElementField('error', value_cls=Error), |
279 | ] | |
284 | ) | |
285 | ||
280 | 286 | __slots__ = tuple(f.name for f in FIELDS) |
281 | 287 | |
282 | 288 | |
283 | 289 | class Autodiscover(EWSElement): |
284 | 290 | ELEMENT_NAME = 'Autodiscover' |
285 | 291 | NAMESPACE = AUTODISCOVER_BASE_NS |
286 | FIELDS = [ | |
292 | FIELDS = Fields( | |
287 | 293 | EWSElementField('response', value_cls=Response), |
288 | 294 | EWSElementField('error_response', value_cls=ErrorResponse), |
289 | ] | |
295 | ) | |
296 | ||
290 | 297 | __slots__ = tuple(f.name for f in FIELDS) |
291 | 298 | |
292 | 299 | @staticmethod |
11 | 11 | |
12 | 12 | |
13 | 13 | class Configuration: |
14 | """ | |
15 | Assembles a connection protocol when autodiscover is not used. | |
14 | """Contains information needed to create an authenticated connection to an EWS endpoint. | |
16 | 15 | |
17 | If the server is not configured with autodiscover, the following should be sufficient: | |
16 | The 'credentials' argument contains the credentials needed to authenticate with the server. Multiple credentials | |
17 | implementations are available in 'exchangelib.credentials'. | |
18 | 18 | |
19 | config = Configuration(server='example.com', credentials=Credentials('MYWINDOMAIN\\myusername', 'topsecret')) | |
20 | account = Account(primary_smtp_address='john@example.com', config=config) | |
19 | config = Configuration(credentials=Credentials('john@example.com', 'MY_SECRET'), ...) | |
21 | 20 | |
22 | You can also set the EWS service endpoint directly: | |
21 | The 'server' and 'service_endpoint' arguments are mutually exclusive. The former must contain only a domain name, | |
22 | the latter a full URL: | |
23 | 23 | |
24 | config = Configuration(service_endpoint='https://mail.example.com/EWS/Exchange.asmx', credentials=...) | |
24 | config = Configuration(server='example.com', ...) | |
25 | config = Configuration(service_endpoint='https://mail.example.com/EWS/Exchange.asmx', ...) | |
25 | 26 | |
26 | If you know which authentication type the server uses, you add that as a hint: | |
27 | If you know which authentication type the server uses, you add that as a hint in 'auth_type'. Likewise, you can | |
28 | add the server version as a hint. This allows to skip the auth type and version guessing routines: | |
27 | 29 | |
28 | config = Configuration(service_endpoint='https://example.com/EWS/Exchange.asmx', auth_type=NTLM, credentials=..) | |
30 | config = Configuration(auth_type=NTLM, ...) | |
31 | config = Configuration(version=Version(build=Build(15, 1, 2, 3)), ...) | |
29 | 32 | |
30 | If you want to use autodiscover, don't use a Configuration object. Instead, set up an account like this: | |
33 | Finally, you can use 'retry_policy' to define a custom retry policy for handling server connection failures: | |
31 | 34 | |
32 | credentials = Credentials(username='MYWINDOMAIN\\myusername', password='topsecret') | |
33 | account = Account(primary_smtp_address='john@example.com', credentials=credentials, autodiscover=True) | |
34 | ||
35 | config = Configuration(retry_policy=FaultTolerance(max_wait=3600), ...) | |
35 | 36 | """ |
36 | 37 | def __init__(self, credentials=None, server=None, service_endpoint=None, auth_type=None, version=None, |
37 | 38 | retry_policy=None): |
64 | 65 | |
65 | 66 | @threaded_cached_property |
66 | 67 | def server(self): |
68 | if not self.service_endpoint: | |
69 | return None | |
67 | 70 | return split_url(self.service_endpoint)[1] |
68 | 71 | |
69 | 72 | def __repr__(self): |
121 | 121 | :param client_id: ID of an authorized OAuth application |
122 | 122 | :param client_secret: Secret associated with the OAuth application |
123 | 123 | :param tenant_id: Microsoft tenant ID of the account to access |
124 | """ | |
125 | ||
126 | def __init__(self, client_id, client_secret, tenant_id): | |
124 | :param identity: An Identity object representing the account that these | |
125 | credentials are connected to. | |
126 | """ | |
127 | ||
128 | def __init__(self, client_id, client_secret, tenant_id, identity=None): | |
127 | 129 | super().__init__() |
128 | 130 | self.client_id = client_id |
129 | 131 | self.client_secret = client_secret |
130 | 132 | self.tenant_id = tenant_id |
133 | self.identity = identity | |
134 | # When set, access_token is a dict (or an oauthlib.oauth2.OAuth2Token, which is also a dict) | |
135 | self.access_token = None | |
131 | 136 | |
132 | 137 | def refresh(self, session): |
133 | 138 | # Creating a new session gets a new access token, so there's no |
147 | 152 | """ |
148 | 153 | # Ensure we don't update the object in the middle of a new session |
149 | 154 | # being created, which could cause a race |
155 | if not isinstance(access_token, dict): | |
156 | raise ValueError("'access_token' must be an OAuth2Token") | |
150 | 157 | with self.lock: |
151 | 158 | self.access_token = access_token |
152 | 159 | |
153 | 160 | def _get_hash_values(self): |
154 | # access_token is a dict (or an oauthlib.oauth2.OAuth2Token, | |
155 | # which is also a dict) and isn't hashable. Extract its | |
156 | # access_token field, which is the important one. | |
157 | return ( | |
158 | getattr(self, k) if k != 'access_token' else self.access_token['access_token'] | |
159 | for k in self.__dict__.keys() if k != '_lock' | |
160 | ) | |
161 | # 'access_token' may be refreshed once in a while. This should not affect the hash signature. | |
162 | # 'identity' is just informational and should also not affect the hash signature. | |
163 | return (getattr(self, k) for k in self.__dict__.keys() if k not in ('_lock', 'identity', 'access_token')) | |
164 | ||
165 | def sig(self): | |
166 | # Like hash(self), but pulls in the access token. Protocol.refresh_credentials() uses this to find out | |
167 | # if the access_token needs to be refreshed. | |
168 | res = [] | |
169 | for k in self.__dict__.keys(): | |
170 | if k in ('_lock', 'identity'): | |
171 | continue | |
172 | if k == 'access_token': | |
173 | res.append(self.access_token['access_token'] if self.access_token else None) | |
174 | continue | |
175 | res.append(getattr(self, k)) | |
176 | return hash(tuple(res)) | |
161 | 177 | |
162 | 178 | def __repr__(self): |
163 | 179 | return self.__class__.__name__ + repr((self.client_id, '********')) |
199 | 215 | def __init__(self, client_id=None, client_secret=None, authorization_code=None, access_token=None): |
200 | 216 | super().__init__(client_id, client_secret, tenant_id=None) |
201 | 217 | self.authorization_code = authorization_code |
218 | if access_token is not None and not isinstance(access_token, dict): | |
219 | raise ValueError("'access_token' must be an OAuth2Token") | |
202 | 220 | self.access_token = access_token |
203 | 221 | |
204 | 222 | def __repr__(self): |
53 | 53 | |
54 | 54 | @classmethod |
55 | 55 | def from_date(cls, d): |
56 | if d.__class__ != datetime.date: | |
56 | if type(d) != datetime.date: | |
57 | 57 | raise ValueError("%r must be a date instance" % d) |
58 | 58 | return cls(d.year, d.month, d.day) |
59 | 59 | |
109 | 109 | |
110 | 110 | @classmethod |
111 | 111 | def from_datetime(cls, d): |
112 | if d.__class__ != datetime.datetime: | |
112 | if type(d) != datetime.datetime: | |
113 | 113 | raise ValueError("%r must be a datetime instance" % d) |
114 | 114 | if d.tzinfo is None: |
115 | 115 | tz = None |
196 | 196 | # Returns whether an 'ExtendedProperty' element matches the definition for this class. Extended property fields |
197 | 197 | # do not have a name, so we must match on the cls.property_* attributes to match a field in the request with a |
198 | 198 | # field in the response. |
199 | # TODO: Rewrite to take advantage of exchangelib.properties.ExtendedFieldURI | |
199 | 200 | extended_field_uri = elem.find('{%s}ExtendedFieldURI' % TNS) |
200 | 201 | cls_props = cls.properties_map() |
201 | 202 | elem_props = {k: extended_field_uri.get(k) for k in cls_props.keys()} |
311 | 312 | property_type = 'String' |
312 | 313 | |
313 | 314 | __slots__ = tuple() |
315 | ||
316 | ||
317 | class Flag(ExtendedProperty): | |
318 | """This property returns 0 for Not Flagged messages, 1 for Flagged messages and 2 for Completed messages. | |
319 | ||
320 | For a description of each status, see: | |
321 | https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/flagstatus | |
322 | """ | |
323 | property_tag = 0x1090 | |
324 | property_type = 'Integer' |
6 | 6 | import logging |
7 | 7 | |
8 | 8 | from .errors import ErrorInvalidServerVersion |
9 | from .ewsdatetime import EWSDateTime, EWSDate, EWSTimeZone, NaiveDateTimeNotAllowed, UnknownTimeZone | |
9 | from .ewsdatetime import EWSDateTime, EWSDate, EWSTimeZone, NaiveDateTimeNotAllowed, UnknownTimeZone, UTC | |
10 | 10 | from .util import create_element, get_xml_attrs, set_xml_value, value_to_xml_text, is_iterable, safe_b64decode, TNS |
11 | 11 | from .version import Build, Version, EXCHANGE_2013 |
12 | 12 | |
360 | 360 | return set_xml_value(field_elem, value, version=version) |
361 | 361 | |
362 | 362 | def field_uri_xml(self): |
363 | from .properties import FieldURI | |
363 | 364 | if not self.field_uri: |
364 | 365 | raise ValueError("'field_uri' value is missing") |
365 | return create_element('t:FieldURI', attrs=dict(FieldURI=self.field_uri)) | |
366 | return FieldURI(field_uri=self.field_uri).to_xml(version=None) | |
366 | 367 | |
367 | 368 | def request_tag(self): |
368 | 369 | if not self.field_uri_postfix: |
574 | 575 | return self.default |
575 | 576 | |
576 | 577 | |
578 | class DateTimeBackedDateField(FieldURIField): | |
579 | # A field that acts like a date, but where values are sent to EWS as EWSDateTime. | |
580 | value_cls = datetime.date | |
581 | ||
582 | def __init__(self, *args, **kwargs): | |
583 | super().__init__(*args, **kwargs) | |
584 | # Create internal field to handle datetime-only logic | |
585 | self._datetime_field = DateTimeField(*args, **kwargs) | |
586 | ||
587 | def date_to_datetime(self, value): | |
588 | return UTC.localize(self._datetime_field.value_cls.combine(value, datetime.time(11, 59))) | |
589 | ||
590 | def from_xml(self, elem, account): | |
591 | res = self._datetime_field.from_xml(elem=elem, account=account) | |
592 | if res is None: | |
593 | return res | |
594 | return res.date() | |
595 | ||
596 | def to_xml(self, value, version): | |
597 | # Convert date to datetime. EWS changes all values to have a time of 11:59 local time, so let's send that. | |
598 | value = self.date_to_datetime(value) | |
599 | return self._datetime_field.to_xml(value=value, version=version) | |
600 | ||
601 | ||
577 | 602 | class TimeField(FieldURIField): |
578 | 603 | value_cls = datetime.time |
579 | 604 | |
622 | 647 | return self.default |
623 | 648 | |
624 | 649 | |
650 | class DateOrDateTimeField(DateTimeField): | |
651 | """This field can handle both EWSDate and EWSDateTime. Used for calendar items where 'start' and 'end' | |
652 | values are conceptually dates when the calendar item is an all-day event, but datetimes in all other cases. | |
653 | ||
654 | For all-day items, we assume both start and end dates are inclusive. | |
655 | ||
656 | For filtering kwarg validation and other places where we must decide on a specific class, we settle on datetime. | |
657 | """ | |
658 | def __init__(self, *args, **kwargs): | |
659 | super().__init__(*args, **kwargs) | |
660 | # Create internal field to handle date-only logic | |
661 | self._date_field = DateField(*args, **kwargs) | |
662 | ||
663 | def clean(self, value, version=None): | |
664 | # Most calendar items will contain datetime values. We can't access the is_all_day value here, so CalendarItem | |
665 | # must handle that sanity check. | |
666 | if type(value) == EWSDate: | |
667 | return self._date_field.clean(value=value, version=version) | |
668 | return super().clean(value=value, version=version) | |
669 | ||
670 | ||
625 | 671 | class TimeZoneField(FieldURIField): |
626 | 672 | value_cls = EWSTimeZone |
627 | 673 | |
642 | 688 | |
643 | 689 | def to_xml(self, value, version): |
644 | 690 | return create_element( |
645 | 't:%s' % self.field_uri_postfix, | |
691 | self.request_tag(), | |
646 | 692 | attrs=OrderedDict([ |
647 | 693 | ('Id', value.ms_id), |
648 | 694 | ('Name', value.ms_name), |
1062 | 1108 | |
1063 | 1109 | @staticmethod |
1064 | 1110 | def field_uri_xml(field_uri, label): |
1065 | return create_element( | |
1066 | 't:IndexedFieldURI', | |
1067 | attrs=OrderedDict([ | |
1068 | ('FieldURI', field_uri), | |
1069 | ('FieldIndex', label), | |
1070 | ]) | |
1071 | ) | |
1111 | from .properties import IndexedFieldURI | |
1112 | return IndexedFieldURI(field_uri=field_uri, field_index=label).to_xml(version=None) | |
1072 | 1113 | |
1073 | 1114 | def __hash__(self): |
1074 | 1115 | return hash(self.name) |
1104 | 1145 | return set_xml_value(field_elem, value, version=version) |
1105 | 1146 | |
1106 | 1147 | def field_uri_xml(self, field_uri, label): |
1107 | return create_element( | |
1108 | 't:IndexedFieldURI', | |
1109 | attrs=OrderedDict([ | |
1110 | ('FieldURI', '%s:%s' % (field_uri, self.field_uri)), | |
1111 | ('FieldIndex', label), | |
1112 | ]) | |
1113 | ) | |
1148 | from .properties import IndexedFieldURI | |
1149 | return IndexedFieldURI(field_uri='%s:%s' % (field_uri, self.field_uri), field_index=label).to_xml(version=None) | |
1114 | 1150 | |
1115 | 1151 | def request_tag(self): |
1116 | 1152 | return 't:%s' % self.field_uri |
1148 | 1184 | |
1149 | 1185 | def field_uri_xml(self): |
1150 | 1186 | raise NotImplementedError() |
1187 | ||
1188 | def clean(self, value, version=None): | |
1189 | if value is not None: | |
1190 | default_labels = self.value_cls.LABEL_CHOICES | |
1191 | if len(value) > len(default_labels): | |
1192 | raise ValueError('This field can handle at most %s values (value: %r)' % (len(default_labels), value)) | |
1193 | tmp = [] | |
1194 | for s, default_label in zip(value, default_labels): | |
1195 | if not isinstance(s, str): | |
1196 | tmp.append(s) | |
1197 | continue | |
1198 | tmp.append(self.value_cls(email=s, label=default_label)) | |
1199 | value = tmp | |
1200 | return super().clean(value, version=version) | |
1151 | 1201 | |
1152 | 1202 | |
1153 | 1203 | class PhoneNumberField(IndexedField): |
1197 | 1247 | return value |
1198 | 1248 | |
1199 | 1249 | def field_uri_xml(self): |
1200 | elem = create_element('t:ExtendedFieldURI') | |
1250 | from .properties import ExtendedFieldURI | |
1201 | 1251 | cls = self.value_cls |
1202 | if cls.distinguished_property_set_id: | |
1203 | elem.set('DistinguishedPropertySetId', cls.distinguished_property_set_id) | |
1204 | if cls.property_set_id: | |
1205 | elem.set('PropertySetId', cls.property_set_id) | |
1206 | if cls.property_tag: | |
1207 | elem.set('PropertyTag', cls.property_tag_as_hex()) | |
1208 | if cls.property_name: | |
1209 | elem.set('PropertyName', cls.property_name) | |
1210 | if cls.property_id: | |
1211 | elem.set('PropertyId', value_to_xml_text(cls.property_id)) | |
1212 | elem.set('PropertyType', cls.property_type) | |
1213 | return elem | |
1252 | return ExtendedFieldURI( | |
1253 | distinguished_property_set_id=cls.distinguished_property_set_id, | |
1254 | property_set_id=cls.property_set_id.lower() if cls.property_set_id else None, | |
1255 | property_tag=cls.property_tag_as_hex(), | |
1256 | property_name=cls.property_name, | |
1257 | property_id=value_to_xml_text(cls.property_id) if cls.property_id else None, | |
1258 | property_type=cls.property_type, | |
1259 | ).to_xml(version=None) | |
1214 | 1260 | |
1215 | 1261 | def from_xml(self, elem, account): |
1216 | 1262 | extended_properties = elem.findall(self.value_cls.response_tag()) |
1299 | 1345 | |
1300 | 1346 | def from_xml(self, elem, account): |
1301 | 1347 | return [self.value_cls.from_xml(elem=e, account=account) for e in elem.findall(self.value_cls.response_tag())] |
1348 | ||
1349 | ||
1350 | class RoutingTypeField(ChoiceField): | |
1351 | def __init__(self, *args, **kwargs): | |
1352 | kwargs['choices'] = {Choice('SMTP'), Choice('EX')} | |
1353 | kwargs['default'] = 'SMTP' | |
1354 | super().__init__(*args, **kwargs) | |
1355 | ||
1356 | ||
1357 | class IdElementField(EWSElementField): | |
1358 | def __init__(self, *args, **kwargs): | |
1359 | kwargs['is_searchable'] = False | |
1360 | kwargs['is_read_only'] = True | |
1361 | super().__init__(*args, **kwargs) |
4 | 4 | from ..errors import ErrorAccessDenied, ErrorFolderNotFound, ErrorCannotEmptyFolder, ErrorCannotDeleteObject, \ |
5 | 5 | ErrorDeleteDistinguishedFolder |
6 | 6 | from ..fields import IntegerField, CharField, FieldPath, EffectiveRightsField, PermissionSetField, EWSElementField, \ |
7 | Field | |
7 | Field, IdElementField | |
8 | 8 | from ..items import CalendarItem, RegisterMixIn, Persona, ITEM_CLASSES, ITEM_TRAVERSAL_CHOICES, SHAPE_CHOICES, \ |
9 | 9 | ID_ONLY, DELETE_TYPE_CHOICES, HARD_DELETE, SHALLOW as SHALLOW_ITEMS |
10 | from ..properties import Mailbox, FolderId, ParentFolderId, InvalidField, DistinguishedFolderId | |
10 | from ..properties import Mailbox, FolderId, ParentFolderId, InvalidField, DistinguishedFolderId, Fields | |
11 | 11 | from ..queryset import QuerySet, SearchableMixIn, DoesNotExist |
12 | 12 | from ..restriction import Restriction |
13 | 13 | from ..services import CreateFolder, UpdateFolder, DeleteFolder, EmptyFolder, FindPeople |
14 | from ..util import TNS | |
14 | from ..util import TNS, require_id | |
15 | 15 | from ..version import Version, EXCHANGE_2007_SP1, EXCHANGE_2010 |
16 | 16 | from .collections import FolderCollection |
17 | 17 | from .queryset import SingleFolderQuerySet, SHALLOW as SHALLOW_FOLDERS, DEEP as DEEP_FOLDERS |
39 | 39 | LOCALIZED_NAMES = dict() # A map of (str)locale: (tuple)localized_folder_names |
40 | 40 | ITEM_MODEL_MAP = {cls.response_tag(): cls for cls in ITEM_CLASSES} |
41 | 41 | ID_ELEMENT_CLS = FolderId |
42 | LOCAL_FIELDS = [ | |
42 | FIELDS = Fields( | |
43 | IdElementField('_id', field_uri='folder:FolderId', value_cls=ID_ELEMENT_CLS), | |
43 | 44 | EWSElementField('parent_folder_id', field_uri='folder:ParentFolderId', value_cls=ParentFolderId, |
44 | 45 | is_read_only=True), |
45 | 46 | CharField('folder_class', field_uri='folder:FolderClass', is_required_after_save=True), |
47 | 48 | IntegerField('total_count', field_uri='folder:TotalCount', is_read_only=True), |
48 | 49 | IntegerField('child_folder_count', field_uri='folder:ChildFolderCount', is_read_only=True), |
49 | 50 | IntegerField('unread_count', field_uri='folder:UnreadCount', is_read_only=True), |
50 | ] | |
51 | FIELDS = RegisterMixIn.FIELDS + LOCAL_FIELDS | |
52 | ||
53 | __slots__ = tuple(f.name for f in LOCAL_FIELDS) + ('is_distinguished',) | |
51 | ) | |
52 | ||
53 | __slots__ = tuple(f.name for f in FIELDS) + ('is_distinguished',) | |
54 | 54 | |
55 | 55 | # Used to register extended properties |
56 | 56 | INSERT_AFTER_FIELD = 'child_folder_count' |
126 | 126 | elif head == '**': |
127 | 127 | # Match anything here or in any subfolder at arbitrary depth |
128 | 128 | for c in self.walk(): |
129 | if fnmatch(c.name, tail or '*'): | |
129 | # fnmatch() may be case-sensitive depending on operating system: | |
130 | # force a case-insensitive match since case appears not to | |
131 | # matter for folders in Exchange | |
132 | if fnmatch(c.name.lower(), (tail or '*').lower()): | |
130 | 133 | yield c |
131 | 134 | else: |
132 | 135 | # Regular pattern |
133 | 136 | for c in self.children: |
134 | if not fnmatch(c.name, head): | |
137 | # See note above on fnmatch() case-sensitivity | |
138 | if not fnmatch(c.name.lower(), head.lower()): | |
135 | 139 | continue |
136 | 140 | if tail is None: |
137 | 141 | yield c |
361 | 365 | # New folder |
362 | 366 | if update_fields: |
363 | 367 | raise ValueError("'update_fields' is only valid for updates") |
364 | res = list(CreateFolder(account=self.account).call(parent_folder=self.parent, folders=[self])) | |
365 | if len(res) != 1: | |
366 | raise ValueError('Expected result length 1, but got %s' % res) | |
367 | if isinstance(res[0], Exception): | |
368 | raise res[0] | |
369 | self.id, self.changekey = res[0].id, res[0].changekey | |
368 | res = CreateFolder(account=self.account).get(parent_folder=self.parent, folders=[self]) | |
369 | self._id = self.ID_ELEMENT_CLS(res.id, res.changekey) | |
370 | 370 | self.root.add_folder(self) # Add this folder to the cache |
371 | 371 | return self |
372 | 372 | |
383 | 383 | # These are required and cannot be deleted |
384 | 384 | continue |
385 | 385 | update_fields.append(f.name) |
386 | res = list(UpdateFolder(account=self.account).call(folders=[(self, update_fields)])) | |
387 | if len(res) != 1: | |
388 | raise ValueError('Expected result length 1, but got %s' % res) | |
389 | if isinstance(res[0], Exception): | |
390 | raise res[0] | |
391 | folder_id, changekey = res[0].id, res[0].changekey | |
386 | res = UpdateFolder(account=self.account).get(folders=[(self, update_fields)]) | |
387 | folder_id, changekey = res.id, res.changekey | |
392 | 388 | if self.id != folder_id: |
393 | 389 | raise ValueError('ID mismatch') |
394 | 390 | # Don't check changekey value. It may not change on no-op updates |
399 | 395 | def delete(self, delete_type=HARD_DELETE): |
400 | 396 | if delete_type not in DELETE_TYPE_CHOICES: |
401 | 397 | raise ValueError("'delete_type' %s must be one of %s" % (delete_type, DELETE_TYPE_CHOICES)) |
402 | res = list(DeleteFolder(account=self.account).call(folders=[self], delete_type=delete_type)) | |
403 | if len(res) != 1: | |
404 | raise ValueError('Expected result length 1, but got %s' % res) | |
405 | if isinstance(res[0], Exception): | |
406 | raise res[0] | |
398 | DeleteFolder(account=self.account).get(folders=[self], delete_type=delete_type) | |
407 | 399 | self.root.remove_folder(self) # Remove the updated folder from the cache |
408 | self.id, self.changekey = None, None | |
400 | self._id = None | |
409 | 401 | |
410 | 402 | def empty(self, delete_type=HARD_DELETE, delete_sub_folders=False): |
411 | 403 | if delete_type not in DELETE_TYPE_CHOICES: |
412 | 404 | raise ValueError("'delete_type' %s must be one of %s" % (delete_type, DELETE_TYPE_CHOICES)) |
413 | res = list(EmptyFolder(account=self.account).call( | |
414 | folders=[self], delete_type=delete_type, delete_sub_folders=delete_sub_folders) | |
405 | EmptyFolder(account=self.account).get( | |
406 | folders=[self], delete_type=delete_type, delete_sub_folders=delete_sub_folders | |
415 | 407 | ) |
416 | if len(res) != 1: | |
417 | raise ValueError('Expected result length 1, but got %s' % res) | |
418 | if isinstance(res[0], Exception): | |
419 | raise res[0] | |
420 | 408 | if delete_sub_folders: |
421 | 409 | # We don't know exactly what was deleted, so invalidate the entire folder cache to be safe |
422 | 410 | self.root.clear_cache() |
465 | 453 | |
466 | 454 | @classmethod |
467 | 455 | def _kwargs_from_elem(cls, elem, account): |
468 | folder_id, changekey = cls.id_from_xml(elem) | |
469 | kwargs = dict(id=folder_id, changekey=changekey) | |
470 | 456 | # Check for 'DisplayName' element before collecting kwargs because because that clears the elements |
471 | 457 | has_name_elem = elem.find(cls.get_field_by_fieldname('name').response_tag()) is not None |
472 | kwargs.update({ | |
473 | f.name: f.from_xml(elem=elem, account=account) for f in cls.FIELDS if f.name not in ('id', 'changekey') | |
474 | }) | |
458 | kwargs = {f.name: f.from_xml(elem=elem, account=account) for f in cls.FIELDS} | |
475 | 459 | if has_name_elem and not kwargs['name']: |
476 | 460 | # When we request the 'DisplayName' property, some folders may still be returned with an empty value. |
477 | 461 | # Assign a default name to these folders. |
492 | 476 | return FolderId(id=self.id, changekey=self.changekey).to_xml(version=version) |
493 | 477 | return super().to_xml(version=version) |
494 | 478 | |
479 | def to_id_xml(self, version): | |
480 | # Folder(name='Foo') is a perfectly valid ID to e.g. create a folder | |
481 | return self.to_xml(version=version) | |
482 | ||
495 | 483 | @classmethod |
496 | 484 | def resolve(cls, account, folder): |
497 | 485 | # Resolve a single folder |
507 | 495 | raise ValueError("Expected folder %r to be a %s instance" % (f, cls)) |
508 | 496 | return f |
509 | 497 | |
498 | @require_id | |
510 | 499 | def refresh(self): |
511 | if not self.account: | |
512 | raise ValueError('%s must have an account' % self.__class__.__name__) | |
513 | if not self.id: | |
514 | raise ValueError('%s must have an ID' % self.__class__.__name__) | |
515 | 500 | fresh_folder = self.resolve(account=self.account, folder=self) |
516 | 501 | if self.id != fresh_folder.id: |
517 | 502 | raise ValueError('ID mismatch') |
559 | 544 | |
560 | 545 | class Folder(BaseFolder): |
561 | 546 | """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/folder""" |
562 | LOCAL_FIELDS = [ | |
547 | LOCAL_FIELDS = Fields( | |
563 | 548 | PermissionSetField('permission_set', field_uri='folder:PermissionSet', supported_from=EXCHANGE_2007_SP1), |
564 | 549 | EffectiveRightsField('effective_rights', field_uri='folder:EffectiveRights', is_read_only=True, |
565 | 550 | supported_from=EXCHANGE_2007_SP1), |
566 | ] | |
551 | ) | |
567 | 552 | FIELDS = BaseFolder.FIELDS + LOCAL_FIELDS |
568 | 553 | |
569 | 554 | __slots__ = tuple(f.name for f in LOCAL_FIELDS) + ('_root',) |
7 | 7 | from ..queryset import QuerySet, SearchableMixIn |
8 | 8 | from ..restriction import Restriction |
9 | 9 | from ..services import FindFolder, GetFolder, FindItem |
10 | from ..util import require_account | |
10 | 11 | from .queryset import FOLDER_TRAVERSAL_CHOICES |
11 | 12 | |
12 | 13 | log = logging.getLogger(__name__) |
255 | 256 | ): |
256 | 257 | yield f |
257 | 258 | |
259 | @require_account | |
258 | 260 | def find_folders(self, q=None, shape=ID_ONLY, depth=None, additional_fields=None, page_size=None, max_items=None, |
259 | 261 | offset=0): |
260 | 262 | # 'depth' controls whether to return direct children or recurse into sub-folders |
262 | 264 | if not self.folders: |
263 | 265 | log.debug('Folder list is empty') |
264 | 266 | return |
265 | if not self.account: | |
266 | raise ValueError('Folder must have an account') | |
267 | 267 | if q is None or q.is_empty(): |
268 | 268 | restriction = None |
269 | 269 | else: |
2 | 2 | from ..errors import ErrorAccessDenied, ErrorFolderNotFound, ErrorNoPublicFolderReplicaAvailable, ErrorItemNotFound, \ |
3 | 3 | ErrorInvalidOperation |
4 | 4 | from ..fields import EffectiveRightsField |
5 | from ..properties import Fields | |
5 | 6 | from ..version import EXCHANGE_2007_SP1, EXCHANGE_2010_SP1 |
6 | 7 | from .collections import FolderCollection |
7 | 8 | from .base import BaseFolder |
21 | 22 | # 'RootOfHierarchy' subclasses must not be in this list. |
22 | 23 | WELLKNOWN_FOLDERS = [] |
23 | 24 | |
24 | LOCAL_FIELDS = [ | |
25 | LOCAL_FIELDS = Fields( | |
25 | 26 | # This folder type also has 'folder:PermissionSet' on some server versions, but requesting it sometimes causes |
26 | 27 | # 'ErrorAccessDenied', as reported by some users. Ignore it entirely for root folders - it's usefulness is |
27 | 28 | # deemed minimal at best. |
28 | 29 | EffectiveRightsField('effective_rights', field_uri='folder:EffectiveRights', is_read_only=True, |
29 | 30 | supported_from=EXCHANGE_2007_SP1), |
30 | ] | |
31 | ) | |
31 | 32 | FIELDS = BaseFolder.FIELDS + LOCAL_FIELDS |
32 | 33 | __slots__ = tuple(f.name for f in LOCAL_FIELDS) + ('_account', '_subfolders') |
33 | 34 |
0 | 0 | import logging |
1 | 1 | |
2 | 2 | from .fields import EmailSubField, LabelField, SubField, NamedSubField, Choice |
3 | from .properties import EWSElement | |
3 | from .properties import EWSElement, Fields | |
4 | 4 | |
5 | 5 | log = logging.getLogger(__name__) |
6 | 6 | |
27 | 27 | class EmailAddress(SingleFieldIndexedElement): |
28 | 28 | """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/entry-emailaddress""" |
29 | 29 | ELEMENT_NAME = 'Entry' |
30 | FIELDS = [ | |
31 | LabelField('label', field_uri='Key', choices={ | |
32 | Choice('EmailAddress1'), Choice('EmailAddress2'), Choice('EmailAddress3') | |
33 | }, default='EmailAddress1'), | |
30 | LABEL_CHOICES = ('EmailAddress1', 'EmailAddress2', 'EmailAddress3') | |
31 | FIELDS = Fields( | |
32 | LabelField('label', field_uri='Key', choices={Choice(c) for c in LABEL_CHOICES}, default=LABEL_CHOICES[0]), | |
34 | 33 | EmailSubField('email'), |
35 | ] | |
34 | ) | |
36 | 35 | |
37 | 36 | __slots__ = tuple(f.name for f in FIELDS) |
38 | 37 | |
40 | 39 | class PhoneNumber(SingleFieldIndexedElement): |
41 | 40 | """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/entry-phonenumber""" |
42 | 41 | ELEMENT_NAME = 'Entry' |
43 | FIELDS = [ | |
42 | FIELDS = Fields( | |
44 | 43 | LabelField('label', field_uri='Key', choices={ |
45 | 44 | Choice('AssistantPhone'), Choice('BusinessFax'), Choice('BusinessPhone'), Choice('BusinessPhone2'), |
46 | 45 | Choice('Callback'), Choice('CarPhone'), Choice('CompanyMainPhone'), Choice('HomeFax'), Choice('HomePhone'), |
48 | 47 | Choice('Pager'), Choice('PrimaryPhone'), Choice('RadioPhone'), Choice('Telex'), Choice('TtyTddPhone'), |
49 | 48 | }, default='PrimaryPhone'), |
50 | 49 | SubField('phone_number'), |
51 | ] | |
50 | ) | |
52 | 51 | |
53 | 52 | __slots__ = tuple(f.name for f in FIELDS) |
54 | 53 | |
61 | 60 | class PhysicalAddress(MultiFieldIndexedElement): |
62 | 61 | """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/entry-physicaladdress""" |
63 | 62 | ELEMENT_NAME = 'Entry' |
64 | FIELDS = [ | |
63 | FIELDS = Fields( | |
65 | 64 | LabelField('label', field_uri='Key', choices={ |
66 | 65 | Choice('Business'), Choice('Home'), Choice('Other') |
67 | 66 | }, default='Business'), |
70 | 69 | NamedSubField('state', field_uri='State'), |
71 | 70 | NamedSubField('country', field_uri='CountryOrRegion'), |
72 | 71 | NamedSubField('zipcode', field_uri='PostalCode'), |
73 | ] | |
72 | ) | |
74 | 73 | |
75 | 74 | __slots__ = tuple(f.name for f in FIELDS) |
76 | 75 |
0 | from .base import RegisterMixIn, MESSAGE_DISPOSITION_CHOICES, SAVE_ONLY, SEND_ONLY, SEND_AND_SAVE_COPY | |
0 | from .base import RegisterMixIn, BulkCreateResult, MESSAGE_DISPOSITION_CHOICES, SAVE_ONLY, SEND_ONLY, \ | |
1 | ID_ONLY, DEFAULT, ALL_PROPERTIES, SEND_MEETING_INVITATIONS_CHOICES, SEND_TO_NONE, SEND_ONLY_TO_ALL, \ | |
2 | SEND_MEETING_INVITATIONS_AND_CANCELLATIONS_CHOICES, SEND_ONLY_TO_CHANGED, SEND_TO_CHANGED_AND_SAVE_COPY, \ | |
3 | SEND_MEETING_CANCELLATIONS_CHOICES, AFFECTED_TASK_OCCURRENCES_CHOICES, ALL_OCCURRENCIES, \ | |
4 | SPECIFIED_OCCURRENCE_ONLY, CONFLICT_RESOLUTION_CHOICES, NEVER_OVERWRITE, AUTO_RESOLVE, ALWAYS_OVERWRITE, \ | |
5 | DELETE_TYPE_CHOICES, HARD_DELETE, SOFT_DELETE, MOVE_TO_DELETED_ITEMS, SEND_TO_ALL_AND_SAVE_COPY, \ | |
6 | SEND_AND_SAVE_COPY, SHAPE_CHOICES | |
1 | 7 | from .calendar_item import CalendarItem, AcceptItem, TentativelyAcceptItem, DeclineItem, CancelCalendarItem, \ |
2 | 8 | MeetingRequest, MeetingResponse, MeetingCancellation, CONFERENCE_TYPES |
3 | 9 | from .contact import Contact, Persona, DistributionList |
4 | from .item import SEND_MEETING_INVITATIONS_CHOICES, SEND_TO_NONE, SEND_ONLY_TO_ALL, SEND_TO_ALL_AND_SAVE_COPY, \ | |
5 | SEND_MEETING_INVITATIONS_AND_CANCELLATIONS_CHOICES, SEND_ONLY_TO_CHANGED, SEND_TO_CHANGED_AND_SAVE_COPY, \ | |
6 | SEND_MEETING_CANCELLATIONS_CHOICES, AFFECTED_TASK_OCCURRENCES_CHOICES, ALL_OCCURRENCIES, \ | |
7 | SPECIFIED_OCCURRENCE_ONLY, CONFLICT_RESOLUTION_CHOICES, NEVER_OVERWRITE, AUTO_RESOLVE, ALWAYS_OVERWRITE, \ | |
8 | DELETE_TYPE_CHOICES, HARD_DELETE, SOFT_DELETE, MOVE_TO_DELETED_ITEMS, BaseItem, Item, BulkCreateResult | |
10 | from .item import BaseItem, Item | |
9 | 11 | from .message import Message, ReplyToItem, ReplyAllToItem, ForwardItem |
10 | 12 | from .post import PostItem, PostReplyItem |
11 | 13 | from .task import Task |
14 | ||
15 | # Traversal enums | |
16 | SHALLOW = 'Shallow' | |
17 | SOFT_DELETED = 'SoftDeleted' | |
18 | ASSOCIATED = 'Associated' | |
19 | ITEM_TRAVERSAL_CHOICES = (SHALLOW, SOFT_DELETED, ASSOCIATED) | |
20 | ||
21 | # Contacts search (ResolveNames) scope enums | |
22 | ACTIVE_DIRECTORY = 'ActiveDirectory' | |
23 | ACTIVE_DIRECTORY_CONTACTS = 'ActiveDirectoryContacts' | |
24 | CONTACTS = 'Contacts' | |
25 | CONTACTS_ACTIVE_DIRECTORY = 'ContactsActiveDirectory' | |
26 | SEARCH_SCOPE_CHOICES = (ACTIVE_DIRECTORY, ACTIVE_DIRECTORY_CONTACTS, CONTACTS, CONTACTS_ACTIVE_DIRECTORY) | |
27 | ||
28 | ||
29 | ITEM_CLASSES = (Item, CalendarItem, Contact, DistributionList, Message, PostItem, Task, MeetingRequest, MeetingResponse, | |
30 | MeetingCancellation) | |
12 | 31 | |
13 | 32 | __all__ = [ |
14 | 33 | 'RegisterMixIn', 'MESSAGE_DISPOSITION_CHOICES', 'SAVE_ONLY', 'SEND_ONLY', 'SEND_AND_SAVE_COPY', |
24 | 43 | 'Message', 'ReplyToItem', 'ReplyAllToItem', 'ForwardItem', |
25 | 44 | 'PostItem', 'PostReplyItem', |
26 | 45 | 'Task', |
46 | 'ITEM_TRAVERSAL_CHOICES', 'SHALLOW', 'SOFT_DELETED', 'ASSOCIATED', | |
47 | 'SHAPE_CHOICES', 'ID_ONLY', 'DEFAULT', 'ALL_PROPERTIES', | |
48 | 'SEARCH_SCOPE_CHOICES', 'ACTIVE_DIRECTORY', 'ACTIVE_DIRECTORY_CONTACTS', 'CONTACTS', 'CONTACTS_ACTIVE_DIRECTORY', | |
49 | 'ITEM_CLASSES', | |
27 | 50 | ] |
28 | ||
29 | # Traversal enums | |
30 | SHALLOW = 'Shallow' | |
31 | SOFT_DELETED = 'SoftDeleted' | |
32 | ASSOCIATED = 'Associated' | |
33 | ITEM_TRAVERSAL_CHOICES = (SHALLOW, SOFT_DELETED, ASSOCIATED) | |
34 | ||
35 | # Shape enums | |
36 | ID_ONLY = 'IdOnly' | |
37 | DEFAULT = 'Default' | |
38 | # AllProperties doesn't actually get all properties in FindItem, just the "first-class" ones. See | |
39 | # https://docs.microsoft.com/en-us/exchange/client-developer/exchange-web-services/email-properties-and-elements-in-ews-in-exchange | |
40 | ALL_PROPERTIES = 'AllProperties' | |
41 | SHAPE_CHOICES = (ID_ONLY, DEFAULT, ALL_PROPERTIES) | |
42 | ||
43 | # Contacts search (ResolveNames) scope enums | |
44 | ACTIVE_DIRECTORY = 'ActiveDirectory' | |
45 | ACTIVE_DIRECTORY_CONTACTS = 'ActiveDirectoryContacts' | |
46 | CONTACTS = 'Contacts' | |
47 | CONTACTS_ACTIVE_DIRECTORY = 'ContactsActiveDirectory' | |
48 | SEARCH_SCOPE_CHOICES = (ACTIVE_DIRECTORY, ACTIVE_DIRECTORY_CONTACTS, CONTACTS, CONTACTS_ACTIVE_DIRECTORY) | |
49 | ||
50 | ||
51 | ITEM_CLASSES = (Item, CalendarItem, Contact, DistributionList, Message, PostItem, Task, MeetingRequest, MeetingResponse, | |
52 | MeetingCancellation) |
1 | 1 | |
2 | 2 | from ..extended_properties import ExtendedProperty |
3 | 3 | from ..fields import BooleanField, ExtendedPropertyField, BodyField, MailboxField, MailboxListField, EWSElementField, \ |
4 | CharField | |
5 | from ..properties import InvalidField, IdChangeKeyMixIn, EWSElement, ReferenceItemId | |
4 | CharField, IdElementField, AttachmentField | |
5 | from ..properties import InvalidField, IdChangeKeyMixIn, EWSElement, ReferenceItemId, ItemId, Fields | |
6 | from ..services import CreateItem | |
7 | from ..util import require_account | |
6 | 8 | from ..version import EXCHANGE_2007_SP1 |
7 | 9 | |
8 | 10 | log = logging.getLogger(__name__) |
11 | ||
12 | # Shape enums | |
13 | ID_ONLY = 'IdOnly' | |
14 | DEFAULT = 'Default' | |
15 | # AllProperties doesn't actually get all properties in FindItem, just the "first-class" ones. See | |
16 | # https://docs.microsoft.com/en-us/exchange/client-developer/exchange-web-services/email-properties-and-elements-in-ews-in-exchange | |
17 | ALL_PROPERTIES = 'AllProperties' | |
18 | SHAPE_CHOICES = (ID_ONLY, DEFAULT, ALL_PROPERTIES) | |
9 | 19 | |
10 | 20 | # MessageDisposition values. See |
11 | 21 | # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/createitem |
13 | 23 | SEND_ONLY = 'SendOnly' |
14 | 24 | SEND_AND_SAVE_COPY = 'SendAndSaveCopy' |
15 | 25 | MESSAGE_DISPOSITION_CHOICES = (SAVE_ONLY, SEND_ONLY, SEND_AND_SAVE_COPY) |
26 | ||
27 | # SendMeetingInvitations values. See | |
28 | # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/createitem | |
29 | # SendMeetingInvitationsOrCancellations. See | |
30 | # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/updateitem | |
31 | # SendMeetingCancellations values. See | |
32 | # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/deleteitem | |
33 | SEND_TO_NONE = 'SendToNone' | |
34 | SEND_ONLY_TO_ALL = 'SendOnlyToAll' | |
35 | SEND_ONLY_TO_CHANGED = 'SendOnlyToChanged' | |
36 | SEND_TO_ALL_AND_SAVE_COPY = 'SendToAllAndSaveCopy' | |
37 | SEND_TO_CHANGED_AND_SAVE_COPY = 'SendToChangedAndSaveCopy' | |
38 | SEND_MEETING_INVITATIONS_CHOICES = (SEND_TO_NONE, SEND_ONLY_TO_ALL, SEND_TO_ALL_AND_SAVE_COPY) | |
39 | SEND_MEETING_INVITATIONS_AND_CANCELLATIONS_CHOICES = (SEND_TO_NONE, SEND_ONLY_TO_ALL, SEND_ONLY_TO_CHANGED, | |
40 | SEND_TO_ALL_AND_SAVE_COPY, SEND_TO_CHANGED_AND_SAVE_COPY) | |
41 | SEND_MEETING_CANCELLATIONS_CHOICES = (SEND_TO_NONE, SEND_ONLY_TO_ALL, SEND_TO_ALL_AND_SAVE_COPY) | |
42 | ||
43 | # AffectedTaskOccurrences values. See | |
44 | # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/deleteitem | |
45 | ALL_OCCURRENCIES = 'AllOccurrences' | |
46 | SPECIFIED_OCCURRENCE_ONLY = 'SpecifiedOccurrenceOnly' | |
47 | AFFECTED_TASK_OCCURRENCES_CHOICES = (ALL_OCCURRENCIES, SPECIFIED_OCCURRENCE_ONLY) | |
48 | ||
49 | # ConflictResolution values. See | |
50 | # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/updateitem | |
51 | NEVER_OVERWRITE = 'NeverOverwrite' | |
52 | AUTO_RESOLVE = 'AutoResolve' | |
53 | ALWAYS_OVERWRITE = 'AlwaysOverwrite' | |
54 | CONFLICT_RESOLUTION_CHOICES = (NEVER_OVERWRITE, AUTO_RESOLVE, ALWAYS_OVERWRITE) | |
55 | ||
56 | # DeleteType values. See | |
57 | # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/deleteitem | |
58 | HARD_DELETE = 'HardDelete' | |
59 | SOFT_DELETE = 'SoftDelete' | |
60 | MOVE_TO_DELETED_ITEMS = 'MoveToDeletedItems' | |
61 | DELETE_TYPE_CHOICES = (HARD_DELETE, SOFT_DELETE, MOVE_TO_DELETED_ITEMS) | |
16 | 62 | |
17 | 63 | |
18 | 64 | class RegisterMixIn(IdChangeKeyMixIn): |
63 | 109 | |
64 | 110 | class BaseItem(RegisterMixIn): |
65 | 111 | """Base class for all other classes that implement EWS items""" |
66 | __slots__ = ('account', 'folder') | |
112 | ID_ELEMENT_CLS = ItemId | |
113 | ||
114 | FIELDS = Fields( | |
115 | IdElementField('_id', field_uri='item:ItemId', value_cls=ID_ELEMENT_CLS), | |
116 | ) | |
117 | ||
118 | __slots__ = tuple(f.name for f in FIELDS) + ('account', 'folder') | |
67 | 119 | |
68 | 120 | def __init__(self, **kwargs): |
69 | 121 | # 'account' is optional but allows calling 'send()' and 'delete()' |
92 | 144 | item.account = account |
93 | 145 | return item |
94 | 146 | |
147 | def to_id_xml(self, version): | |
148 | return self._id.to_xml(version=version) | |
149 | ||
95 | 150 | |
96 | 151 | class BaseReplyItem(EWSElement): |
97 | 152 | """Base class for reply/forward elements that share the same fields""" |
98 | FIELDS = [ | |
153 | FIELDS = Fields( | |
99 | 154 | CharField('subject', field_uri='Subject'), |
100 | 155 | BodyField('body', field_uri='Body'), # Accepts and returns Body or HTMLBody instances |
101 | 156 | MailboxListField('to_recipients', field_uri='ToRecipients'), |
108 | 163 | BodyField('new_body', field_uri='NewBodyContent'), # Accepts and returns Body or HTMLBody instances |
109 | 164 | MailboxField('received_by', field_uri='ReceivedBy', supported_from=EXCHANGE_2007_SP1), |
110 | 165 | MailboxField('received_by_representing', field_uri='ReceivedRepresenting', supported_from=EXCHANGE_2007_SP1), |
111 | ] | |
166 | ) | |
112 | 167 | |
113 | 168 | __slots__ = tuple(f.name for f in FIELDS) + ('account',) |
114 | 169 | |
120 | 175 | raise ValueError("'account' %r must be an Account instance" % self.account) |
121 | 176 | super().__init__(**kwargs) |
122 | 177 | |
178 | @require_account | |
123 | 179 | def send(self, save_copy=True, copy_to_folder=None): |
124 | if not self.account: | |
125 | raise ValueError('%s must have an account' % self.__class__.__name__) | |
126 | 180 | if copy_to_folder: |
127 | 181 | if not save_copy: |
128 | 182 | raise AttributeError("'save_copy' must be True when 'copy_to_folder' is set") |
129 | 183 | message_disposition = SEND_AND_SAVE_COPY if save_copy else SEND_ONLY |
130 | res = self.account.bulk_create(items=[self], folder=copy_to_folder, message_disposition=message_disposition) | |
131 | if res and isinstance(res[0], Exception): | |
132 | raise res[0] | |
133 | ||
184 | CreateItem(account=self.account).get( | |
185 | items=[self], | |
186 | folder=copy_to_folder, | |
187 | message_disposition=message_disposition, | |
188 | send_meeting_invitations=SEND_TO_NONE, | |
189 | expect_result=False, | |
190 | ) | |
191 | ||
192 | @require_account | |
134 | 193 | def save(self, folder): |
135 | 194 | """ |
136 | 195 | save reply/forward and retrieve the item result for further modification, |
137 | 196 | you may want to use account.drafts as the folder. |
138 | 197 | """ |
139 | if not self.account: | |
140 | raise ValueError('%s must have an account' % self.__class__.__name__) | |
141 | res = self.account.bulk_create(items=[self], folder=folder, message_disposition=SAVE_ONLY) | |
142 | if res and isinstance(res[0], Exception): | |
143 | raise res[0] | |
144 | res = list(self.account.fetch(res)) # retrieve result | |
145 | if res and isinstance(res[0], Exception): | |
146 | raise res[0] | |
147 | return res[0] | |
198 | res = CreateItem(account=self.account).get( | |
199 | items=[self], | |
200 | folder=folder, | |
201 | message_disposition=SAVE_ONLY, | |
202 | send_meeting_invitations=SEND_TO_NONE, | |
203 | ) | |
204 | return BulkCreateResult.from_xml(elem=res, account=self) | |
205 | ||
206 | ||
207 | class BulkCreateResult(BaseItem): | |
208 | """A dummy class to store return values from a CreateItem service call""" | |
209 | LOCAL_FIELDS = Fields( | |
210 | AttachmentField('attachments', field_uri='item:Attachments'), # ItemAttachment or FileAttachment | |
211 | ) | |
212 | FIELDS = BaseItem.FIELDS + LOCAL_FIELDS | |
213 | ||
214 | __slots__ = tuple(f.name for f in LOCAL_FIELDS) | |
215 | ||
216 | def __init__(self, **kwargs): | |
217 | super().__init__(**kwargs) | |
218 | # pylint: disable=access-member-before-definition | |
219 | if self.attachments is None: | |
220 | self.attachments = [] |
0 | import datetime | |
0 | 1 | import logging |
1 | 2 | |
3 | from ..ewsdatetime import EWSDate, EWSDateTime | |
2 | 4 | from ..fields import BooleanField, IntegerField, TextField, ChoiceField, URIField, BodyField, DateTimeField, \ |
3 | 5 | MessageHeaderField, AttachmentField, RecurrenceField, MailboxField, AttendeesField, Choice, OccurrenceField, \ |
4 | 6 | OccurrenceListField, TimeZoneField, CharField, EnumAsIntField, FreeBusyStatusField, ReferenceItemIdField, \ |
5 | AssociatedCalendarItemIdField | |
6 | from ..properties import Attendee, ReferenceItemId, AssociatedCalendarItemId | |
7 | AssociatedCalendarItemIdField, DateOrDateTimeField | |
8 | from ..properties import Attendee, ReferenceItemId, AssociatedCalendarItemId, OccurrenceItemId, RecurringMasterItemId, \ | |
9 | Fields | |
7 | 10 | from ..recurrence import FirstOccurrence, LastOccurrence, Occurrence, DeletedOccurrence |
11 | from ..services import CreateItem | |
12 | from ..util import set_xml_value, require_account | |
8 | 13 | from ..version import EXCHANGE_2010, EXCHANGE_2013 |
9 | from .base import BaseItem, BaseReplyItem, SEND_AND_SAVE_COPY | |
14 | from .base import BaseItem, BaseReplyItem, BulkCreateResult, SEND_ONLY, SEND_AND_SAVE_COPY, SEND_TO_NONE | |
10 | 15 | from .item import Item |
11 | 16 | from .message import Message |
12 | 17 | |
52 | 57 | MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/calendaritem |
53 | 58 | """ |
54 | 59 | ELEMENT_NAME = 'CalendarItem' |
55 | LOCAL_FIELDS = [ | |
60 | LOCAL_FIELDS = Fields( | |
56 | 61 | TextField('uid', field_uri='calendar:UID', is_required_after_save=True, is_searchable=False), |
57 | DateTimeField('start', field_uri='calendar:Start', is_required=True), | |
58 | DateTimeField('end', field_uri='calendar:End', is_required=True), | |
62 | DateOrDateTimeField('start', field_uri='calendar:Start', is_required=True), | |
63 | DateOrDateTimeField('end', field_uri='calendar:End', is_required=True), | |
59 | 64 | DateTimeField('original_start', field_uri='calendar:OriginalStart', is_read_only=True), |
60 | 65 | BooleanField('is_all_day', field_uri='calendar:IsAllDayEvent', is_required=True, default=False), |
61 | 66 | FreeBusyStatusField('legacy_free_busy_status', field_uri='calendar:LegacyFreeBusyStatus', is_required=True, |
111 | 116 | is_read_only=True), |
112 | 117 | URIField('meeting_workspace_url', field_uri='calendar:MeetingWorkspaceUrl'), |
113 | 118 | URIField('net_show_url', field_uri='calendar:NetShowUrl'), |
114 | ] | |
119 | ) | |
115 | 120 | FIELDS = Item.FIELDS + LOCAL_FIELDS |
116 | 121 | |
117 | 122 | __slots__ = tuple(f.name for f in LOCAL_FIELDS) |
123 | ||
124 | def occurrence(self, index): | |
125 | """Return a new CalendarItem instance with an ID pointing to the n'th occurrence in the recurrence. The index | |
126 | is 1-based. No other field values are fetched from the server. | |
127 | ||
128 | Only call this method on a recurring master. | |
129 | """ | |
130 | return self.__class__( | |
131 | account=self.account, | |
132 | folder=self.folder, | |
133 | _id=OccurrenceItemId(id=self.id, changekey=self.changekey, instance_index=index), | |
134 | ) | |
135 | ||
136 | def recurring_master(self): | |
137 | """Return a new CalendarItem instance with an ID pointing to the recurring master item of this occurrence. No | |
138 | other field values are fetched from the server. | |
139 | ||
140 | Only call this method on an occurrence of a recurring master. | |
141 | """ | |
142 | return self.__class__( | |
143 | account=self.account, | |
144 | folder=self.folder, | |
145 | _id=RecurringMasterItemId(id=self.id, changekey=self.changekey), | |
146 | ) | |
118 | 147 | |
119 | 148 | @classmethod |
120 | 149 | def timezone_fields(cls): |
123 | 152 | def clean_timezone_fields(self, version): |
124 | 153 | # pylint: disable=access-member-before-definition |
125 | 154 | # Sets proper values on the timezone fields if they are not already set |
155 | if self.start is None: | |
156 | start_tz = None | |
157 | elif type(self.start) == EWSDate: | |
158 | start_tz = self.account.default_timezone | |
159 | else: | |
160 | start_tz = self.start.tzinfo | |
161 | if self.end is None: | |
162 | end_tz = None | |
163 | elif type(self.end) == EWSDate: | |
164 | end_tz = self.account.default_timezone | |
165 | else: | |
166 | end_tz = self.end.tzinfo | |
126 | 167 | if version.build < EXCHANGE_2010: |
127 | if self._meeting_timezone is None and self.start is not None: | |
128 | self._meeting_timezone = self.start.tzinfo | |
168 | if self._meeting_timezone is None: | |
169 | self._meeting_timezone = start_tz | |
129 | 170 | self._start_timezone = None |
130 | 171 | self._end_timezone = None |
131 | 172 | else: |
132 | 173 | self._meeting_timezone = None |
133 | if self._start_timezone is None and self.start is not None: | |
134 | self._start_timezone = self.start.tzinfo | |
135 | if self._end_timezone is None and self.end is not None: | |
136 | self._end_timezone = self.end.tzinfo | |
174 | if self._start_timezone is None: | |
175 | self._start_timezone = start_tz | |
176 | if self._end_timezone is None: | |
177 | self._end_timezone = end_tz | |
137 | 178 | |
138 | 179 | def clean(self, version=None): |
139 | 180 | # pylint: disable=access-member-before-definition |
159 | 200 | update_fields.remove('uid') |
160 | 201 | return update_fields |
161 | 202 | |
203 | @classmethod | |
204 | def from_xml(cls, elem, account): | |
205 | item = super().from_xml(elem=elem, account=account) | |
206 | # EWS returns the start and end values as a datetime regardless of the is_all_day status. Convert to date if | |
207 | # applicable. | |
208 | if not item.is_all_day: | |
209 | return item | |
210 | for field_name in ('start', 'end'): | |
211 | val = getattr(item, field_name) | |
212 | if val is None: | |
213 | continue | |
214 | # Return just the date part of the value. Subtract 1 day from the date if this is the end field. This is | |
215 | # the inverse of what we do in .to_xml(). Convert to the local timezone before getting the date. | |
216 | if field_name == 'end': | |
217 | val -= datetime.timedelta(days=1) | |
218 | tz = getattr(item, '_%s_timezone' % field_name) | |
219 | setattr(item, field_name, val.astimezone(tz).date()) | |
220 | return item | |
221 | ||
222 | def date_to_datetime(self, field_name): | |
223 | # EWS always expects a datetime. If we have a date value, then convert it to datetime in the local | |
224 | # timezone. Additionally, if this the end field, add 1 day to the date. We could add 12 hours to both | |
225 | # start and end values and let EWS apply its logic, but that seems hacky. | |
226 | value = getattr(self, field_name) | |
227 | if self.account.version.build < EXCHANGE_2010: | |
228 | tz = self._meeting_timezone | |
229 | else: | |
230 | tz = getattr(self, '_%s_timezone' % field_name) | |
231 | value = tz.localize(EWSDateTime.combine(value, datetime.time(0, 0))) | |
232 | if field_name == 'end': | |
233 | value += datetime.timedelta(days=1) | |
234 | return value | |
235 | ||
236 | def to_xml(self, version): | |
237 | # EWS has some special logic related to all-day start and end values. Non-midnight start values are pushed to | |
238 | # the previous midnight. Non-midnight end values are pushed to the following midnight. Midnight in this context | |
239 | # refers to midnight in the local timezone. See | |
240 | # | |
241 | # https://docs.microsoft.com/en-us/exchange/client-developer/exchange-web-services/how-to-create-all-day-events-by-using-ews-in-exchange | |
242 | # | |
243 | elem = super().to_xml(version=version) | |
244 | if not self.is_all_day: | |
245 | return elem | |
246 | for field_name in ('start', 'end'): | |
247 | value = getattr(self, field_name) | |
248 | if value is None: | |
249 | continue | |
250 | if type(value) == EWSDate: | |
251 | # EWS always expects a datetime | |
252 | value = self.date_to_datetime(field_name=field_name) | |
253 | # We already generated an XML element for this field, but it contains a plain date at this point, which | |
254 | # is invalid. Replace the value. | |
255 | field = self.get_field_by_fieldname(field_name) | |
256 | set_xml_value(elem=elem.find(field.response_tag()), value=value, version=version) | |
257 | return elem | |
258 | ||
162 | 259 | |
163 | 260 | class BaseMeetingItem(Item): |
164 | 261 | """ |
171 | 268 | Therefore BaseMeetingItem inherits from EWSElement has no save() or send() method |
172 | 269 | |
173 | 270 | """ |
174 | LOCAL_FIELDS = Message.LOCAL_FIELDS[:-2] + [ | |
271 | LOCAL_FIELDS = Message.LOCAL_FIELDS[:-2] + Fields( | |
175 | 272 | AssociatedCalendarItemIdField('associated_calendar_item_id', field_uri='meeting:AssociatedCalendarItemId', |
176 | 273 | value_cls=AssociatedCalendarItemId), |
177 | 274 | BooleanField('is_delegated', field_uri='meeting:IsDelegated', is_read_only=True, default=False), |
181 | 278 | choices={Choice('Unknown'), Choice('Organizer'), Choice('Tentative'), |
182 | 279 | Choice('Accept'), Choice('Decline'), Choice('NoResponseReceived')}, |
183 | 280 | is_required=True, default='Unknown'), |
184 | ] | |
281 | ) | |
185 | 282 | FIELDS = Item.FIELDS + LOCAL_FIELDS |
186 | 283 | |
187 | 284 | __slots__ = tuple(f.name for f in LOCAL_FIELDS) |
192 | 289 | MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/meetingrequest |
193 | 290 | """ |
194 | 291 | ELEMENT_NAME = 'MeetingRequest' |
195 | LOCAL_FIELDS = [ | |
292 | LOCAL_FIELDS = Fields( | |
196 | 293 | ChoiceField('meeting_request_type', field_uri='meetingRequest:MeetingRequestType', |
197 | 294 | choices={Choice('FullUpdate'), Choice('InformationalUpdate'), Choice('NewMeetingRequest'), |
198 | 295 | Choice('None'), Choice('Outdated'), Choice('PrincipalWantsCopy'), |
201 | 298 | ChoiceField('intended_free_busy_status', field_uri='meetingRequest:IntendedFreeBusyStatus', choices={ |
202 | 299 | Choice('Free'), Choice('Tentative'), Choice('Busy'), Choice('OOF'), Choice('NoData')}, |
203 | 300 | is_required=True, default='Busy'), |
204 | ] + [f for f in CalendarItem.LOCAL_FIELDS[1:] if f.name != 'is_response_requested'] | |
301 | ) + Fields(*(f for f in CalendarItem.LOCAL_FIELDS[1:] if f.name != 'is_response_requested')) | |
205 | 302 | |
206 | 303 | # FIELDS on this element are shuffled compared to other elements |
207 | 304 | culture_idx = None |
233 | 330 | class MeetingResponse(BaseMeetingItem): |
234 | 331 | """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/meetingresponse""" |
235 | 332 | ELEMENT_NAME = 'MeetingResponse' |
236 | LOCAL_FIELDS = [ | |
333 | LOCAL_FIELDS = Fields( | |
237 | 334 | MailboxField('received_by', field_uri='message:ReceivedBy', is_read_only=True), |
238 | 335 | MailboxField('received_representing', field_uri='message:ReceivedRepresenting', is_read_only=True), |
239 | ] | |
336 | ) | |
240 | 337 | # FIELDS on this element are shuffled compared to other elements |
241 | 338 | culture_idx = None |
242 | 339 | for i, field in enumerate(Item.FIELDS): |
260 | 357 | |
261 | 358 | class BaseMeetingReplyItem(BaseItem): |
262 | 359 | """Base class for meeting request reply items that share the same fields (Accept, TentativelyAccept, Decline)""" |
263 | FIELDS = [ | |
360 | FIELDS = Fields( | |
264 | 361 | CharField('item_class', field_uri='item:ItemClass', is_read_only=True), |
265 | 362 | ChoiceField('sensitivity', field_uri='item:Sensitivity', choices={ |
266 | 363 | Choice('Normal'), Choice('Personal'), Choice('Private'), Choice('Confidential') |
268 | 365 | BodyField('body', field_uri='item:Body'), # Accepts and returns Body or HTMLBody instances |
269 | 366 | AttachmentField('attachments', field_uri='item:Attachments'), # ItemAttachment or FileAttachment |
270 | 367 | MessageHeaderField('headers', field_uri='item:InternetMessageHeaders', is_read_only=True), |
271 | ] + Message.LOCAL_FIELDS[:6] + [ | |
368 | ) + Message.LOCAL_FIELDS[:6] + Fields( | |
272 | 369 | ReferenceItemIdField('reference_item_id', field_uri='item:ReferenceItemId', value_cls=ReferenceItemId), |
273 | 370 | MailboxField('received_by', field_uri='message:ReceivedBy', is_read_only=True), |
274 | 371 | MailboxField('received_representing', field_uri='message:ReceivedRepresenting', is_read_only=True), |
275 | 372 | DateTimeField('proposed_start', field_uri='meeting:ProposedStart', supported_from=EXCHANGE_2013), |
276 | 373 | DateTimeField('proposed_end', field_uri='meeting:ProposedEnd', supported_from=EXCHANGE_2013), |
277 | ] | |
374 | ) | |
278 | 375 | |
279 | 376 | __slots__ = tuple(f.name for f in FIELDS) |
280 | 377 | |
378 | @require_account | |
281 | 379 | def send(self, message_disposition=SEND_AND_SAVE_COPY): |
282 | if not self.account: | |
283 | raise ValueError('%s must have an account' % self.__class__.__name__) | |
284 | ||
285 | res = self.account.bulk_create(items=[self], folder=self.folder, message_disposition=message_disposition) | |
286 | ||
287 | for r_item in res: | |
288 | if isinstance(r_item, Exception): | |
289 | raise r_item | |
290 | return res | |
380 | res = CreateItem(account=self.account).get( | |
381 | items=[self], | |
382 | folder=self.folder, | |
383 | message_disposition=message_disposition, | |
384 | send_meeting_invitations=SEND_TO_NONE, | |
385 | expect_result=message_disposition not in (SEND_ONLY, SEND_AND_SAVE_COPY), | |
386 | ) | |
387 | return BulkCreateResult.from_xml(elem=res, account=self) | |
291 | 388 | |
292 | 389 | |
293 | 390 | class AcceptItem(BaseMeetingReplyItem): |
314 | 411 | class CancelCalendarItem(BaseReplyItem): |
315 | 412 | """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/cancelcalendaritem""" |
316 | 413 | ELEMENT_NAME = 'CancelCalendarItem' |
317 | FIELDS = [f for f in BaseReplyItem.FIELDS if f.name != 'author'] | |
318 | __slots__ = tuple() | |
414 | FIELDS = Fields(*(f for f in BaseReplyItem.FIELDS if f.name != 'author')) | |
415 | __slots__ = tuple() |
0 | 0 | import logging |
1 | 1 | |
2 | from ..fields import BooleanField, Base64Field, TextField, ChoiceField, URIField, DateTimeField, PhoneNumberField, \ | |
3 | EmailAddressesField, PhysicalAddressField, Choice, MemberListField, CharField, TextListField, EmailAddressField | |
4 | from ..properties import PersonaId, IdChangeKeyMixIn | |
2 | from ..fields import BooleanField, Base64Field, TextField, ChoiceField, URIField, DateTimeBackedDateField, \ | |
3 | PhoneNumberField, EmailAddressesField, PhysicalAddressField, Choice, MemberListField, CharField, TextListField, \ | |
4 | EmailAddressField, IdElementField | |
5 | from ..properties import PersonaId, IdChangeKeyMixIn, Fields | |
5 | 6 | from ..version import EXCHANGE_2010, EXCHANGE_2013 |
6 | 7 | from .item import Item |
7 | 8 | |
13 | 14 | MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/contact |
14 | 15 | """ |
15 | 16 | ELEMENT_NAME = 'Contact' |
16 | LOCAL_FIELDS = [ | |
17 | LOCAL_FIELDS = Fields( | |
17 | 18 | TextField('file_as', field_uri='contacts:FileAs'), |
18 | 19 | ChoiceField('file_as_mapping', field_uri='contacts:FileAsMapping', choices={ |
19 | 20 | Choice('None'), Choice('LastCommaFirst'), Choice('FirstSpaceLast'), Choice('Company'), |
34 | 35 | PhysicalAddressField('physical_addresses', field_uri='contacts:PhysicalAddress'), |
35 | 36 | PhoneNumberField('phone_numbers', field_uri='contacts:PhoneNumber'), |
36 | 37 | TextField('assistant_name', field_uri='contacts:AssistantName'), |
37 | DateTimeField('birthday', field_uri='contacts:Birthday'), | |
38 | DateTimeBackedDateField('birthday', field_uri='contacts:Birthday'), | |
38 | 39 | URIField('business_homepage', field_uri='contacts:BusinessHomePage'), |
39 | 40 | TextListField('children', field_uri='contacts:Children'), |
40 | 41 | TextListField('companies', field_uri='contacts:Companies', is_searchable=False), |
54 | 55 | TextField('profession', field_uri='contacts:Profession'), |
55 | 56 | TextField('spouse_name', field_uri='contacts:SpouseName'), |
56 | 57 | CharField('surname', field_uri='contacts:Surname'), |
57 | DateTimeField('wedding_anniversary', field_uri='contacts:WeddingAnniversary'), | |
58 | DateTimeBackedDateField('wedding_anniversary', field_uri='contacts:WeddingAnniversary'), | |
58 | 59 | BooleanField('has_picture', field_uri='contacts:HasPicture', supported_from=EXCHANGE_2010, is_read_only=True), |
59 | 60 | TextField('phonetic_full_name', field_uri='contacts:PhoneticFullName', supported_from=EXCHANGE_2013, |
60 | 61 | is_read_only=True), |
75 | 76 | TextField('directory_id', field_uri='contacts:DirectoryId', supported_from=EXCHANGE_2013, is_read_only=True), |
76 | 77 | # Placeholder for ManagerMailbox |
77 | 78 | # Placeholder for DirectReports |
78 | ] | |
79 | ) | |
79 | 80 | FIELDS = Item.FIELDS + LOCAL_FIELDS |
80 | 81 | |
81 | 82 | __slots__ = tuple(f.name for f in LOCAL_FIELDS) |
85 | 86 | """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/persona""" |
86 | 87 | ELEMENT_NAME = 'Persona' |
87 | 88 | ID_ELEMENT_CLS = PersonaId |
88 | LOCAL_FIELDS = [ | |
89 | LOCAL_FIELDS = Fields( | |
90 | IdElementField('_id', field_uri='persona:PersonaId', value_cls=ID_ELEMENT_CLS), | |
89 | 91 | CharField('file_as', field_uri='persona:FileAs'), |
90 | 92 | CharField('display_name', field_uri='persona:DisplayName'), |
91 | 93 | CharField('given_name', field_uri='persona:GivenName'), |
98 | 100 | CharField('company_name', field_uri='persona:CompanyName'), |
99 | 101 | CharField('im_address', field_uri='persona:ImAddress'), |
100 | 102 | TextField('initials', field_uri='persona:Initials'), |
101 | ] | |
103 | ) | |
102 | 104 | FIELDS = IdChangeKeyMixIn.FIELDS + LOCAL_FIELDS |
103 | 105 | |
104 | 106 | __slots__ = tuple(f.name for f in LOCAL_FIELDS) |
109 | 111 | MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/distributionlist |
110 | 112 | """ |
111 | 113 | ELEMENT_NAME = 'DistributionList' |
112 | LOCAL_FIELDS = [ | |
114 | LOCAL_FIELDS = Fields( | |
113 | 115 | CharField('display_name', field_uri='contacts:DisplayName', is_required=True), |
114 | 116 | CharField('file_as', field_uri='contacts:FileAs', is_read_only=True), |
115 | 117 | ChoiceField('contact_source', field_uri='contacts:ContactSource', choices={ |
116 | 118 | Choice('Store'), Choice('ActiveDirectory') |
117 | 119 | }, is_read_only=True), |
118 | 120 | MemberListField('members', field_uri='distributionlist:Members'), |
119 | ] | |
121 | ) | |
120 | 122 | FIELDS = Item.FIELDS + LOCAL_FIELDS |
121 | 123 | |
122 | 124 | __slots__ = tuple(f.name for f in LOCAL_FIELDS) |
1 | 1 | |
2 | 2 | from ..fields import BooleanField, IntegerField, TextField, CharListField, ChoiceField, URIField, BodyField, \ |
3 | 3 | DateTimeField, MessageHeaderField, AttachmentField, Choice, EWSElementField, EffectiveRightsField, CultureField, \ |
4 | CharField, MimeContentField | |
5 | from ..properties import ConversationId, ParentFolderId, ReferenceItemId | |
6 | from ..util import is_iterable | |
4 | CharField, MimeContentField, FieldPath | |
5 | from ..properties import ConversationId, ParentFolderId, ReferenceItemId, OccurrenceItemId, RecurringMasterItemId,\ | |
6 | Fields | |
7 | from ..services import GetItem, CreateItem, UpdateItem, DeleteItem, MoveItem, CopyItem, ArchiveItem | |
8 | from ..util import is_iterable, require_account, require_id | |
7 | 9 | from ..version import EXCHANGE_2010, EXCHANGE_2013 |
8 | from .base import BaseItem, SAVE_ONLY, SEND_ONLY, SEND_AND_SAVE_COPY | |
10 | from .base import BaseItem, BulkCreateResult, SAVE_ONLY, SEND_ONLY, SEND_AND_SAVE_COPY, ID_ONLY, SEND_TO_NONE, \ | |
11 | AUTO_RESOLVE, SOFT_DELETE, HARD_DELETE, ALL_OCCURRENCIES, MOVE_TO_DELETED_ITEMS | |
9 | 12 | |
10 | 13 | log = logging.getLogger(__name__) |
11 | ||
12 | # SendMeetingInvitations values. See | |
13 | # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/createitem | |
14 | # SendMeetingInvitationsOrCancellations. See | |
15 | # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/updateitem | |
16 | # SendMeetingCancellations values. See | |
17 | # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/deleteitem | |
18 | SEND_TO_NONE = 'SendToNone' | |
19 | SEND_ONLY_TO_ALL = 'SendOnlyToAll' | |
20 | SEND_ONLY_TO_CHANGED = 'SendOnlyToChanged' | |
21 | SEND_TO_ALL_AND_SAVE_COPY = 'SendToAllAndSaveCopy' | |
22 | SEND_TO_CHANGED_AND_SAVE_COPY = 'SendToChangedAndSaveCopy' | |
23 | SEND_MEETING_INVITATIONS_CHOICES = (SEND_TO_NONE, SEND_ONLY_TO_ALL, SEND_TO_ALL_AND_SAVE_COPY) | |
24 | SEND_MEETING_INVITATIONS_AND_CANCELLATIONS_CHOICES = (SEND_TO_NONE, SEND_ONLY_TO_ALL, SEND_ONLY_TO_CHANGED, | |
25 | SEND_TO_ALL_AND_SAVE_COPY, SEND_TO_CHANGED_AND_SAVE_COPY) | |
26 | SEND_MEETING_CANCELLATIONS_CHOICES = (SEND_TO_NONE, SEND_ONLY_TO_ALL, SEND_TO_ALL_AND_SAVE_COPY) | |
27 | ||
28 | # AffectedTaskOccurrences values. See | |
29 | # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/deleteitem | |
30 | ALL_OCCURRENCIES = 'AllOccurrences' | |
31 | SPECIFIED_OCCURRENCE_ONLY = 'SpecifiedOccurrenceOnly' | |
32 | AFFECTED_TASK_OCCURRENCES_CHOICES = (ALL_OCCURRENCIES, SPECIFIED_OCCURRENCE_ONLY) | |
33 | ||
34 | # ConflictResolution values. See | |
35 | # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/updateitem | |
36 | NEVER_OVERWRITE = 'NeverOverwrite' | |
37 | AUTO_RESOLVE = 'AutoResolve' | |
38 | ALWAYS_OVERWRITE = 'AlwaysOverwrite' | |
39 | CONFLICT_RESOLUTION_CHOICES = (NEVER_OVERWRITE, AUTO_RESOLVE, ALWAYS_OVERWRITE) | |
40 | ||
41 | # DeleteType values. See | |
42 | # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/deleteitem | |
43 | HARD_DELETE = 'HardDelete' | |
44 | SOFT_DELETE = 'SoftDelete' | |
45 | MOVE_TO_DELETED_ITEMS = 'MoveToDeletedItems' | |
46 | DELETE_TYPE_CHOICES = (HARD_DELETE, SOFT_DELETE, MOVE_TO_DELETED_ITEMS) | |
47 | 14 | |
48 | 15 | |
49 | 16 | class Item(BaseItem): |
52 | 19 | """ |
53 | 20 | ELEMENT_NAME = 'Item' |
54 | 21 | |
55 | LOCAL_FIELDS = [ | |
22 | LOCAL_FIELDS = Fields( | |
56 | 23 | MimeContentField('mime_content', field_uri='item:MimeContent', is_read_only_after_send=True), |
57 | 24 | EWSElementField('parent_folder_id', field_uri='item:ParentFolderId', value_cls=ParentFolderId, |
58 | 25 | is_read_only=True), |
85 | 52 | BooleanField('reminder_is_set', field_uri='item:ReminderIsSet', is_required=True, default=False), |
86 | 53 | IntegerField('reminder_minutes_before_start', field_uri='item:ReminderMinutesBeforeStart', |
87 | 54 | is_required_after_save=True, min=0, default=0), |
88 | CharField('display_cc', field_uri='item:DisplayCc', is_read_only=True), | |
89 | CharField('display_to', field_uri='item:DisplayTo', is_read_only=True), | |
55 | TextField('display_cc', field_uri='item:DisplayCc', is_read_only=True), | |
56 | TextField('display_to', field_uri='item:DisplayTo', is_read_only=True), | |
90 | 57 | BooleanField('has_attachments', field_uri='item:HasAttachments', is_read_only=True), |
91 | 58 | # ExtendedProperty fields go here |
92 | 59 | CultureField('culture', field_uri='item:Culture', is_required_after_save=True, is_searchable=False), |
101 | 68 | EWSElementField('conversation_id', field_uri='item:ConversationId', value_cls=ConversationId, |
102 | 69 | is_read_only=True, supported_from=EXCHANGE_2010), |
103 | 70 | BodyField('unique_body', field_uri='item:UniqueBody', is_read_only=True, supported_from=EXCHANGE_2010), |
104 | ] | |
105 | ||
71 | ) | |
106 | 72 | FIELDS = LOCAL_FIELDS[0:1] + BaseItem.FIELDS + LOCAL_FIELDS[1:] |
107 | 73 | |
108 | 74 | __slots__ = tuple(f.name for f in LOCAL_FIELDS) |
132 | 98 | conflict_resolution=conflict_resolution, |
133 | 99 | send_meeting_invitations=send_meeting_invitations |
134 | 100 | ) |
135 | if self.id != item_id: | |
101 | if self.id != item_id and not isinstance(self._id, (OccurrenceItemId, RecurringMasterItemId)): | |
102 | # When we update an item with an OccurrenceItemId as ID, EWS returns the ID of the occurrence, so | |
103 | # the ID of this item changes. | |
136 | 104 | raise ValueError("'id' mismatch in returned update response") |
137 | 105 | # Don't check that changekeys are different. No-op saves will sometimes leave the changekey intact |
138 | self.changekey = changekey | |
106 | self._id = self.ID_ELEMENT_CLS(item_id, changekey) | |
139 | 107 | else: |
140 | 108 | if update_fields: |
141 | 109 | raise ValueError("'update_fields' is only valid for updates") |
142 | 110 | tmp_attachments = None |
143 | if self.account and self.account.version.build < EXCHANGE_2010 and self.attachments: | |
144 | # Exchange 2007 can't save attachments immediately. You need to first save, then attach. Store | |
145 | # the attachment of this item temporarily and attach later. | |
111 | if self.account and self.account.version.build < EXCHANGE_2013 and self.attachments: | |
112 | # At least some versions prior to Exchange 2013 can't save attachments immediately. You need to first | |
113 | # save, then attach. Store the attachment of this item temporarily and attach later. | |
146 | 114 | tmp_attachments, self.attachments = self.attachments, [] |
147 | 115 | item = self._create(message_disposition=SAVE_ONLY, send_meeting_invitations=send_meeting_invitations) |
148 | self.id, self.changekey = item.id, item.changekey | |
116 | self._id = self.ID_ELEMENT_CLS(item.id, item.changekey) | |
149 | 117 | for old_att, new_att in zip(self.attachments, item.attachments): |
150 | 118 | if old_att.attachment_id is not None: |
151 | 119 | raise ValueError("Old 'attachment_id' is not empty") |
157 | 125 | self.attach(tmp_attachments) |
158 | 126 | return self |
159 | 127 | |
128 | @require_account | |
160 | 129 | def _create(self, message_disposition, send_meeting_invitations): |
161 | if not self.account: | |
162 | raise ValueError('%s must have an account' % self.__class__.__name__) | |
163 | # bulk_create() returns an Item because we want to return the ID of both the main item *and* attachments | |
164 | res = self.account.bulk_create( | |
165 | items=[self], folder=self.folder, message_disposition=message_disposition, | |
166 | send_meeting_invitations=send_meeting_invitations) | |
167 | if message_disposition in (SEND_ONLY, SEND_AND_SAVE_COPY): | |
168 | if res: | |
169 | raise ValueError('Got a response in non-save mode') | |
170 | return None | |
171 | if len(res) != 1: | |
172 | raise ValueError('Expected result length 1, but got %s' % res) | |
173 | if isinstance(res[0], Exception): | |
174 | raise res[0] | |
175 | return res[0] | |
130 | # Return a BulkCreateResult because we want to return the ID of both the main item *and* attachments | |
131 | res = CreateItem(account=self.account).get( | |
132 | items=[self], | |
133 | folder=self.folder, | |
134 | message_disposition=message_disposition, | |
135 | send_meeting_invitations=send_meeting_invitations, | |
136 | expect_result=message_disposition not in (SEND_ONLY, SEND_AND_SAVE_COPY), | |
137 | ) | |
138 | if res is None: | |
139 | return | |
140 | return BulkCreateResult.from_xml(elem=res, account=self) | |
176 | 141 | |
177 | 142 | def _update_fieldnames(self): |
178 | 143 | from .contact import Contact, DistributionList |
201 | 166 | update_fieldnames.append(f.name) |
202 | 167 | return update_fieldnames |
203 | 168 | |
169 | @require_account | |
204 | 170 | def _update(self, update_fieldnames, message_disposition, conflict_resolution, send_meeting_invitations): |
205 | if not self.account: | |
206 | raise ValueError('%s must have an account' % self.__class__.__name__) | |
207 | 171 | if not self.changekey: |
208 | 172 | raise ValueError('%s must have changekey' % self.__class__.__name__) |
209 | 173 | if not update_fieldnames: |
210 | 174 | # The fields to update was not specified explicitly. Update all fields where update is possible |
211 | 175 | update_fieldnames = self._update_fieldnames() |
212 | # bulk_update() returns a tuple | |
213 | res = self.account.bulk_update( | |
214 | items=[(self, update_fieldnames)], message_disposition=message_disposition, | |
176 | res = UpdateItem(account=self.account).get( | |
177 | items=[(self, update_fieldnames)], | |
178 | message_disposition=message_disposition, | |
215 | 179 | conflict_resolution=conflict_resolution, |
216 | send_meeting_invitations_or_cancellations=send_meeting_invitations) | |
217 | if message_disposition == SEND_AND_SAVE_COPY: | |
218 | if res: | |
219 | raise ValueError('Got a response in non-save mode') | |
220 | return None | |
221 | if len(res) != 1: | |
222 | raise ValueError('Expected result length 1, but got %s' % res) | |
223 | if isinstance(res[0], Exception): | |
224 | raise res[0] | |
225 | return res[0] | |
226 | ||
180 | send_meeting_invitations_or_cancellations=send_meeting_invitations, | |
181 | suppress_read_receipts=True, | |
182 | expect_result=message_disposition != SEND_AND_SAVE_COPY, | |
183 | ) | |
184 | if res is None: | |
185 | return | |
186 | return Item.id_from_xml(res) | |
187 | ||
188 | @require_id | |
227 | 189 | def refresh(self): |
228 | 190 | # Updates the item based on fresh data from EWS |
229 | if not self.account: | |
230 | raise ValueError('%s must have an account' % self.__class__.__name__) | |
231 | if not self.id: | |
232 | raise ValueError('%s must have an ID' % self.__class__.__name__) | |
233 | res = list(self.account.fetch(ids=[self])) | |
234 | if len(res) != 1: | |
235 | raise ValueError('Expected result length 1, but got %s' % res) | |
236 | if isinstance(res[0], Exception): | |
237 | raise res[0] | |
238 | fresh_item = res[0] | |
239 | if self.id != fresh_item.id: | |
240 | raise ValueError('Unexpected ID of fresh item') | |
191 | from ..folders import Folder | |
192 | additional_fields = { | |
193 | FieldPath(field=f) for f in Folder(root=self.account.root).allowed_item_fields(version=self.account.version) | |
194 | } | |
195 | ||
196 | elem = GetItem(account=self.account).get(items=[self], additional_fields=additional_fields, shape=ID_ONLY) | |
197 | res = Folder.item_model_from_tag(elem.tag).from_xml(elem=elem, account=self.account) | |
198 | if self.id != res.id and not isinstance(self._id, (OccurrenceItemId, RecurringMasterItemId)): | |
199 | # When we refresh an item with an OccurrenceItemId as ID, EWS returns the ID of the occurrence, so | |
200 | # the ID of this item changes. | |
201 | raise ValueError("'id' mismatch in returned update response") | |
241 | 202 | for f in self.FIELDS: |
242 | setattr(self, f.name, getattr(fresh_item, f.name)) | |
203 | setattr(self, f.name, getattr(res, f.name)) | |
243 | 204 | # 'parent_item' should point to 'self', not 'fresh_item'. That way, 'fresh_item' can be garbage collected. |
244 | 205 | for a in self.attachments: |
245 | 206 | a.parent_item = self |
246 | del fresh_item | |
247 | ||
207 | del res | |
208 | ||
209 | @require_id | |
248 | 210 | def copy(self, to_folder): |
249 | if not self.account: | |
250 | raise ValueError('%s must have an account' % self.__class__.__name__) | |
251 | if not self.id: | |
252 | raise ValueError('%s must have an ID' % self.__class__.__name__) | |
253 | res = self.account.bulk_copy(ids=[self], to_folder=to_folder) | |
254 | if not res: | |
211 | res = CopyItem(account=self.account).get( | |
212 | items=[self], | |
213 | to_folder=to_folder, | |
214 | expect_result=None, | |
215 | ) | |
216 | if res is None: | |
255 | 217 | # Assume 'to_folder' is a public folder or a folder in a different mailbox |
256 | 218 | return |
257 | if len(res) != 1: | |
258 | raise ValueError('Expected result length 1, but got %s' % res) | |
259 | if isinstance(res[0], Exception): | |
260 | raise res[0] | |
261 | return res[0] | |
262 | ||
219 | return Item.id_from_xml(res) | |
220 | ||
221 | @require_id | |
263 | 222 | def move(self, to_folder): |
264 | if not self.account: | |
265 | raise ValueError('%s must have an account' % self.__class__.__name__) | |
266 | if not self.id: | |
267 | raise ValueError('%s must have an ID' % self.__class__.__name__) | |
268 | res = self.account.bulk_move(ids=[self], to_folder=to_folder) | |
269 | if not res: | |
223 | res = MoveItem(account=self.account).get( | |
224 | items=[self], | |
225 | to_folder=to_folder, | |
226 | expect_result=None, | |
227 | ) | |
228 | if res is None: | |
270 | 229 | # Assume 'to_folder' is a public folder or a folder in a different mailbox |
271 | self.id, self.changekey = None, None | |
230 | self._id = None | |
272 | 231 | return |
273 | if len(res) != 1: | |
274 | raise ValueError('Expected result length 1, but got %s' % res) | |
275 | if isinstance(res[0], Exception): | |
276 | raise res[0] | |
277 | self.id, self.changekey = res[0] | |
232 | self._id = self.ID_ELEMENT_CLS(*Item.id_from_xml(res)) | |
278 | 233 | self.folder = to_folder |
279 | 234 | |
280 | 235 | def move_to_trash(self, send_meeting_cancellations=SEND_TO_NONE, affected_task_occurrences=ALL_OCCURRENCIES, |
282 | 237 | # Delete and move to the trash folder. |
283 | 238 | self._delete(delete_type=MOVE_TO_DELETED_ITEMS, send_meeting_cancellations=send_meeting_cancellations, |
284 | 239 | affected_task_occurrences=affected_task_occurrences, suppress_read_receipts=suppress_read_receipts) |
285 | self.id, self.changekey = None, None | |
240 | self._id = None | |
286 | 241 | self.folder = self.account.trash |
287 | 242 | |
288 | 243 | def soft_delete(self, send_meeting_cancellations=SEND_TO_NONE, affected_task_occurrences=ALL_OCCURRENCIES, |
290 | 245 | # Delete and move to the dumpster, if it is enabled. |
291 | 246 | self._delete(delete_type=SOFT_DELETE, send_meeting_cancellations=send_meeting_cancellations, |
292 | 247 | affected_task_occurrences=affected_task_occurrences, suppress_read_receipts=suppress_read_receipts) |
293 | self.id, self.changekey = None, None | |
248 | self._id = None | |
294 | 249 | self.folder = self.account.recoverable_items_deletions |
295 | 250 | |
296 | 251 | def delete(self, send_meeting_cancellations=SEND_TO_NONE, affected_task_occurrences=ALL_OCCURRENCIES, |
298 | 253 | # Remove the item permanently. No copies are stored anywhere. |
299 | 254 | self._delete(delete_type=HARD_DELETE, send_meeting_cancellations=send_meeting_cancellations, |
300 | 255 | affected_task_occurrences=affected_task_occurrences, suppress_read_receipts=suppress_read_receipts) |
301 | self.id, self.changekey, self.folder = None, None, None | |
302 | ||
256 | self._id, self.folder = None, None | |
257 | ||
258 | @require_id | |
303 | 259 | def _delete(self, delete_type, send_meeting_cancellations, affected_task_occurrences, suppress_read_receipts): |
304 | if not self.account: | |
305 | raise ValueError('%s must have an account' % self.__class__.__name__) | |
306 | if not self.id: | |
307 | raise ValueError('%s must have an ID' % self.__class__.__name__) | |
308 | res = self.account.bulk_delete( | |
309 | ids=[self], delete_type=delete_type, send_meeting_cancellations=send_meeting_cancellations, | |
310 | affected_task_occurrences=affected_task_occurrences, suppress_read_receipts=suppress_read_receipts) | |
311 | if len(res) != 1: | |
312 | raise ValueError('Expected result length 1, but got %s' % res) | |
313 | if isinstance(res[0], Exception): | |
314 | raise res[0] | |
315 | ||
260 | DeleteItem(account=self.account).get( | |
261 | items=[self], | |
262 | delete_type=delete_type, | |
263 | send_meeting_cancellations=send_meeting_cancellations, | |
264 | affected_task_occurrences=affected_task_occurrences, | |
265 | suppress_read_receipts=suppress_read_receipts, | |
266 | ) | |
267 | ||
268 | @require_id | |
316 | 269 | def archive(self, to_folder): |
317 | if not self.account: | |
318 | raise ValueError('%s must have an account' % self.__class__.__name__) | |
319 | if not self.id: | |
320 | raise ValueError('%s must have an ID' % self.__class__.__name__) | |
321 | res = self.account.bulk_archive(ids=[self], to_folder=to_folder) | |
322 | if len(res) != 1: | |
323 | raise ValueError('Expected result length 1, but got %s' % res) | |
324 | if isinstance(res[0], Exception): | |
325 | raise res[0] | |
326 | return res[0] | |
270 | return ArchiveItem(account=self.account).get(items=[self], to_folder=to_folder) | |
327 | 271 | |
328 | 272 | def attach(self, attachments): |
329 | 273 | """Add an attachment, or a list of attachments, to this item. If the item has already been saved, the |
364 | 308 | if a in self.attachments: |
365 | 309 | self.attachments.remove(a) |
366 | 310 | |
311 | @require_id | |
367 | 312 | def create_forward(self, subject, body, to_recipients, cc_recipients=None, bcc_recipients=None): |
368 | 313 | from .message import ForwardItem |
369 | if not self.account: | |
370 | raise ValueError('%s must have an account' % self.__class__.__name__) | |
371 | if not self.id: | |
372 | raise ValueError('%s must have an ID' % self.__class__.__name__) | |
373 | 314 | return ForwardItem( |
374 | 315 | account=self.account, |
375 | 316 | reference_item_id=ReferenceItemId(id=self.id, changekey=self.changekey), |
388 | 329 | cc_recipients, |
389 | 330 | bcc_recipients, |
390 | 331 | ).send() |
391 | ||
392 | ||
393 | class BulkCreateResult(BaseItem): | |
394 | """A dummy class to store return values from a CreateItem service call""" | |
395 | LOCAL_FIELDS = [ | |
396 | AttachmentField('attachments', field_uri='item:Attachments'), # ItemAttachment or FileAttachment | |
397 | ] | |
398 | FIELDS = BaseItem.FIELDS + LOCAL_FIELDS | |
399 | ||
400 | __slots__ = tuple(f.name for f in LOCAL_FIELDS) | |
401 | ||
402 | def __init__(self, **kwargs): | |
403 | super().__init__(**kwargs) | |
404 | # pylint: disable=access-member-before-definition | |
405 | if self.attachments is None: | |
406 | self.attachments = [] |
0 | 0 | import logging |
1 | 1 | |
2 | 2 | from ..fields import BooleanField, Base64Field, TextField, MailboxField, MailboxListField, CharField |
3 | from ..properties import ReferenceItemId | |
4 | from ..version import EXCHANGE_2010 | |
3 | from ..properties import ReferenceItemId, Fields | |
4 | from ..services import SendItem | |
5 | from ..util import require_account, require_id | |
6 | from ..version import EXCHANGE_2013 | |
5 | 7 | from .base import BaseReplyItem |
6 | 8 | from .item import Item, AUTO_RESOLVE, SEND_TO_NONE, SEND_ONLY, SEND_AND_SAVE_COPY |
7 | 9 | |
13 | 15 | MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/message-ex15websvcsotherref |
14 | 16 | """ |
15 | 17 | ELEMENT_NAME = 'Message' |
16 | LOCAL_FIELDS = [ | |
18 | LOCAL_FIELDS = Fields( | |
17 | 19 | MailboxField('sender', field_uri='message:Sender', is_read_only=True, is_read_only_after_send=True), |
18 | 20 | MailboxListField('to_recipients', field_uri='message:ToRecipients', is_read_only_after_send=True, |
19 | 21 | is_searchable=False), |
37 | 39 | MailboxField('received_by', field_uri='message:ReceivedBy', is_read_only=True), |
38 | 40 | MailboxField('received_representing', field_uri='message:ReceivedRepresenting', is_read_only=True), |
39 | 41 | # Placeholder for ReminderMessageData |
40 | ] | |
42 | ) | |
41 | 43 | FIELDS = Item.FIELDS + LOCAL_FIELDS |
42 | 44 | |
43 | 45 | __slots__ = tuple(f.name for f in LOCAL_FIELDS) |
44 | 46 | |
47 | @require_account | |
45 | 48 | def send(self, save_copy=True, copy_to_folder=None, conflict_resolution=AUTO_RESOLVE, |
46 | 49 | send_meeting_invitations=SEND_TO_NONE): |
47 | 50 | # Only sends a message. The message can either be an existing draft stored in EWS or a new message that does |
48 | 51 | # not yet exist in EWS. |
49 | if not self.account: | |
50 | raise ValueError('%s must have an account' % self.__class__.__name__) | |
52 | if copy_to_folder and not save_copy: | |
53 | raise AttributeError("'save_copy' must be True when 'copy_to_folder' is set") | |
54 | if save_copy and not copy_to_folder: | |
55 | copy_to_folder = self.account.sent # 'Sent' is default EWS behaviour | |
51 | 56 | if self.id: |
52 | res = self.account.bulk_send(ids=[self], save_copy=save_copy, copy_to_folder=copy_to_folder) | |
53 | if len(res) != 1: | |
54 | raise ValueError('Expected result length 1, but got %s' % res) | |
55 | if isinstance(res[0], Exception): | |
56 | raise res[0] | |
57 | SendItem(account=self.account).get(items=[self], saved_item_folder=copy_to_folder) | |
57 | 58 | # The item will be deleted from the original folder |
58 | self.id, self.changekey = None, None | |
59 | self._id = None | |
59 | 60 | self.folder = copy_to_folder |
60 | 61 | return None |
61 | 62 | |
62 | 63 | # New message |
63 | 64 | if copy_to_folder: |
64 | if not save_copy: | |
65 | raise AttributeError("'save_copy' must be True when 'copy_to_folder' is set") | |
66 | 65 | # This would better be done via send_and_save() but lets just support it here |
67 | 66 | self.folder = copy_to_folder |
68 | 67 | return self.send_and_save(conflict_resolution=conflict_resolution, |
69 | 68 | send_meeting_invitations=send_meeting_invitations) |
70 | 69 | |
71 | if self.account.version.build < EXCHANGE_2010 and self.attachments: | |
72 | # Exchange 2007 can't send attachments immediately. You need to first save, then attach, then send. | |
73 | # This is done in send_and_save(). send() will delete the item again. | |
70 | if self.account.version.build < EXCHANGE_2013 and self.attachments: | |
71 | # At least some versions prior to Exchange 2013 can't send attachments immediately. You need to first save, | |
72 | # then attach, then send. This is done in send_and_save(). send() will delete the item again. | |
74 | 73 | self.send_and_save(conflict_resolution=conflict_resolution, |
75 | 74 | send_meeting_invitations=send_meeting_invitations) |
76 | 75 | return None |
77 | 76 | |
78 | res = self._create(message_disposition=SEND_ONLY, send_meeting_invitations=send_meeting_invitations) | |
79 | if res: | |
80 | raise ValueError('Unexpected response in send-only mode') | |
77 | self._create(message_disposition=SEND_ONLY, send_meeting_invitations=send_meeting_invitations) | |
81 | 78 | return None |
82 | 79 | |
83 | 80 | def send_and_save(self, update_fields=None, conflict_resolution=AUTO_RESOLVE, |
91 | 88 | send_meeting_invitations=send_meeting_invitations |
92 | 89 | ) |
93 | 90 | else: |
94 | if self.account.version.build < EXCHANGE_2010 and self.attachments: | |
95 | # Exchange 2007 can't send-and-save attachments immediately. You need to first save, then attach, then | |
96 | # send. This is done in save(). | |
91 | if self.account.version.build < EXCHANGE_2013 and self.attachments: | |
92 | # At least some versions prior to Exchange 2013 can't send-and-save attachments immediately. You need | |
93 | # to first save, then attach, then send. This is done in save(). | |
97 | 94 | self.save(update_fields=update_fields, conflict_resolution=conflict_resolution, |
98 | 95 | send_meeting_invitations=send_meeting_invitations) |
99 | 96 | self.send(save_copy=False, conflict_resolution=conflict_resolution, |
106 | 103 | if res: |
107 | 104 | raise ValueError('Unexpected response in send-only mode') |
108 | 105 | |
106 | @require_id | |
109 | 107 | def create_reply(self, subject, body, to_recipients=None, cc_recipients=None, bcc_recipients=None): |
110 | if not self.account: | |
111 | raise ValueError('%s must have an account' % self.__class__.__name__) | |
112 | if not self.id: | |
113 | raise ValueError('%s must have an ID' % self.__class__.__name__) | |
114 | 108 | if to_recipients is None: |
115 | 109 | if not self.author: |
116 | 110 | raise ValueError("'to_recipients' must be set when message has no 'author'") |
134 | 128 | bcc_recipients |
135 | 129 | ).send() |
136 | 130 | |
131 | @require_id | |
137 | 132 | def create_reply_all(self, subject, body): |
138 | if not self.account: | |
139 | raise ValueError('%s must have an account' % self.__class__.__name__) | |
140 | if not self.id: | |
141 | raise ValueError('%s must have an ID' % self.__class__.__name__) | |
142 | 133 | to_recipients = list(self.to_recipients) if self.to_recipients else [] |
143 | 134 | if self.author: |
144 | 135 | to_recipients.append(self.author) |
0 | 0 | import logging |
1 | 1 | |
2 | 2 | from ..fields import TextField, BodyField, DateTimeField, MailboxField |
3 | from ..properties import Fields | |
3 | 4 | from .item import Item |
4 | 5 | from .message import Message |
5 | 6 | |
11 | 12 | MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/postitem |
12 | 13 | """ |
13 | 14 | ELEMENT_NAME = 'PostItem' |
14 | LOCAL_FIELDS = Message.LOCAL_FIELDS[6:11] + [ | |
15 | LOCAL_FIELDS = Message.LOCAL_FIELDS[6:11] + Fields( | |
15 | 16 | DateTimeField('posted_time', field_uri='postitem:PostedTime', is_read_only=True), |
16 | 17 | TextField('references', field_uri='message:References'), |
17 | 18 | MailboxField('sender', field_uri='message:Sender', is_read_only=True, is_read_only_after_send=True), |
18 | ] | |
19 | ) | |
19 | 20 | FIELDS = Item.FIELDS + LOCAL_FIELDS |
20 | 21 | |
21 | 22 | __slots__ = tuple(f.name for f in LOCAL_FIELDS) |
28 | 29 | # TODO: Untested and unfinished. |
29 | 30 | ELEMENT_NAME = 'PostReplyItem' |
30 | 31 | |
31 | LOCAL_FIELDS = Message.LOCAL_FIELDS + [ | |
32 | LOCAL_FIELDS = Message.LOCAL_FIELDS + Fields( | |
32 | 33 | BodyField('new_body', field_uri='NewBodyContent'), # Accepts and returns Body or HTMLBody instances |
33 | ] | |
34 | ) | |
34 | 35 | # FIELDS on this element only has Item fields up to 'culture' |
35 | 36 | culture_idx = None |
36 | 37 | for i, field in enumerate(Item.FIELDS): |
3 | 3 | from ..ewsdatetime import UTC_NOW |
4 | 4 | from ..fields import BooleanField, IntegerField, DecimalField, TextField, ChoiceField, DateTimeField, Choice, \ |
5 | 5 | CharField, TextListField |
6 | from ..properties import Fields | |
6 | 7 | from .item import Item |
7 | 8 | |
8 | 9 | log = logging.getLogger(__name__) |
15 | 16 | ELEMENT_NAME = 'Task' |
16 | 17 | NOT_STARTED = 'NotStarted' |
17 | 18 | COMPLETED = 'Completed' |
18 | LOCAL_FIELDS = [ | |
19 | LOCAL_FIELDS = Fields( | |
19 | 20 | IntegerField('actual_work', field_uri='task:ActualWork', min=0), |
20 | 21 | DateTimeField('assigned_time', field_uri='task:AssignedTime', is_read_only=True), |
21 | 22 | TextField('billing_information', field_uri='task:BillingInformation'), |
44 | 45 | }, is_required=True, is_searchable=False, default=NOT_STARTED), |
45 | 46 | CharField('status_description', field_uri='task:StatusDescription', is_read_only=True), |
46 | 47 | IntegerField('total_work', field_uri='task:TotalWork', min=0), |
47 | ] | |
48 | ) | |
48 | 49 | FIELDS = Item.FIELDS + LOCAL_FIELDS |
49 | 50 | |
50 | 51 | __slots__ = tuple(f.name for f in LOCAL_FIELDS) |
9 | 9 | from .fields import SubField, TextField, EmailAddressField, ChoiceField, DateTimeField, EWSElementField, MailboxField, \ |
10 | 10 | Choice, BooleanField, IdField, ExtendedPropertyField, IntegerField, TimeField, EnumField, CharField, EmailField, \ |
11 | 11 | EWSElementListField, EnumListField, FreeBusyStatusField, UnknownEntriesField, MessageField, RecipientAddressField, \ |
12 | WEEKDAY_NAMES, FieldPath, Field | |
12 | RoutingTypeField, WEEKDAY_NAMES, FieldPath, Field | |
13 | 13 | from .util import get_xml_attr, create_element, set_xml_value, value_to_xml_text, MNS, TNS |
14 | 14 | from .version import Version, EXCHANGE_2013 |
15 | 15 | |
22 | 22 | |
23 | 23 | class InvalidFieldForVersion(ValueError): |
24 | 24 | pass |
25 | ||
26 | ||
27 | class Fields(list): | |
28 | """A collection type for the FIELDS class attribute. Works like a list but supports fast lookup by name. | |
29 | """ | |
30 | def __init__(self, *fields): | |
31 | super().__init__(fields) | |
32 | self._dict = {} | |
33 | for f in fields: | |
34 | # Check for duplicate field names | |
35 | if f.name in self._dict: | |
36 | raise ValueError('Field %r is a duplicate' % f) | |
37 | self._dict[f.name] = f | |
38 | ||
39 | def __getitem__(self, idx_or_slice): | |
40 | # Support fast lookup by name. Make sure slicing returns an instance of this class | |
41 | if isinstance(idx_or_slice, str): | |
42 | return self._dict[idx_or_slice] | |
43 | if isinstance(idx_or_slice, int): | |
44 | return super().__getitem__(idx_or_slice) | |
45 | res = super().__getitem__(idx_or_slice) | |
46 | return self.__class__(*res) | |
47 | ||
48 | def __add__(self, other): | |
49 | # Make sure addition returns an instance of this class | |
50 | res = super().__add__(other) | |
51 | return self.__class__(*res) | |
52 | ||
53 | def copy(self): | |
54 | return self.__class__(*self) | |
55 | ||
56 | def index_by_name(self, field_name): | |
57 | for i, f in enumerate(self): | |
58 | if f.name == field_name: | |
59 | return i | |
60 | raise ValueError('Unknown field name %r' % field_name) | |
61 | ||
62 | def insert(self, index, field): | |
63 | if field.name in self._dict: | |
64 | raise ValueError('Field %r is a duplicate' % field) | |
65 | super().insert(index, field) | |
66 | self._dict[field.name] = field | |
67 | ||
68 | def remove(self, field): | |
69 | super().remove(field) | |
70 | del self._dict[field.name] | |
25 | 71 | |
26 | 72 | |
27 | 73 | class Body(str): |
97 | 143 | class EWSElement(metaclass=abc.ABCMeta): |
98 | 144 | """Base class for all XML element implementations""" |
99 | 145 | ELEMENT_NAME = None # The name of the XML tag |
100 | FIELDS = [] # A list of attributes supported by this item class, ordered the same way as in EWS documentation | |
146 | FIELDS = Fields() # A list of attributes supported by this item class, ordered the same way as in EWS documentation | |
101 | 147 | NAMESPACE = TNS # The XML tag namespace. Either TNS or MNS |
102 | 148 | |
103 | 149 | _fields_lock = Lock() |
134 | 180 | # Avoid silently accepting spelling errors to field names that are not set via __init__. We need to be able to |
135 | 181 | # set values for predefined and registered fields, whatever non-field attributes this class defines, and |
136 | 182 | # property setters. |
137 | for f in self.FIELDS: | |
138 | if f.name == key: | |
139 | return super().__setattr__(key, value) | |
183 | if key in self.FIELDS: | |
184 | return super().__setattr__(key, value) | |
140 | 185 | if key in self._slots_keys(): |
141 | 186 | return super().__setattr__(key, value) |
142 | 187 | if hasattr(self, key): |
162 | 207 | # Clears an XML element to reduce memory consumption |
163 | 208 | elem.clear() |
164 | 209 | # Don't attempt to clean up previous siblings. We may not have parsed them yet. |
165 | elem.getparent().remove(elem) | |
210 | parent = elem.getparent() | |
211 | if parent is None: | |
212 | return | |
213 | parent.remove(elem) | |
166 | 214 | |
167 | 215 | @classmethod |
168 | 216 | def from_xml(cls, elem, account): |
225 | 273 | |
226 | 274 | @classmethod |
227 | 275 | def get_field_by_fieldname(cls, fieldname): |
228 | for f in cls.FIELDS: | |
229 | if f.name == fieldname: | |
230 | return f | |
231 | raise InvalidField("'%s' is not a valid field name on '%s'" % (fieldname, cls.__name__)) | |
276 | try: | |
277 | return cls.FIELDS[fieldname] | |
278 | except KeyError: | |
279 | raise InvalidField("'%s' is not a valid field name on '%s'" % (fieldname, cls.__name__)) | |
232 | 280 | |
233 | 281 | @classmethod |
234 | 282 | def validate_field(cls, field, version): |
255 | 303 | def add_field(cls, field, insert_after): |
256 | 304 | """Insert a new field at the preferred place in the tuple and update the slots cache""" |
257 | 305 | with cls._fields_lock: |
258 | idx = tuple(f.name for f in cls.FIELDS).index(insert_after) + 1 | |
306 | idx = cls.FIELDS.index_by_name(insert_after) + 1 | |
259 | 307 | # This class may not have its own FIELDS attribute. Make sure not to edit an attribute belonging to a parent |
260 | 308 | # class. |
261 | cls.FIELDS = list(cls.FIELDS) | |
309 | cls.FIELDS = cls.FIELDS.copy() | |
262 | 310 | cls.FIELDS.insert(idx, field) |
263 | 311 | |
264 | 312 | @classmethod |
267 | 315 | with cls._fields_lock: |
268 | 316 | # This class may not have its own FIELDS attribute. Make sure not to edit an attribute belonging to a parent |
269 | 317 | # class. |
270 | cls.FIELDS = list(cls.FIELDS) | |
318 | cls.FIELDS = cls.FIELDS.copy() | |
271 | 319 | cls.FIELDS.remove(field) |
272 | 320 | |
273 | 321 | def __eq__(self, other): |
302 | 350 | """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/internetmessageheader""" |
303 | 351 | ELEMENT_NAME = 'InternetMessageHeader' |
304 | 352 | |
305 | FIELDS = [ | |
353 | FIELDS = Fields( | |
306 | 354 | TextField('name', field_uri='HeaderName', is_attribute=True), |
307 | 355 | SubField('value'), |
308 | ] | |
356 | ) | |
309 | 357 | |
310 | 358 | __slots__ = tuple(f.name for f in FIELDS) |
311 | 359 | |
319 | 367 | |
320 | 368 | ID_ATTR = 'Id' |
321 | 369 | CHANGEKEY_ATTR = 'ChangeKey' |
322 | FIELDS = [ | |
370 | FIELDS = Fields( | |
323 | 371 | IdField('id', field_uri=ID_ATTR, is_required=True), |
324 | 372 | IdField('changekey', field_uri=CHANGEKEY_ATTR, is_required=False), |
325 | ] | |
373 | ) | |
326 | 374 | |
327 | 375 | __slots__ = tuple(f.name for f in FIELDS) |
328 | 376 | |
348 | 396 | |
349 | 397 | ID_ATTR = 'RootItemId' |
350 | 398 | CHANGEKEY_ATTR = 'RootItemChangeKey' |
351 | FIELDS = [ | |
399 | FIELDS = Fields( | |
352 | 400 | IdField('id', field_uri=ID_ATTR, is_required=True), |
353 | 401 | IdField('changekey', field_uri=CHANGEKEY_ATTR, is_required=True), |
354 | ] | |
402 | ) | |
355 | 403 | |
356 | 404 | __slots__ = tuple() |
357 | 405 | |
368 | 416 | """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/conversationid""" |
369 | 417 | ELEMENT_NAME = 'ConversationId' |
370 | 418 | |
371 | FIELDS = [ | |
372 | IdField('id', field_uri=ItemId.ID_ATTR, is_required=True), | |
373 | # Sometimes required, see MSDN link | |
374 | IdField('changekey', field_uri=ItemId.CHANGEKEY_ATTR), | |
375 | ] | |
376 | ||
419 | # ChangeKey attribute is sometimes required, see MSDN link | |
377 | 420 | __slots__ = tuple() |
378 | 421 | |
379 | 422 | |
411 | 454 | __slots__ = tuple() |
412 | 455 | |
413 | 456 | |
457 | class RecurringMasterItemId(ItemId): | |
458 | """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/recurringmasteritemid""" | |
459 | ELEMENT_NAME = 'RecurringMasterItemId' | |
460 | ||
461 | ID_ATTR = 'OccurrenceId' | |
462 | CHANGEKEY_ATTR = 'ChangeKey' | |
463 | FIELDS = Fields( | |
464 | IdField('id', field_uri=ID_ATTR, is_required=True), | |
465 | IdField('changekey', field_uri=CHANGEKEY_ATTR, is_required=False), | |
466 | ) | |
467 | ||
468 | __slots__ = tuple() | |
469 | ||
470 | ||
471 | class OccurrenceItemId(ItemId): | |
472 | """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/occurrenceitemid""" | |
473 | ELEMENT_NAME = 'OccurrenceItemId' | |
474 | ||
475 | ID_ATTR = 'RecurringMasterId' | |
476 | CHANGEKEY_ATTR = 'ChangeKey' | |
477 | FIELDS = Fields( | |
478 | IdField('id', field_uri=ID_ATTR, is_required=True), | |
479 | IdField('changekey', field_uri=CHANGEKEY_ATTR, is_required=False), | |
480 | IntegerField('instance_index', field_uri='InstanceIndex', is_attribute=True, is_required=True, min=1), | |
481 | ) | |
482 | ||
483 | __slots__ = tuple() + ('instance_index',) | |
484 | ||
485 | ||
414 | 486 | class Mailbox(EWSElement): |
415 | 487 | """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/mailbox""" |
416 | 488 | ELEMENT_NAME = 'Mailbox' |
417 | ||
418 | FIELDS = [ | |
489 | MAILBOX = 'Mailbox' | |
490 | ONE_OFF = 'OneOff' | |
491 | MAILBOX_TYPE_CHOICES = { | |
492 | Choice(MAILBOX), Choice('PublicDL'), Choice('PrivateDL'), Choice('Contact'), Choice('PublicFolder'), | |
493 | Choice('Unknown'), Choice(ONE_OFF), Choice('GroupMailbox', supported_from=EXCHANGE_2013) | |
494 | } | |
495 | ||
496 | FIELDS = Fields( | |
419 | 497 | TextField('name', field_uri='Name'), |
420 | 498 | EmailAddressField('email_address', field_uri='EmailAddress'), |
421 | ChoiceField('routing_type', field_uri='RoutingType', choices={Choice('SMTP')}, default='SMTP'), | |
422 | ChoiceField('mailbox_type', field_uri='MailboxType', choices={ | |
423 | Choice('Mailbox'), Choice('PublicDL'), Choice('PrivateDL'), Choice('Contact'), Choice('PublicFolder'), | |
424 | Choice('Unknown'), Choice('OneOff'), Choice('GroupMailbox', supported_from=EXCHANGE_2013) | |
425 | }, default='Mailbox'), | |
499 | RoutingTypeField('routing_type', field_uri='RoutingType'), | |
500 | ChoiceField('mailbox_type', field_uri='MailboxType', choices=MAILBOX_TYPE_CHOICES, default=MAILBOX), | |
426 | 501 | EWSElementField('item_id', value_cls=ItemId, is_read_only=True), |
427 | ] | |
502 | ) | |
428 | 503 | |
429 | 504 | __slots__ = tuple(f.name for f in FIELDS) |
430 | 505 | |
431 | 506 | def clean(self, version=None): |
432 | 507 | super().clean(version=version) |
433 | if not self.email_address and not self.item_id: | |
434 | # See "Remarks" section of | |
508 | ||
509 | if self.mailbox_type != self.ONE_OFF and not self.email_address and not self.item_id: | |
510 | # A OneOff Mailbox (a one-off member of a personal distribution list) may lack these fields, but other | |
511 | # Mailboxes require at least one. See also "Remarks" section of | |
435 | 512 | # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/mailbox |
436 | raise ValueError("Mailbox must have either 'email_address' or 'item_id' set") | |
513 | raise ValueError("Mailbox type %r must have either 'email_address' or 'item_id' set" % self.mailbox_type) | |
437 | 514 | |
438 | 515 | def __hash__(self): |
439 | 516 | # Exchange may add 'mailbox_type' and 'name' on insert. We're satisfied if the item_id or email address matches. |
476 | 553 | MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/mailbox-availability |
477 | 554 | """ |
478 | 555 | ELEMENT_NAME = 'Mailbox' |
479 | FIELDS = [ | |
556 | FIELDS = Fields( | |
480 | 557 | TextField('name', field_uri='Name'), |
481 | 558 | EmailAddressField('email_address', field_uri='Address', is_required=True), |
482 | ChoiceField('routing_type', field_uri='RoutingType', choices={Choice('SMTP')}, default='SMTP'), | |
483 | ] | |
559 | RoutingTypeField('routing_type', field_uri='RoutingType'), | |
560 | ) | |
484 | 561 | |
485 | 562 | __slots__ = tuple(f.name for f in FIELDS) |
486 | 563 | |
511 | 588 | ELEMENT_NAME = 'MailboxData' |
512 | 589 | ATTENDEE_TYPES = {'Optional', 'Organizer', 'Required', 'Resource', 'Room'} |
513 | 590 | |
514 | FIELDS = [ | |
591 | FIELDS = Fields( | |
515 | 592 | EmailField('email'), |
516 | 593 | ChoiceField('attendee_type', field_uri='AttendeeType', choices={Choice(c) for c in ATTENDEE_TYPES}), |
517 | 594 | BooleanField('exclude_conflicts', field_uri='ExcludeConflicts'), |
518 | ] | |
595 | ) | |
519 | 596 | |
520 | 597 | __slots__ = tuple(f.name for f in FIELDS) |
521 | 598 | |
524 | 601 | """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/distinguishedfolderid""" |
525 | 602 | ELEMENT_NAME = 'DistinguishedFolderId' |
526 | 603 | |
527 | FIELDS = [ | |
528 | IdField('id', field_uri=ItemId.ID_ATTR, is_required=True), | |
529 | IdField('changekey', field_uri=ItemId.CHANGEKEY_ATTR), | |
604 | LOCAL_FIELDS = Fields( | |
530 | 605 | MailboxField('mailbox'), |
531 | ] | |
606 | ) | |
607 | FIELDS = ItemId.FIELDS + LOCAL_FIELDS | |
532 | 608 | |
533 | 609 | __slots__ = ('mailbox',) |
534 | 610 | |
543 | 619 | class TimeWindow(EWSElement): |
544 | 620 | """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/timewindow""" |
545 | 621 | ELEMENT_NAME = 'TimeWindow' |
546 | FIELDS = [ | |
622 | FIELDS = Fields( | |
547 | 623 | DateTimeField('start', field_uri='StartTime', is_required=True), |
548 | 624 | DateTimeField('end', field_uri='EndTime', is_required=True), |
549 | ] | |
625 | ) | |
550 | 626 | |
551 | 627 | __slots__ = tuple(f.name for f in FIELDS) |
552 | 628 | |
555 | 631 | """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/freebusyviewoptions""" |
556 | 632 | ELEMENT_NAME = 'FreeBusyViewOptions' |
557 | 633 | REQUESTED_VIEWS = {'MergedOnly', 'FreeBusy', 'FreeBusyMerged', 'Detailed', 'DetailedMerged'} |
558 | FIELDS = [ | |
634 | FIELDS = Fields( | |
559 | 635 | EWSElementField('time_window', value_cls=TimeWindow, is_required=True), |
560 | 636 | # Interval value is in minutes |
561 | 637 | IntegerField('merged_free_busy_interval', field_uri='MergedFreeBusyIntervalInMinutes', min=6, max=1440, |
562 | 638 | default=30, is_required=True), |
563 | 639 | ChoiceField('requested_view', field_uri='RequestedView', choices={Choice(c) for c in REQUESTED_VIEWS}, |
564 | 640 | is_required=True), # Choice('None') is also valid, but only for responses |
565 | ] | |
641 | ) | |
566 | 642 | |
567 | 643 | __slots__ = tuple(f.name for f in FIELDS) |
568 | 644 | |
573 | 649 | |
574 | 650 | RESPONSE_TYPES = {'Unknown', 'Organizer', 'Tentative', 'Accept', 'Decline', 'NoResponseReceived'} |
575 | 651 | |
576 | FIELDS = [ | |
652 | FIELDS = Fields( | |
577 | 653 | MailboxField('mailbox', is_required=True), |
578 | 654 | ChoiceField('response_type', field_uri='ResponseType', choices={Choice(c) for c in RESPONSE_TYPES}, |
579 | 655 | default='Unknown'), |
580 | 656 | DateTimeField('last_response_time', field_uri='LastResponseTime'), |
581 | ] | |
657 | ) | |
582 | 658 | |
583 | 659 | __slots__ = tuple(f.name for f in FIELDS) |
584 | 660 | |
589 | 665 | |
590 | 666 | class TimeZoneTransition(EWSElement): |
591 | 667 | """Base class for StandardTime and DaylightTime classes""" |
592 | FIELDS = [ | |
668 | FIELDS = Fields( | |
593 | 669 | IntegerField('bias', field_uri='Bias', is_required=True), # Offset from the default bias, in minutes |
594 | 670 | TimeField('time', field_uri='Time', is_required=True), |
595 | 671 | IntegerField('occurrence', field_uri='DayOrder', is_required=True), # n'th occurrence of weekday in iso_month |
596 | 672 | IntegerField('iso_month', field_uri='Month', is_required=True), |
597 | 673 | EnumField('weekday', field_uri='DayOfWeek', enum=WEEKDAY_NAMES, is_required=True), |
598 | 674 | # 'Year' is not implemented yet |
599 | ] | |
675 | ) | |
600 | 676 | |
601 | 677 | __slots__ = tuple(f.name for f in FIELDS) |
602 | 678 | |
633 | 709 | """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/timezone-availability""" |
634 | 710 | ELEMENT_NAME = 'TimeZone' |
635 | 711 | |
636 | FIELDS = [ | |
712 | FIELDS = Fields( | |
637 | 713 | IntegerField('bias', field_uri='Bias', is_required=True), # Standard (non-DST) offset from UTC, in minutes |
638 | 714 | EWSElementField('standard_time', value_cls=StandardTime), |
639 | 715 | EWSElementField('daylight_time', value_cls=DaylightTime), |
640 | ] | |
716 | ) | |
641 | 717 | |
642 | 718 | __slots__ = tuple(f.name for f in FIELDS) |
643 | 719 | |
769 | 845 | ELEMENT_NAME = 'CalendarView' |
770 | 846 | NAMESPACE = MNS |
771 | 847 | |
772 | FIELDS = [ | |
848 | FIELDS = Fields( | |
773 | 849 | DateTimeField('start', field_uri='StartDate', is_required=True, is_attribute=True), |
774 | 850 | DateTimeField('end', field_uri='EndDate', is_required=True, is_attribute=True), |
775 | 851 | IntegerField('max_items', field_uri='MaxEntriesReturned', min=1, is_attribute=True), |
776 | ] | |
852 | ) | |
777 | 853 | |
778 | 854 | __slots__ = tuple(f.name for f in FIELDS) |
779 | 855 | |
786 | 862 | class CalendarEventDetails(EWSElement): |
787 | 863 | """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/calendareventdetails""" |
788 | 864 | ELEMENT_NAME = 'CalendarEventDetails' |
789 | FIELDS = [ | |
865 | FIELDS = Fields( | |
790 | 866 | CharField('id', field_uri='ID'), |
791 | 867 | CharField('subject', field_uri='Subject'), |
792 | 868 | CharField('location', field_uri='Location'), |
795 | 871 | BooleanField('is_exception', field_uri='IsException'), |
796 | 872 | BooleanField('is_reminder_set', field_uri='IsReminderSet'), |
797 | 873 | BooleanField('is_private', field_uri='IsPrivate'), |
798 | ] | |
874 | ) | |
799 | 875 | |
800 | 876 | __slots__ = tuple(f.name for f in FIELDS) |
801 | 877 | |
803 | 879 | class CalendarEvent(EWSElement): |
804 | 880 | """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/calendarevent""" |
805 | 881 | ELEMENT_NAME = 'CalendarEvent' |
806 | FIELDS = [ | |
882 | FIELDS = Fields( | |
807 | 883 | DateTimeField('start', field_uri='StartTime'), |
808 | 884 | DateTimeField('end', field_uri='EndTime'), |
809 | 885 | FreeBusyStatusField('busy_type', field_uri='BusyType', is_required=True, default='Busy'), |
810 | 886 | EWSElementField('details', field_uri='CalendarEventDetails', value_cls=CalendarEventDetails), |
811 | ] | |
887 | ) | |
812 | 888 | |
813 | 889 | __slots__ = tuple(f.name for f in FIELDS) |
814 | 890 | |
816 | 892 | class WorkingPeriod(EWSElement): |
817 | 893 | """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/workingperiod""" |
818 | 894 | ELEMENT_NAME = 'WorkingPeriod' |
819 | FIELDS = [ | |
895 | FIELDS = Fields( | |
820 | 896 | EnumListField('weekdays', field_uri='DayOfWeek', enum=WEEKDAY_NAMES, is_required=True), |
821 | 897 | TimeField('start', field_uri='StartTimeInMinutes', is_required=True), |
822 | 898 | TimeField('end', field_uri='EndTimeInMinutes', is_required=True), |
823 | ] | |
899 | ) | |
824 | 900 | |
825 | 901 | __slots__ = tuple(f.name for f in FIELDS) |
826 | 902 | |
829 | 905 | """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/freebusyview""" |
830 | 906 | ELEMENT_NAME = 'FreeBusyView' |
831 | 907 | NAMESPACE = MNS |
832 | FIELDS = [ | |
908 | FIELDS = Fields( | |
833 | 909 | ChoiceField('view_type', field_uri='FreeBusyViewType', choices={ |
834 | 910 | Choice('None'), Choice('MergedOnly'), Choice('FreeBusy'), Choice('FreeBusyMerged'), Choice('Detailed'), |
835 | 911 | Choice('DetailedMerged'), |
842 | 918 | # TimeZone is also inside the WorkingHours element. It contains information about the timezone which the |
843 | 919 | # account is located in. |
844 | 920 | EWSElementField('working_hours_timezone', field_uri='TimeZone', value_cls=TimeZone), |
845 | ] | |
921 | ) | |
846 | 922 | |
847 | 923 | __slots__ = tuple(f.name for f in FIELDS) |
848 | 924 | |
900 | 976 | """ |
901 | 977 | ELEMENT_NAME = 'Member' |
902 | 978 | |
903 | FIELDS = [ | |
979 | FIELDS = Fields( | |
904 | 980 | MailboxField('mailbox', is_required=True), |
905 | 981 | ChoiceField('status', field_uri='Status', choices={ |
906 | 982 | Choice('Unrecognized'), Choice('Normal'), Choice('Demoted') |
907 | 983 | }, default='Normal'), |
908 | ] | |
984 | ) | |
909 | 985 | |
910 | 986 | __slots__ = tuple(f.name for f in FIELDS) |
911 | 987 | |
918 | 994 | """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/userid""" |
919 | 995 | ELEMENT_NAME = 'UserId' |
920 | 996 | |
921 | FIELDS = [ | |
997 | FIELDS = Fields( | |
922 | 998 | CharField('sid', field_uri='SID'), |
923 | 999 | EmailAddressField('primary_smtp_address', field_uri='PrimarySmtpAddress'), |
924 | 1000 | CharField('display_name', field_uri='DisplayName'), |
926 | 1002 | Choice('Default'), Choice('Anonymous') |
927 | 1003 | }), |
928 | 1004 | CharField('external_user_identity', field_uri='ExternalUserIdentity'), |
929 | ] | |
1005 | ) | |
930 | 1006 | |
931 | 1007 | __slots__ = tuple(f.name for f in FIELDS) |
932 | 1008 | |
936 | 1012 | ELEMENT_NAME = 'Permission' |
937 | 1013 | |
938 | 1014 | PERMISSION_ENUM = {Choice('None'), Choice('Owned'), Choice('All')} |
939 | FIELDS = [ | |
1015 | FIELDS = Fields( | |
940 | 1016 | ChoiceField('permission_level', field_uri='PermissionLevel', choices={ |
941 | 1017 | Choice('None'), Choice('Owner'), Choice('PublishingEditor'), Choice('Editor'), Choice('PublishingAuthor'), |
942 | 1018 | Choice('Author'), Choice('NoneditingAuthor'), Choice('Reviewer'), Choice('Contributor'), Choice('Custom') |
952 | 1028 | Choice('None'), Choice('FullDetails') |
953 | 1029 | }, default='None'), |
954 | 1030 | EWSElementField('user_id', field_uri='UserId', value_cls=UserId, is_required=True) |
955 | ] | |
1031 | ) | |
956 | 1032 | |
957 | 1033 | __slots__ = tuple(f.name for f in FIELDS) |
958 | 1034 | |
962 | 1038 | ELEMENT_NAME = 'Permission' |
963 | 1039 | |
964 | 1040 | PERMISSION_ENUM = {Choice('None'), Choice('Owned'), Choice('All')} |
965 | FIELDS = [ | |
1041 | FIELDS = Fields( | |
966 | 1042 | ChoiceField('calendar_permission_level', field_uri='CalendarPermissionLevel', choices={ |
967 | 1043 | Choice('None'), Choice('Owner'), Choice('PublishingEditor'), Choice('Editor'), Choice('PublishingAuthor'), |
968 | 1044 | Choice('Author'), Choice('NoneditingAuthor'), Choice('Reviewer'), Choice('Contributor'), |
969 | 1045 | Choice('FreeBusyTimeOnly'), Choice('FreeBusyTimeAndSubjectAndLocation'), Choice('Custom') |
970 | 1046 | }, default='None'), |
971 | ] + Permission.FIELDS[1:] | |
1047 | ) + Permission.FIELDS[1:] | |
972 | 1048 | |
973 | 1049 | __slots__ = tuple(f.name for f in FIELDS) |
974 | 1050 | |
983 | 1059 | # For simplicity, we implement the two distinct but equally names elements as one class. |
984 | 1060 | ELEMENT_NAME = 'PermissionSet' |
985 | 1061 | |
986 | FIELDS = [ | |
1062 | FIELDS = Fields( | |
987 | 1063 | EWSElementListField('permissions', field_uri='Permissions', value_cls=Permission), |
988 | 1064 | EWSElementListField('calendar_permissions', field_uri='CalendarPermissions', value_cls=CalendarPermission), |
989 | 1065 | UnknownEntriesField('unknown_entries', field_uri='UnknownEntries'), |
990 | ] | |
1066 | ) | |
991 | 1067 | |
992 | 1068 | __slots__ = tuple(f.name for f in FIELDS) |
993 | 1069 | |
996 | 1072 | """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/effectiverights""" |
997 | 1073 | ELEMENT_NAME = 'EffectiveRights' |
998 | 1074 | |
999 | FIELDS = [ | |
1075 | FIELDS = Fields( | |
1000 | 1076 | BooleanField('create_associated', field_uri='CreateAssociated', default=False), |
1001 | 1077 | BooleanField('create_contents', field_uri='CreateContents', default=False), |
1002 | 1078 | BooleanField('create_hierarchy', field_uri='CreateHierarchy', default=False), |
1004 | 1080 | BooleanField('modify', field_uri='Modify', default=False), |
1005 | 1081 | BooleanField('read', field_uri='Read', default=False), |
1006 | 1082 | BooleanField('view_private_items', field_uri='ViewPrivateItems', default=False), |
1007 | ] | |
1083 | ) | |
1008 | 1084 | |
1009 | 1085 | __slots__ = tuple(f.name for f in FIELDS) |
1010 | 1086 | |
1017 | 1093 | PERMISSION_LEVEL_CHOICES = { |
1018 | 1094 | Choice('None'), Choice('Editor'), Choice('Reviewer'), Choice('Author'), Choice('Custom'), |
1019 | 1095 | } |
1020 | FIELDS = [ | |
1096 | FIELDS = Fields( | |
1021 | 1097 | ChoiceField('calendar_folder_permission_level', field_uri='CalendarFolderPermissionLevel', |
1022 | 1098 | choices=PERMISSION_LEVEL_CHOICES, default='None'), |
1023 | 1099 | ChoiceField('tasks_folder_permission_level', field_uri='TasksFolderPermissionLevel', |
1030 | 1106 | choices=PERMISSION_LEVEL_CHOICES, default='None'), |
1031 | 1107 | ChoiceField('journal_folder_permission_level', field_uri='JournalFolderPermissionLevel', |
1032 | 1108 | choices=PERMISSION_LEVEL_CHOICES, default='None'), |
1033 | ] | |
1109 | ) | |
1034 | 1110 | |
1035 | 1111 | __slots__ = tuple(f.name for f in FIELDS) |
1036 | 1112 | |
1040 | 1116 | ELEMENT_NAME = 'DelegateUser' |
1041 | 1117 | NAMESPACE = MNS |
1042 | 1118 | |
1043 | FIELDS = [ | |
1119 | FIELDS = Fields( | |
1044 | 1120 | EWSElementField('user_id', field_uri='UserId', value_cls=UserId), |
1045 | 1121 | EWSElementField('delegate_permissions', field_uri='DelegatePermissions', value_cls=DelegatePermissions), |
1046 | 1122 | BooleanField('receive_copies_of_meeting_messages', field_uri='ReceiveCopiesOfMeetingMessages', default=False), |
1047 | 1123 | BooleanField('view_private_items', field_uri='ViewPrivateItems', default=False), |
1048 | ] | |
1124 | ) | |
1049 | 1125 | |
1050 | 1126 | __slots__ = tuple(f.name for f in FIELDS) |
1051 | 1127 | |
1054 | 1130 | """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/searchablemailbox""" |
1055 | 1131 | ELEMENT_NAME = 'SearchableMailbox' |
1056 | 1132 | |
1057 | FIELDS = [ | |
1133 | FIELDS = Fields( | |
1058 | 1134 | CharField('guid', field_uri='Guid'), |
1059 | 1135 | EmailAddressField('primary_smtp_address', field_uri='PrimarySmtpAddress'), |
1060 | 1136 | BooleanField('is_external', field_uri='IsExternalMailbox'), |
1062 | 1138 | CharField('display_name', field_uri='DisplayName'), |
1063 | 1139 | BooleanField('is_membership_group', field_uri='IsMembershipGroup'), |
1064 | 1140 | CharField('reference_id', field_uri='ReferenceId'), |
1065 | ] | |
1141 | ) | |
1066 | 1142 | |
1067 | 1143 | __slots__ = tuple(f.name for f in FIELDS) |
1068 | 1144 | |
1069 | 1145 | |
1070 | 1146 | class FailedMailbox(EWSElement): |
1071 | 1147 | """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/failedmailbox""" |
1072 | FIELDS = [ | |
1148 | FIELDS = Fields( | |
1073 | 1149 | CharField('mailbox', field_uri='Mailbox'), |
1074 | 1150 | IntegerField('error_code', field_uri='ErrorCode'), |
1075 | 1151 | CharField('error_message', field_uri='ErrorMessage'), |
1076 | 1152 | BooleanField('is_archive', field_uri='IsArchive'), |
1077 | ] | |
1153 | ) | |
1078 | 1154 | |
1079 | 1155 | __slots__ = tuple(f.name for f in FIELDS) |
1080 | 1156 | |
1098 | 1174 | """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/outofoffice""" |
1099 | 1175 | ELEMENT_NAME = 'OutOfOffice' |
1100 | 1176 | |
1101 | FIELDS = [ | |
1177 | FIELDS = Fields( | |
1102 | 1178 | MessageField('reply_body', field_uri='ReplyBody'), |
1103 | 1179 | DateTimeField('start', field_uri='StartTime', is_required=False), |
1104 | 1180 | DateTimeField('end', field_uri='EndTime', is_required=False), |
1105 | ] | |
1181 | ) | |
1106 | 1182 | |
1107 | 1183 | __slots__ = tuple(f.name for f in FIELDS) |
1108 | 1184 | |
1132 | 1208 | ELEMENT_NAME = 'MailTips' |
1133 | 1209 | NAMESPACE = MNS |
1134 | 1210 | |
1135 | FIELDS = [ | |
1211 | FIELDS = Fields( | |
1136 | 1212 | RecipientAddressField('recipient_address'), |
1137 | 1213 | ChoiceField('pending_mail_tips', field_uri='PendingMailTips', choices={Choice(c) for c in MAIL_TIPS_TYPES}), |
1138 | 1214 | EWSElementField('out_of_office', field_uri='OutOfOffice', value_cls=OutOfOffice), |
1144 | 1220 | BooleanField('delivery_restricted', field_uri='DeliveryRestricted'), |
1145 | 1221 | BooleanField('is_moderated', field_uri='IsModerated'), |
1146 | 1222 | BooleanField('invalid_recipient', field_uri='InvalidRecipient'), |
1147 | ] | |
1223 | ) | |
1148 | 1224 | |
1149 | 1225 | __slots__ = tuple(f.name for f in FIELDS) |
1150 | 1226 | |
1162 | 1238 | class AlternateId(EWSElement): |
1163 | 1239 | """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/alternateid""" |
1164 | 1240 | ELEMENT_NAME = 'AlternateId' |
1165 | FIELDS = [ | |
1241 | FIELDS = Fields( | |
1166 | 1242 | CharField('id', field_uri='Id', is_required=True, is_attribute=True), |
1167 | 1243 | ChoiceField('format', field_uri='Format', is_required=True, is_attribute=True, |
1168 | 1244 | choices={Choice(c) for c in ID_FORMATS}), |
1169 | 1245 | EmailAddressField('mailbox', field_uri='Mailbox', is_required=True, is_attribute=True), |
1170 | 1246 | BooleanField('is_archive', field_uri='IsArchive', is_required=False, is_attribute=True), |
1171 | ] | |
1247 | ) | |
1172 | 1248 | |
1173 | 1249 | __slots__ = tuple(f.name for f in FIELDS) |
1174 | 1250 | |
1181 | 1257 | class AlternatePublicFolderId(EWSElement): |
1182 | 1258 | """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/alternatepublicfolderid""" |
1183 | 1259 | ELEMENT_NAME = 'AlternatePublicFolderId' |
1184 | FIELDS = [ | |
1260 | FIELDS = Fields( | |
1185 | 1261 | CharField('folder_id', field_uri='FolderId', is_required=True, is_attribute=True), |
1186 | 1262 | ChoiceField('format', field_uri='Format', is_required=True, is_attribute=True, |
1187 | 1263 | choices={Choice(c) for c in ID_FORMATS}), |
1188 | ] | |
1264 | ) | |
1189 | 1265 | |
1190 | 1266 | __slots__ = tuple(f.name for f in FIELDS) |
1191 | 1267 | |
1195 | 1271 | https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/alternatepublicfolderitemid |
1196 | 1272 | """ |
1197 | 1273 | ELEMENT_NAME = 'AlternatePublicFolderItemId' |
1198 | FIELDS = [ | |
1274 | FIELDS = Fields( | |
1199 | 1275 | CharField('folder_id', field_uri='FolderId', is_required=True, is_attribute=True), |
1200 | 1276 | ChoiceField('format', field_uri='Format', is_required=True, is_attribute=True, |
1201 | 1277 | choices={Choice(c) for c in ID_FORMATS}), |
1202 | 1278 | CharField('item_id', field_uri='ItemId', is_required=True, is_attribute=True), |
1203 | ] | |
1279 | ) | |
1280 | ||
1281 | __slots__ = tuple(f.name for f in FIELDS) | |
1282 | ||
1283 | ||
1284 | class FieldURI(EWSElement): | |
1285 | """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/fielduri""" | |
1286 | ELEMENT_NAME = 'FieldURI' | |
1287 | FIELDS = Fields( | |
1288 | CharField('field_uri', field_uri='FieldURI', is_attribute=True, is_required=True), | |
1289 | ) | |
1290 | ||
1291 | __slots__ = tuple(f.name for f in FIELDS) | |
1292 | ||
1293 | ||
1294 | class IndexedFieldURI(EWSElement): | |
1295 | """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/indexedfielduri""" | |
1296 | ELEMENT_NAME = 'IndexedFieldURI' | |
1297 | FIELDS = Fields( | |
1298 | CharField('field_uri', field_uri='FieldURI', is_attribute=True, is_required=True), | |
1299 | CharField('field_index', field_uri='FieldIndex', is_attribute=True, is_required=True), | |
1300 | ) | |
1301 | ||
1302 | __slots__ = tuple(f.name for f in FIELDS) | |
1303 | ||
1304 | ||
1305 | class ExtendedFieldURI(EWSElement): | |
1306 | """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/extendedfielduri""" | |
1307 | ELEMENT_NAME = 'ExtendedFieldURI' | |
1308 | FIELDS = Fields( | |
1309 | CharField('distinguished_property_set_id', field_uri='DistinguishedPropertySetId', is_attribute=True), | |
1310 | CharField('property_set_id', field_uri='PropertySetId', is_attribute=True), | |
1311 | CharField('property_tag', field_uri='PropertyTag', is_attribute=True), | |
1312 | CharField('property_name', field_uri='PropertyName', is_attribute=True), | |
1313 | CharField('property_id', field_uri='PropertyId', is_attribute=True), | |
1314 | CharField('property_type', field_uri='PropertyType', is_attribute=True), | |
1315 | ) | |
1316 | ||
1317 | __slots__ = tuple(f.name for f in FIELDS) | |
1318 | ||
1319 | ||
1320 | class ExceptionFieldURI(EWSElement): | |
1321 | """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/exceptionfielduri""" | |
1322 | ELEMENT_NAME = 'ExceptionFieldURI' | |
1323 | FIELDS = Fields( | |
1324 | CharField('field_uri', field_uri='FieldURI', is_attribute=True, is_required=True), | |
1325 | ) | |
1204 | 1326 | |
1205 | 1327 | __slots__ = tuple(f.name for f in FIELDS) |
1206 | 1328 | |
1207 | 1329 | |
1208 | 1330 | class IdChangeKeyMixIn(EWSElement): |
1209 | """Base class for classes that have 'id' and 'changekey' fields which are actually attributes on ID element""" | |
1210 | ID_ELEMENT_CLS = ItemId | |
1211 | ||
1212 | FIELDS = [ | |
1213 | IdField('id', field_uri=ID_ELEMENT_CLS.ID_ATTR, is_read_only=True), | |
1214 | IdField('changekey', field_uri=ID_ELEMENT_CLS.CHANGEKEY_ATTR, is_read_only=True), | |
1215 | ] | |
1216 | ||
1217 | __slots__ = tuple(f.name for f in FIELDS) | |
1331 | """Base class for classes that have a concept of 'id' and 'changekey' values. The values are actually stored on | |
1332 | a separate element but we add convenience methods to hide that fact.""" | |
1333 | ID_ELEMENT_CLS = None | |
1334 | ||
1335 | __slots__ = tuple() | |
1336 | ||
1337 | def __init__(self, **kwargs): | |
1338 | _id = self.ID_ELEMENT_CLS(kwargs.pop('id', None), kwargs.pop('changekey', None)) | |
1339 | if _id.id or _id.changekey: | |
1340 | kwargs['_id'] = _id | |
1341 | super().__init__(**kwargs) | |
1342 | ||
1343 | @classmethod | |
1344 | def get_field_by_fieldname(cls, fieldname): | |
1345 | if fieldname in ('id', 'changekey'): | |
1346 | return cls.ID_ELEMENT_CLS.get_field_by_fieldname(fieldname=fieldname) | |
1347 | return super().get_field_by_fieldname(fieldname=fieldname) | |
1348 | ||
1349 | @property | |
1350 | def id(self): | |
1351 | if self._id is None: | |
1352 | return None | |
1353 | return self._id.id | |
1354 | ||
1355 | @id.setter | |
1356 | def id(self, value): | |
1357 | if self._id is None: | |
1358 | self._id = self.ID_ELEMENT_CLS() | |
1359 | self._id.id = value | |
1360 | ||
1361 | @property | |
1362 | def changekey(self): | |
1363 | if self._id is None: | |
1364 | return None | |
1365 | return self._id.changekey | |
1366 | ||
1367 | @changekey.setter | |
1368 | def changekey(self, value): | |
1369 | if self._id is None: | |
1370 | self._id = self.ID_ELEMENT_CLS() | |
1371 | self._id.changekey = value | |
1218 | 1372 | |
1219 | 1373 | @classmethod |
1220 | 1374 | def id_from_xml(cls, elem): |
1375 | # This method must be reasonably fast | |
1221 | 1376 | id_elem = elem.find(cls.ID_ELEMENT_CLS.response_tag()) |
1222 | 1377 | if id_elem is None: |
1223 | 1378 | return None, None |
1224 | 1379 | return id_elem.get(cls.ID_ELEMENT_CLS.ID_ATTR), id_elem.get(cls.ID_ELEMENT_CLS.CHANGEKEY_ATTR) |
1225 | 1380 | |
1226 | @classmethod | |
1227 | def from_xml(cls, elem, account): | |
1228 | # The ID and changekey are actually in an 'ItemId' child element | |
1229 | item_id, changekey = cls.id_from_xml(elem) | |
1230 | kwargs = { | |
1231 | f.name: f.from_xml(elem=elem, account=account) for f in cls.FIELDS if f.name not in ('id', 'changekey') | |
1232 | } | |
1233 | cls._clear(elem) | |
1234 | return cls(id=item_id, changekey=changekey, **kwargs) | |
1381 | def to_id_xml(self, version): | |
1382 | raise NotImplementedError() | |
1235 | 1383 | |
1236 | 1384 | def __eq__(self, other): |
1237 | 1385 | if isinstance(other, tuple): |
5 | 5 | """ |
6 | 6 | import datetime |
7 | 7 | import logging |
8 | from multiprocessing.pool import ThreadPool | |
9 | 8 | import os |
10 | 9 | from threading import Lock |
11 | 10 | from queue import LifoQueue, Empty, Full |
12 | 11 | |
13 | from cached_property import threaded_cached_property | |
14 | 12 | import requests.adapters |
15 | 13 | import requests.sessions |
16 | 14 | import requests.utils |
22 | 20 | from .properties import FreeBusyViewOptions, MailboxData, TimeWindow, TimeZone |
23 | 21 | from .services import GetServerTimeZones, GetRoomLists, GetRooms, ResolveNames, GetUserAvailability, \ |
24 | 22 | GetSearchableMailboxes, ExpandDL, ConvertId |
25 | from .transport import get_auth_instance, get_service_authtype, NTLM, GSSAPI, SSPI, OAUTH2, DEFAULT_HEADERS | |
23 | from .transport import get_auth_instance, get_service_authtype, NTLM, OAUTH2, CREDENTIALS_REQUIRED, DEFAULT_HEADERS | |
26 | 24 | from .version import Version, API_VERSIONS |
27 | 25 | |
28 | 26 | log = logging.getLogger(__name__) |
37 | 35 | |
38 | 36 | # The maximum number of sessions (== TCP connections, see below) we will open to this service endpoint. Keep this |
39 | 37 | # low unless you have an agreement with the Exchange admin on the receiving end to hammer the server and |
40 | # rate-limiting policies have been disabled for the connecting user. | |
41 | SESSION_POOLSIZE = 4 | |
38 | # rate-limiting policies have been disabled for the connecting user. Changing this setting only makes sense if | |
39 | # you are using a thread pool to run multiple concurrent workers in this process. | |
40 | SESSION_POOLSIZE = 1 | |
42 | 41 | # We want only 1 TCP connection per Session object. We may have lots of different credentials hitting the server and |
43 | 42 | # each credential needs its own session (NTLM auth will only send credentials once and then secure the connection, |
44 | 43 | # so a connection can only handle requests for one credential). Having multiple connections ser Session could |
45 | 44 | # quickly exhaust the maximum number of concurrent connections the Exchange server allows from one client. |
46 | 45 | CONNECTIONS_PER_SESSION = 1 |
46 | # The number of times a session may be reused before creating a new session object. 'None' means "infinite". | |
47 | # Discarding sessions after a certain number of usages may limit memory leaks in the Session object. | |
48 | MAX_SESSION_USAGE_COUNT = 100 | |
47 | 49 | # Timeout for HTTP requests |
48 | 50 | TIMEOUT = 120 |
49 | 51 | |
143 | 145 | def get_auth_type(self): |
144 | 146 | # Autodetect and return authentication type |
145 | 147 | raise NotImplementedError() |
146 | ||
147 | @classmethod | |
148 | def get_useragent(cls): | |
149 | if not cls.USERAGENT: | |
150 | # import here to avoid a cyclic import | |
151 | from exchangelib import __version__ | |
152 | cls.USERAGENT = "exchangelib/%s (%s)" % (__version__, requests.utils.default_user_agent()) | |
153 | return cls.USERAGENT | |
154 | 148 | |
155 | 149 | def _create_session_pool(self): |
156 | 150 | # Create a pool to reuse sessions containing connections to the server |
187 | 181 | log.debug('Server %s: Waiting for session', self.server) |
188 | 182 | session = self._session_pool.get(timeout=_timeout) |
189 | 183 | log.debug('Server %s: Got session %s', self.server, session.session_id) |
184 | session.usage_count += 1 | |
190 | 185 | return session |
191 | 186 | except Empty: |
192 | 187 | # This is normal when we have many worker threads starving for available sessions |
195 | 190 | def release_session(self, session): |
196 | 191 | # This should never fail, as we don't have more sessions than the queue contains |
197 | 192 | log.debug('Server %s: Releasing session %s', self.server, session.session_id) |
193 | if self.MAX_SESSION_USAGE_COUNT and session.usage_count > self.MAX_SESSION_USAGE_COUNT: | |
194 | log.debug('Server %s: session %s usage exceeded limit. Discarding', self.server, session.session_id) | |
195 | session = self.renew_session(session) | |
198 | 196 | try: |
199 | 197 | self._session_pool.put(session, block=False) |
200 | 198 | except Full: |
220 | 218 | # application didn't provide an OAuth client secret, so we can't |
221 | 219 | # handle token refreshing for it. |
222 | 220 | with self.credentials.lock: |
223 | if hash(self.credentials) == session.credentials_hash: | |
221 | if self.credentials.sig() == session.credentials_sig: | |
224 | 222 | # Credentials have not been refreshed by another thread: |
225 | 223 | # they're the same as the session was created with. If |
226 | 224 | # this isn't the case, we can just go ahead with a new |
229 | 227 | return self.renew_session(session) |
230 | 228 | |
231 | 229 | def create_session(self): |
232 | with self.credentials.lock: | |
233 | if self.auth_type is None: | |
234 | raise ValueError('Cannot create session without knowing the auth type') | |
235 | if isinstance(self.credentials, OAuth2Credentials): | |
236 | session = self.create_oauth2_session() | |
237 | elif self.credentials: | |
238 | if self.auth_type == NTLM and self.credentials.type == self.credentials.EMAIL: | |
239 | username = '\\' + self.credentials.username | |
230 | if self.auth_type is None: | |
231 | raise ValueError('Cannot create session without knowing the auth type') | |
232 | if self.credentials is None: | |
233 | if self.auth_type in CREDENTIALS_REQUIRED: | |
234 | raise ValueError('Auth type %r requires credentials' % self.auth_type) | |
235 | session = self.raw_session() | |
236 | session.auth = get_auth_instance(auth_type=self.auth_type) | |
237 | else: | |
238 | with self.credentials.lock: | |
239 | if isinstance(self.credentials, OAuth2Credentials): | |
240 | session = self.create_oauth2_session() | |
241 | # Keep track of the credentials used to create this session. If | |
242 | # and when we need to renew credentials (for example, refreshing | |
243 | # an OAuth access token), this lets us easily determine whether | |
244 | # the credentials have already been refreshed in another thread | |
245 | # by the time this session tries. | |
246 | session.credentials_sig = self.credentials.sig() | |
240 | 247 | else: |
241 | username = self.credentials.username | |
242 | session = self.raw_session() | |
243 | session.auth = get_auth_instance(auth_type=self.auth_type, username=username, | |
244 | password=self.credentials.password) | |
245 | else: | |
246 | if self.auth_type not in (GSSAPI, SSPI): | |
247 | raise ValueError('Auth type %r requires credentials' % self.auth_type) | |
248 | session = self.raw_session() | |
249 | session.auth = get_auth_instance(auth_type=self.auth_type) | |
250 | # Keep track of the credentials used to create this session. If | |
251 | # and when we need to renew credentials (for example, refreshing | |
252 | # an OAuth access token), this lets us easily determine whether | |
253 | # the credentials have already been refreshed in another thread | |
254 | # by the time this session tries. | |
255 | session.credentials_hash = hash(self.credentials) | |
248 | if self.auth_type == NTLM and self.credentials.type == self.credentials.EMAIL: | |
249 | username = '\\' + self.credentials.username | |
250 | else: | |
251 | username = self.credentials.username | |
252 | session = self.raw_session() | |
253 | session.auth = get_auth_instance(auth_type=self.auth_type, username=username, | |
254 | password=self.credentials.password) | |
255 | ||
256 | 256 | # Add some extra info |
257 | 257 | session.session_id = sum(map(ord, str(os.urandom(100)))) # Used for debugging messages in services |
258 | session.usage_count = 0 | |
258 | 259 | session.protocol = self |
259 | 260 | log.debug('Server %s: Created session %s', self.server, session.session_id) |
260 | 261 | return session |
328 | 329 | else: |
329 | 330 | session = requests.sessions.Session() |
330 | 331 | session.headers.update(DEFAULT_HEADERS) |
331 | session.headers["User-Agent"] = cls.get_useragent() | |
332 | session.headers['User-Agent'] = cls.USERAGENT | |
332 | 333 | session.mount('http://', adapter=cls.get_adapter()) |
333 | 334 | session.mount('https://', adapter=cls.get_adapter()) |
334 | 335 | return session |
418 | 419 | self.config.version = Version.guess(self, api_version_hint=self._api_version_hint) |
419 | 420 | return self.config.version |
420 | 421 | |
421 | @threaded_cached_property | |
422 | def thread_pool(self): | |
423 | # Used by services to process service requests that are able to run in parallel. Thread pool should be | |
424 | # larger than the connection pool so we have time to process data without idling the connection. | |
425 | # Create the pool as the last thing here, since we may fail in the version or auth type guessing, which would | |
426 | # leave open threads around to be garbage collected. | |
427 | thread_poolsize = 4 * self._session_pool_size | |
428 | return ThreadPool(processes=thread_poolsize) | |
429 | ||
430 | def close(self): | |
431 | log.debug('Server %s: Closing thread pool', self.server) | |
432 | # Close the thread pool before closing the session pool to ensure all sessions are released. | |
433 | if "thread_pool" in self.__dict__: | |
434 | # Calling thread_pool.join() in Python 3.8 will hang forever. This is seen when running a test case that | |
435 | # uses the thread pool, e.g.: python tests/__init__.py MessagesTest.test_export_with_error | |
436 | # I don't know yet why this is happening. | |
437 | self.thread_pool.terminate() | |
438 | del self.__dict__["thread_pool"] | |
439 | super().close() | |
440 | ||
441 | 422 | def get_timezones(self, timezones=None, return_full_timezone_data=False): |
442 | 423 | """ Get timezone definitions from the server |
443 | 424 | |
452 | 433 | def get_free_busy_info(self, accounts, start, end, merged_free_busy_interval=30, requested_view='DetailedMerged'): |
453 | 434 | """ Returns free/busy information for a list of accounts |
454 | 435 | |
455 | :param accounts: A list of (account, attendee_type, exclude_conflicts) tuples, where account is an Account | |
456 | object, attendee_type is a MailboxData.attendee_type choice, and exclude_conflicts is a boolean. | |
436 | :param accounts: A list of (account, attendee_type, exclude_conflicts) tuples, where account is either an | |
437 | Account object or a string, attendee_type is a MailboxData.attendee_type choice, and exclude_conflicts is a | |
438 | boolean. | |
457 | 439 | :param start: The start datetime of the request |
458 | 440 | :param end: The end datetime of the request |
459 | 441 | :param merged_free_busy_interval: The interval, in minutes, of merged free/busy information |
463 | 445 | """ |
464 | 446 | from .account import Account |
465 | 447 | for account, attendee_type, exclude_conflicts in accounts: |
466 | if not isinstance(account, Account): | |
467 | raise ValueError("'accounts' item %r must be an 'Account' instance" % account) | |
448 | if not isinstance(account, (Account, str)): | |
449 | raise ValueError("'accounts' item %r must be an 'Account' or 'str' instance" % account) | |
468 | 450 | if attendee_type not in MailboxData.ATTENDEE_TYPES: |
469 | 451 | raise ValueError("'accounts' item %r must be one of %s" % (attendee_type, MailboxData.ATTENDEE_TYPES)) |
470 | 452 | if not isinstance(exclude_conflicts, bool): |
488 | 470 | for_year=start.year |
489 | 471 | ), |
490 | 472 | mailbox_data=[MailboxData( |
491 | email=account.primary_smtp_address, | |
473 | email=account.primary_smtp_address if isinstance(account, Account) else account, | |
492 | 474 | attendee_type=attendee_type, |
493 | 475 | exclude_conflicts=exclude_conflicts |
494 | 476 | ) for account, attendee_type, exclude_conflicts in accounts], |
515 | 497 | :param shape: |
516 | 498 | :return: A list of Mailbox items or, if return_full_contact_data is True, tuples of (Mailbox, Contact) items |
517 | 499 | """ |
518 | from .items import SHAPE_CHOICES, SEARCH_SCOPE_CHOICES | |
519 | if search_scope: | |
520 | if search_scope not in SEARCH_SCOPE_CHOICES: | |
521 | raise ValueError("'search_scope' %s must be one if %s" % (search_scope, SEARCH_SCOPE_CHOICES)) | |
522 | if shape: | |
523 | if shape not in SHAPE_CHOICES: | |
524 | raise ValueError("'shape' %s must be one if %s" % (shape, SHAPE_CHOICES)) | |
525 | 500 | return list(ResolveNames(protocol=self).call( |
526 | 501 | unresolved_entries=names, return_full_contact_data=return_full_contact_data, search_scope=search_scope, |
527 | 502 | contact_data_shape=shape, |
558 | 533 | :param destination_format: A string |
559 | 534 | :return: a generator of AlternateId, AlternatePublicFolderId or AlternatePublicFolderItemId instances |
560 | 535 | """ |
561 | from .properties import ID_FORMATS, AlternateId, AlternatePublicFolderId, AlternatePublicFolderItemId | |
562 | if destination_format not in ID_FORMATS: | |
563 | raise ValueError("'destination_format' %r must be one of %s" % (destination_format, ID_FORMATS)) | |
536 | from .properties import AlternateId, AlternatePublicFolderId, AlternatePublicFolderItemId | |
564 | 537 | cls_map = {cls.response_tag(): cls for cls in ( |
565 | 538 | AlternateId, AlternatePublicFolderId, AlternatePublicFolderItemId |
566 | 539 | )} |
610 | 583 | super().cert_verify(conn=conn, url=url, verify=False, cert=cert) |
611 | 584 | |
612 | 585 | |
586 | class TLSClientAuth(requests.adapters.HTTPAdapter): | |
587 | """An HTTP adapter that implements Certificate Based Authentication (CBA)""" | |
588 | cert_file = None | |
589 | ||
590 | def init_poolmanager(self, *args, **kwargs): | |
591 | kwargs['cert_file'] = self.cert_file | |
592 | return super().init_poolmanager(*args, **kwargs) | |
593 | ||
594 | ||
613 | 595 | class RetryPolicy: |
614 | 596 | """Stores retry logic used when faced with errors from the server""" |
615 | 597 | @property |
624 | 606 | |
625 | 607 | @back_off_until.setter |
626 | 608 | def back_off_until(self, value): |
609 | raise NotImplementedError() | |
610 | ||
611 | def back_off(self, seconds): | |
627 | 612 | raise NotImplementedError() |
628 | 613 | |
629 | 614 |
131 | 131 | raise InvalidField("Unknown field path %r on folders %s" % (field_path, self.folder_collection.folders)) |
132 | 132 | |
133 | 133 | @property |
134 | def _item_id_field(self): | |
134 | def _id_field(self): | |
135 | 135 | return self._get_field_path('id') |
136 | 136 | |
137 | 137 | @property |
287 | 287 | yield val |
288 | 288 | self._cache = _cache |
289 | 289 | |
290 | """Do not implement __len__. The implementation of list() tries to preallocate memory by calling __len__ on the | |
291 | given sequence, before calling __iter__. If we implemented __len__, we would end up calling FindItems twice, once | |
292 | to get the result of self.count(), an once to return the actual result. | |
293 | ||
294 | Also, according to https://stackoverflow.com/questions/37189968/how-to-have-list-consume-iter-without-calling-len, | |
295 | a __len__ implementation should be cheap. That does not hold for self.count(). | |
296 | ||
290 | 297 | def __len__(self): |
291 | 298 | if self.is_cached: |
292 | 299 | return len(self._cache) |
293 | 300 | # This queryset has no cache yet. Call the optimized counting implementation |
294 | 301 | return self.count() |
302 | """ | |
295 | 303 | |
296 | 304 | def __getitem__(self, idx_or_slice): |
297 | 305 | # Support indexing and slicing. This is non-greedy when possible (slicing start, stop and step are not negative, |
349 | 357 | # _query() will return an iterator of (id, changekey) tuples |
350 | 358 | if self._changekey_field not in self.only_fields: |
351 | 359 | transform_func = id_only_func |
352 | elif self._item_id_field not in self.only_fields: | |
360 | elif self._id_field not in self.only_fields: | |
353 | 361 | transform_func = changekey_only_func |
354 | 362 | else: |
355 | 363 | transform_func = id_and_changekey_func |
543 | 551 | # We allow calling get(id=..., changekey=...) to get a single item, but only if exactly these two |
544 | 552 | # kwargs are present. |
545 | 553 | account = self.folder_collection.account |
546 | item_id = self._item_id_field.field.clean(kwargs['id'], version=account.version) | |
554 | item_id = self._id_field.field.clean(kwargs['id'], version=account.version) | |
547 | 555 | changekey = self._changekey_field.field.clean(kwargs.get('changekey'), version=account.version) |
548 | 556 | items = list(account.fetch(ids=[(item_id, changekey)], only_fields=self.only_fields)) |
549 | 557 | else: |
666 | 674 | def __str__(self): |
667 | 675 | fmt_args = [('q', str(self.q)), ('folders', '[%s]' % ', '.join(str(f) for f in self.folder_collection.folders))] |
668 | 676 | if self.is_cached: |
669 | fmt_args.append(('len', str(len(self)))) | |
677 | fmt_args.append(('len', str(len(self._cache)))) | |
670 | 678 | return self.__class__.__name__ + '(%s)' % ', '.join('%s=%s' % (k, v) for k, v in fmt_args) |
671 | 679 | |
672 | 680 | |
673 | 681 | def _get_value_or_default(item, field_order): |
674 | 682 | # Python can only sort values when <, > and = are implemented for the two types. Try as best we can to sort |
675 | 683 | # items, even when the item may have a None value for the field in question, or when the item is an |
676 | # Exception. If the field to be sorted by does not have a default value, there's really nothing we can do | |
677 | # about it; we'll eventually raise a TypeError. If it does, we sort all None values and exceptions as the | |
678 | # default value. | |
684 | # Exception. If the field to be sorted by does not have a default value, try creating an empty instance if the | |
685 | # field value class. If that doesn't work, there's really nothing we can do about it; we'll raise an error. If | |
686 | # it does, we sort all None values and exceptions as the default value. | |
687 | default = field_order.field_path.field.default or field_order.field_path.field.value_cls() | |
679 | 688 | if isinstance(item, Exception): |
680 | return field_order.field_path.field.default | |
689 | return default | |
681 | 690 | val = field_order.field_path.get_value(item) |
682 | 691 | if val is None: |
683 | return field_order.field_path.field.default | |
692 | return default | |
684 | 693 | return val |
685 | 694 | |
686 | 695 |
0 | 0 | import logging |
1 | 1 | |
2 | 2 | from .fields import IntegerField, EnumField, EnumListField, DateField, DateTimeField, EWSElementField, \ |
3 | MONTHS, WEEK_NUMBERS, WEEKDAYS | |
4 | from .properties import EWSElement, IdChangeKeyMixIn | |
3 | IdElementField, MONTHS, WEEK_NUMBERS, WEEKDAYS | |
4 | from .properties import EWSElement, IdChangeKeyMixIn, ItemId, Fields | |
5 | 5 | |
6 | 6 | log = logging.getLogger(__name__) |
7 | 7 | |
28 | 28 | """ |
29 | 29 | ELEMENT_NAME = 'AbsoluteYearlyRecurrence' |
30 | 30 | |
31 | FIELDS = [ | |
31 | FIELDS = Fields( | |
32 | 32 | # The day of month of an occurrence, in range 1 -> 31. If a particular month has less days than the day_of_month |
33 | 33 | # value, the last day in the month is assumed |
34 | 34 | IntegerField('day_of_month', field_uri='DayOfMonth', min=1, max=31, is_required=True), |
35 | 35 | # The month of the year, from 1 - 12 |
36 | 36 | EnumField('month', field_uri='Month', enum=MONTHS, is_required=True), |
37 | ] | |
37 | ) | |
38 | 38 | |
39 | 39 | __slots__ = tuple(f.name for f in FIELDS) |
40 | 40 | |
47 | 47 | """ |
48 | 48 | ELEMENT_NAME = 'RelativeYearlyRecurrence' |
49 | 49 | |
50 | FIELDS = [ | |
50 | FIELDS = Fields( | |
51 | 51 | # The weekday of the occurrence, as a valid ISO 8601 weekday number in range 1 -> 7 (1 being Monday). |
52 | 52 | # Alternatively, the weekday can be one of the DAY (or 8), WEEK_DAY (or 9) or WEEKEND_DAY (or 10) consts which |
53 | 53 | # is interpreted as the first day, weekday, or weekend day in the month, respectively. |
57 | 57 | EnumField('week_number', field_uri='DayOfWeekIndex', enum=WEEK_NUMBERS, is_required=True), |
58 | 58 | # The month of the year, from 1 - 12 |
59 | 59 | EnumField('month', field_uri='Month', enum=MONTHS, is_required=True), |
60 | ] | |
60 | ) | |
61 | 61 | |
62 | 62 | __slots__ = tuple(f.name for f in FIELDS) |
63 | 63 | |
74 | 74 | """ |
75 | 75 | ELEMENT_NAME = 'AbsoluteMonthlyRecurrence' |
76 | 76 | |
77 | FIELDS = [ | |
77 | FIELDS = Fields( | |
78 | 78 | # Interval, in months, in range 1 -> 99 |
79 | 79 | IntegerField('interval', field_uri='Interval', min=1, max=99, is_required=True), |
80 | 80 | # The day of month of an occurrence, in range 1 -> 31. If a particular month has less days than the day_of_month |
81 | 81 | # value, the last day in the month is assumed |
82 | 82 | IntegerField('day_of_month', field_uri='DayOfMonth', min=1, max=31, is_required=True), |
83 | ] | |
83 | ) | |
84 | 84 | |
85 | 85 | __slots__ = tuple(f.name for f in FIELDS) |
86 | 86 | |
93 | 93 | """ |
94 | 94 | ELEMENT_NAME = 'RelativeMonthlyRecurrence' |
95 | 95 | |
96 | FIELDS = [ | |
96 | FIELDS = Fields( | |
97 | 97 | # Interval, in months, in range 1 -> 99 |
98 | 98 | IntegerField('interval', field_uri='Interval', min=1, max=99, is_required=True), |
99 | 99 | # The weekday of the occurrence, as a valid ISO 8601 weekday number in range 1 -> 7 (1 being Monday). |
103 | 103 | # Week number of the month, in range 1 -> 5. If 5 is specified, this assumes the last week of the month for |
104 | 104 | # months that have only 4 weeks. |
105 | 105 | EnumField('week_number', field_uri='DayOfWeekIndex', enum=WEEK_NUMBERS, is_required=True), |
106 | ] | |
106 | ) | |
107 | 107 | |
108 | 108 | __slots__ = tuple(f.name for f in FIELDS) |
109 | 109 | |
120 | 120 | """ |
121 | 121 | ELEMENT_NAME = 'WeeklyRecurrence' |
122 | 122 | |
123 | FIELDS = [ | |
123 | FIELDS = Fields( | |
124 | 124 | # Interval, in weeks, in range 1 -> 99 |
125 | 125 | IntegerField('interval', field_uri='Interval', min=1, max=99, is_required=True), |
126 | 126 | # List of valid ISO 8601 weekdays, as list of numbers in range 1 -> 7 (1 being Monday) |
127 | 127 | EnumListField('weekdays', field_uri='DaysOfWeek', enum=WEEKDAYS, is_required=True), |
128 | 128 | # The first day of the week. Defaults to Monday |
129 | 129 | EnumField('first_day_of_week', field_uri='FirstDayOfWeek', enum=WEEKDAYS, default=1, is_required=True), |
130 | ] | |
130 | ) | |
131 | 131 | |
132 | 132 | __slots__ = tuple(f.name for f in FIELDS) |
133 | 133 | |
148 | 148 | """ |
149 | 149 | ELEMENT_NAME = 'DailyRecurrence' |
150 | 150 | |
151 | FIELDS = [ | |
151 | FIELDS = Fields( | |
152 | 152 | # Interval, in days, in range 1 -> 999 |
153 | 153 | IntegerField('interval', field_uri='Interval', min=1, max=999, is_required=True), |
154 | ] | |
154 | ) | |
155 | 155 | |
156 | 156 | __slots__ = tuple(f.name for f in FIELDS) |
157 | 157 | |
169 | 169 | """ |
170 | 170 | ELEMENT_NAME = 'NoEndRecurrence' |
171 | 171 | |
172 | FIELDS = [ | |
172 | FIELDS = Fields( | |
173 | 173 | # Start date, as EWSDate |
174 | 174 | DateField('start', field_uri='StartDate', is_required=True), |
175 | ] | |
175 | ) | |
176 | 176 | |
177 | 177 | __slots__ = tuple(f.name for f in FIELDS) |
178 | 178 | |
182 | 182 | """ |
183 | 183 | ELEMENT_NAME = 'EndDateRecurrence' |
184 | 184 | |
185 | FIELDS = [ | |
185 | FIELDS = Fields( | |
186 | 186 | # Start date, as EWSDate |
187 | 187 | DateField('start', field_uri='StartDate', is_required=True), |
188 | 188 | # End date, as EWSDate |
189 | 189 | DateField('end', field_uri='EndDate', is_required=True), |
190 | ] | |
190 | ) | |
191 | 191 | |
192 | 192 | __slots__ = tuple(f.name for f in FIELDS) |
193 | 193 | |
196 | 196 | """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/numberedrecurrence""" |
197 | 197 | ELEMENT_NAME = 'NumberedRecurrence' |
198 | 198 | |
199 | FIELDS = [ | |
199 | FIELDS = Fields( | |
200 | 200 | # Start date, as EWSDate |
201 | 201 | DateField('start', field_uri='StartDate', is_required=True), |
202 | 202 | # The number of occurrences in this pattern, in range 1 -> 999 |
203 | 203 | IntegerField('number', field_uri='NumberOfOccurrences', min=1, max=999, is_required=True), |
204 | ] | |
204 | ) | |
205 | 205 | |
206 | 206 | __slots__ = tuple(f.name for f in FIELDS) |
207 | 207 | |
209 | 209 | class Occurrence(IdChangeKeyMixIn): |
210 | 210 | """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/occurrence""" |
211 | 211 | ELEMENT_NAME = 'Occurrence' |
212 | ||
213 | LOCAL_FIELDS = [ | |
212 | ID_ELEMENT_CLS = ItemId | |
213 | ||
214 | FIELDS = Fields( | |
215 | IdElementField('_id', field_uri='ItemId', value_cls=ID_ELEMENT_CLS), | |
214 | 216 | # The modified start time of the item, as EWSDateTime |
215 | 217 | DateTimeField('start', field_uri='Start'), |
216 | 218 | # The modified end time of the item, as EWSDateTime |
217 | 219 | DateTimeField('end', field_uri='End'), |
218 | 220 | # The original start time of the item, as EWSDateTime |
219 | 221 | DateTimeField('original_start', field_uri='OriginalStart'), |
220 | ] | |
221 | FIELDS = IdChangeKeyMixIn.FIELDS + LOCAL_FIELDS | |
222 | ||
223 | __slots__ = tuple(f.name for f in LOCAL_FIELDS) | |
222 | ) | |
223 | ||
224 | __slots__ = tuple(f.name for f in FIELDS) | |
224 | 225 | |
225 | 226 | |
226 | 227 | # Container elements: |
244 | 245 | """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/deletedoccurrence""" |
245 | 246 | ELEMENT_NAME = 'DeletedOccurrence' |
246 | 247 | |
247 | FIELDS = [ | |
248 | FIELDS = Fields( | |
248 | 249 | # The modified start time of the item, as EWSDateTime |
249 | 250 | DateTimeField('start', field_uri='Start'), |
250 | ] | |
251 | ) | |
251 | 252 | |
252 | 253 | __slots__ = tuple(f.name for f in FIELDS) |
253 | 254 | |
262 | 263 | """ |
263 | 264 | ELEMENT_NAME = 'Recurrence' |
264 | 265 | |
265 | FIELDS = [ | |
266 | FIELDS = Fields( | |
266 | 267 | EWSElementField('pattern', value_cls=Pattern), |
267 | 268 | EWSElementField('boundary', value_cls=Boundary), |
268 | ] | |
269 | ) | |
269 | 270 | |
270 | 271 | __slots__ = tuple(f.name for f in FIELDS) |
271 | 272 |
383 | 383 | # return None. |
384 | 384 | from .indexed_properties import SingleFieldIndexedElement |
385 | 385 | from .extended_properties import ExtendedProperty |
386 | from .fields import DateTimeBackedDateField | |
386 | 387 | # Don't check self.value just yet. We want to return error messages on the field path first, and then the value. |
387 | 388 | # This is done in _get_field_path() and _get_clean_value(), respectively. |
388 | 389 | self._check_integrity() |
399 | 400 | # We allow a filter shortcut of e.g. email_addresses__contains=EmailAddress(label='Foo', ...) instead of |
400 | 401 | # email_addresses__Foo_email_address=.... Set FieldPath label now so we can generate the field_uri. |
401 | 402 | field_path.label = clean_value.label |
403 | elif isinstance(field_path.field, DateTimeBackedDateField): | |
404 | # We need to convert to datetime | |
405 | clean_value = field_path.field.date_to_datetime(clean_value) | |
402 | 406 | elem.append(field_path.to_xml()) |
403 | 407 | constant = create_element('t:Constant') |
404 | 408 | if self.op != self.EXISTS: |
14 | 14 | ErrorCannotDeleteTaskOccurrence, ErrorMimeContentConversionFailed, ErrorRecurrenceHasNoOccurrence, \ |
15 | 15 | ErrorNoPublicFolderReplicaAvailable, MalformedResponseError, ErrorExceededConnectionCount, \ |
16 | 16 | SessionPoolMinSizeReached, ErrorIncorrectSchemaVersion, ErrorInvalidRequest |
17 | from ..properties import FieldURI, IndexedFieldURI, ExtendedFieldURI, ExceptionFieldURI | |
17 | 18 | from ..transport import wrap, extra_headers |
18 | 19 | from ..util import chunkify, create_element, add_xml_child, get_xml_attr, to_xml, post_ratelimited, \ |
19 | 20 | xml_to_str, set_xml_value, SOAPNS, TNS, MNS, ENS, ParseError |
57 | 58 | # @abc.abstractmethod |
58 | 59 | # def get_payload(self, **kwargs): |
59 | 60 | # raise NotImplementedError() |
61 | ||
62 | def get(self, expect_result=True, **kwargs): | |
63 | # Calls the service but expects exactly one result, or None when expect_result=False, or None or exactly one | |
64 | # result when expect_result=None. | |
65 | res = list(self.call(**kwargs)) | |
66 | if expect_result is None and not res: | |
67 | # Allow empty result | |
68 | return | |
69 | if expect_result is False: | |
70 | if res: | |
71 | raise ValueError('Expected result length 0, but got %r', res) | |
72 | return | |
73 | if len(res) != 1: | |
74 | raise ValueError('Expected result length 1, but got %r' % res) | |
75 | if isinstance(res[0], Exception): | |
76 | raise res[0] | |
77 | return res[0] | |
60 | 78 | |
61 | 79 | def _get_elements(self, payload): |
62 | 80 | while True: |
109 | 127 | # guessing tango, but then the server may decide that any arbitrary legacy backend server may actually process |
110 | 128 | # the request for an account. Prepare to handle ErrorInvalidSchemaVersionForMailboxVersion errors and set the |
111 | 129 | # server version per-account. |
130 | from ..credentials import IMPERSONATION, OAuth2Credentials | |
112 | 131 | from ..version import API_VERSIONS |
132 | account_to_impersonate = None | |
133 | timezone = None | |
134 | primary_smtp_address = None | |
113 | 135 | if isinstance(self, EWSAccountService): |
114 | account = self.account | |
115 | 136 | version_hint = self.account.version |
137 | if self.account.access_type == IMPERSONATION: | |
138 | account_to_impersonate = self.account.identity | |
139 | timezone = self.account.default_timezone | |
140 | primary_smtp_address = self.account.primary_smtp_address | |
116 | 141 | else: |
117 | account = None | |
118 | 142 | # We may be here due to version guessing in Protocol.version, so we can't use the Protocol.version property |
119 | 143 | version_hint = self.protocol.config.version |
144 | if isinstance(self.protocol.credentials, OAuth2Credentials): | |
145 | account_to_impersonate = self.protocol.credentials.identity | |
120 | 146 | api_versions = [version_hint.api_version] + [v for v in API_VERSIONS if v != version_hint.api_version] |
121 | 147 | for api_version in api_versions: |
122 | log.debug('Trying API version %s for account %s', api_version, account) | |
148 | log.debug('Trying API version %s', api_version) | |
123 | 149 | r, session = post_ratelimited( |
124 | 150 | protocol=self.protocol, |
125 | 151 | session=self.protocol.get_session(), |
126 | 152 | url=self.protocol.service_endpoint, |
127 | headers=extra_headers(account=account), | |
128 | data=wrap(content=payload, api_version=api_version, account=account), | |
153 | headers=extra_headers(primary_smtp_address=primary_smtp_address), | |
154 | data=wrap( | |
155 | content=payload, | |
156 | api_version=api_version, | |
157 | account_to_impersonate=account_to_impersonate, | |
158 | timezone=timezone, | |
159 | ), | |
129 | 160 | allow_redirects=False, |
130 | 161 | stream=self.streaming, |
131 | 162 | ) |
163 | # TODO: We should only release the session when we have fully consumed the response, but that requires fully | |
164 | # consuming the generator returned by _get_soap_messages. The caller may not always do that. This | |
165 | # seems to work anyway. | |
166 | self.protocol.release_session(session) | |
132 | 167 | if self.streaming: |
133 | 168 | # Let 'requests' decode raw data automatically |
134 | 169 | r.raw.decode_content = True |
135 | else: | |
136 | # If we're streaming, we want to wait to release the session until we have consumed the stream. | |
137 | self.protocol.release_session(session) | |
138 | 170 | try: |
139 | 171 | header, body = self._get_soap_parts(response=r, **parse_opts) |
140 | 172 | except ParseError as e: |
173 | r.close() # Release memory | |
141 | 174 | raise SOAPError('Bad SOAP response: %s' % e) |
142 | 175 | # The body may contain error messages from Exchange, but we still want to collect version info |
143 | 176 | if header is not None: |
147 | 180 | except TransportError as te: |
148 | 181 | log.debug('Failed to update version info (%s)', te) |
149 | 182 | try: |
150 | res = self._get_soap_messages(body=body, **parse_opts) | |
183 | return self._get_soap_messages(body=body, **parse_opts) | |
151 | 184 | except (ErrorInvalidServerVersion, ErrorIncorrectSchemaVersion, ErrorInvalidRequest): |
152 | 185 | # The guessed server version is wrong. Try the next version |
153 | 186 | log.debug('API version %s was invalid', api_version) |
154 | 187 | continue |
155 | 188 | except ErrorInvalidSchemaVersionForMailboxVersion: |
156 | if not account: | |
157 | # This should never happen for non-account services | |
158 | raise ValueError("'account' should not be None") | |
159 | 189 | # The guessed server version is wrong for this account. Try the next version |
160 | log.debug('API version %s was invalid for account %s', api_version, account) | |
190 | log.debug('API version %s was invalid', api_version) | |
161 | 191 | continue |
162 | 192 | except ErrorExceededConnectionCount as e: |
163 | 193 | # ErrorExceededConnectionCount indicates that the connecting user has too many open TCP connections to |
164 | 194 | # the server. Decrease our session pool size. |
165 | if self.streaming: | |
166 | # In streaming mode, we haven't released the session yet, so we can't discard the session | |
167 | raise | |
168 | else: | |
169 | try: | |
170 | self.protocol.decrease_poolsize() | |
171 | continue | |
172 | except SessionPoolMinSizeReached: | |
173 | # We're already as low as we can go. Let the user handle this. | |
174 | raise e | |
195 | try: | |
196 | self.protocol.decrease_poolsize() | |
197 | continue | |
198 | except SessionPoolMinSizeReached: | |
199 | # We're already as low as we can go. Let the user handle this. | |
200 | raise e | |
175 | 201 | except (ErrorTooManyObjectsOpened, ErrorTimeoutExpired) as e: |
176 | 202 | # ErrorTooManyObjectsOpened means there are too many connections to the Exchange database. This is very |
177 | 203 | # often a symptom of sending too many requests. |
187 | 213 | # Re-raise as an ErrorServerBusy with a default delay of 5 minutes |
188 | 214 | raise ErrorServerBusy(msg='Reraised from %s(%s)' % (e.__class__.__name__, e), back_off=300) |
189 | 215 | finally: |
190 | if self.streaming: | |
191 | # TODO: We shouldn't release the session yet if we still haven't fully consumed the stream. It seems | |
192 | # a Session can handle multiple unfinished streaming requests, though. | |
193 | self.protocol.release_session(session) | |
194 | return res | |
195 | if account: | |
196 | raise ErrorInvalidSchemaVersionForMailboxVersion('Tried versions %s but all were invalid for account %s' % | |
197 | (api_versions, account)) | |
216 | if not self.streaming: | |
217 | # In streaming mode, we may not have accessed the raw stream yet. Caller must handle this. | |
218 | r.close() # Release memory | |
219 | ||
220 | if isinstance(self, EWSAccountService): | |
221 | raise ErrorInvalidSchemaVersionForMailboxVersion('Tried versions %s but all were invalid' % api_versions) | |
198 | 222 | raise ErrorInvalidServerVersion('Tried versions %s but all were invalid' % api_versions) |
199 | 223 | |
200 | 224 | def _handle_backoff(self, e): |
338 | 362 | except self.ERRORS_TO_CATCH_IN_RESPONSE as e: |
339 | 363 | return e |
340 | 364 | |
341 | @classmethod | |
342 | def _get_exception(cls, code, text, msg_xml): | |
365 | @staticmethod | |
366 | def _get_exception(code, text, msg_xml): | |
343 | 367 | if not code: |
344 | 368 | return TransportError('Empty ResponseCode in ResponseMessage (MessageText: %s, MessageXml: %s)' % ( |
345 | 369 | text, msg_xml)) |
346 | 370 | if msg_xml is not None: |
347 | 371 | # If this is an ErrorInvalidPropertyRequest error, the xml may contain a specific FieldURI |
348 | for tag_name in ('FieldURI', 'IndexedFieldURI', 'ExtendedFieldURI', 'ExceptionFieldURI'): | |
349 | field_uri_elem = msg_xml.find('{%s}%s' % (TNS, tag_name)) | |
350 | if field_uri_elem is not None: | |
351 | text += ' (field: %s)' % xml_to_str(field_uri_elem) | |
372 | for elem_cls in (FieldURI, IndexedFieldURI, ExtendedFieldURI, ExceptionFieldURI): | |
373 | elem = msg_xml.find(elem_cls.response_tag()) | |
374 | if elem is not None: | |
375 | field_uri = elem_cls.from_xml(elem, account=None) | |
376 | text += ' (field: %s)' % field_uri | |
352 | 377 | # If this is an ErrorInternalServerError error, the xml may contain a more specific error code |
353 | 378 | inner_code, inner_text = None, None |
354 | 379 | for value_elem in msg_xml.findall('{%s}Value' % TNS): |
507 | 532 | class EWSPooledMixIn(EWSService): |
508 | 533 | def _pool_requests(self, payload_func, items, **kwargs): |
509 | 534 | log.debug('Processing items in chunks of %s', self.chunk_size) |
510 | # Chop items list into suitable pieces and let worker threads chew on the work. The order of the output result | |
511 | # list must be the same as the input id list, so the caller knows which status message belongs to which ID. | |
512 | # Yield results as they become available. | |
513 | results = [] | |
514 | n = 0 | |
515 | for chunk in chunkify(items, self.chunk_size): | |
516 | n += 1 | |
517 | log.debug('Starting %s._get_elements worker %s for %s items', self.__class__.__name__, n, len(chunk)) | |
518 | results.append((n, self.protocol.thread_pool.apply_async( | |
519 | lambda c: self._get_elements(payload=payload_func(c, **kwargs)), | |
520 | (chunk,) | |
521 | ))) | |
522 | ||
523 | # Results will be available before iteration has finished if 'items' is a slow generator. Return early | |
524 | while True: | |
525 | if not results: | |
526 | break | |
527 | i, r = results[0] | |
528 | if not r.ready(): | |
529 | # First non-yielded result isn't ready yet. Yielding other ready results would mess up ordering | |
530 | break | |
531 | log.debug('%s._get_elements result %s is ready early', self.__class__.__name__, i) | |
532 | for elem in r.get(): | |
533 | yield elem | |
534 | # Results object has been processed. Remove from list. | |
535 | del results[0] | |
536 | ||
537 | # Yield remaining results in order, as they become available | |
538 | for i, r in results: | |
539 | log.debug('Waiting for %s._get_elements result %s of %s', self.__class__.__name__, i, n) | |
540 | elems = r.get() | |
541 | log.debug('%s._get_elements result %s of %s is ready', self.__class__.__name__, i, n) | |
542 | for elem in elems: | |
535 | # Chop items list into suitable pieces. The order of the output result list must be the same as the input id | |
536 | # list, so the caller knows which status message belongs to which ID. Yield results as they become available. | |
537 | for i, chunk in enumerate(chunkify(items, self.chunk_size), start=1): | |
538 | log.debug('Processing %s chunk %s containing %s items', self.__class__.__name__, i, len(chunk)) | |
539 | for elem in self._get_elements(payload=payload_func(chunk, **kwargs)): | |
543 | 540 | yield elem |
544 | 541 | |
545 | 542 | |
546 | def to_item_id(item, item_cls): | |
543 | def to_item_id(item, item_cls, version): | |
547 | 544 | # Coerce a tuple, dict or object to an 'item_cls' instance. Used to create [Parent][Item|Folder]Id instances from a |
548 | 545 | # variety of input. |
549 | 546 | if isinstance(item, item_cls): |
547 | # Allow any subclass of item_cls, e.g. OccurrenceItemId when ItemId is passed | |
550 | 548 | return item |
549 | from ..folders import BaseFolder | |
550 | from ..items import BaseItem | |
551 | if isinstance(item, (BaseFolder, BaseItem)): | |
552 | return item.to_id_xml(version=version) | |
551 | 553 | if isinstance(item, (tuple, list)): |
552 | 554 | return item_cls(*item) |
553 | 555 | if isinstance(item, dict): |
567 | 569 | |
568 | 570 | |
569 | 571 | def create_folder_ids_element(tag, folders, version): |
570 | from ..folders import BaseFolder, FolderId, DistinguishedFolderId | |
572 | from ..folders import FolderId, DistinguishedFolderId | |
571 | 573 | folder_ids = create_element(tag) |
572 | 574 | for folder in folders: |
573 | 575 | log.debug('Collecting folder %s', folder) |
574 | if not isinstance(folder, (BaseFolder, FolderId, DistinguishedFolderId)): | |
575 | folder = to_item_id(folder, FolderId) | |
576 | if not isinstance(folder, DistinguishedFolderId): | |
577 | folder = to_item_id(folder, FolderId, version=version) | |
576 | 578 | set_xml_value(folder_ids, folder, version=version) |
577 | 579 | if not len(folder_ids): |
578 | 580 | raise ValueError('"folders" must not be empty') |
584 | 586 | item_ids = create_element('m:ItemIds') |
585 | 587 | for item in items: |
586 | 588 | log.debug('Collecting item %s', item) |
587 | set_xml_value(item_ids, to_item_id(item, ItemId), version=version) | |
589 | set_xml_value(item_ids, to_item_id(item, ItemId, version=version), version=version) | |
588 | 590 | if not len(item_ids): |
589 | 591 | raise ValueError('"items" must not be empty') |
590 | 592 | return item_ids |
20 | 20 | if self.protocol.version.build < EXCHANGE_2007_SP1: |
21 | 21 | raise NotImplementedError( |
22 | 22 | '%r is only supported for Exchange 2007 SP1 servers and later' % self.SERVICE_NAME) |
23 | from ..properties import ID_FORMATS | |
24 | if destination_format not in ID_FORMATS: | |
25 | raise ValueError("'destination_format' %r must be one of %s" % (destination_format, ID_FORMATS)) | |
23 | 26 | return self._pool_requests(payload_func=self.get_payload, **dict( |
24 | 27 | items=items, |
25 | 28 | destination_format=destination_format, |
16 | 16 | |
17 | 17 | def get_payload(self, parent_item, items): |
18 | 18 | from ..properties import ParentItemId |
19 | from ..items import BaseItem | |
19 | 20 | payload = create_element('m:%s' % self.SERVICE_NAME) |
20 | parent_id = to_item_id(parent_item, ParentItemId) | |
21 | payload.append(parent_id.to_xml(version=self.account.version)) | |
21 | version = self.account.version | |
22 | if isinstance(parent_item, BaseItem): | |
23 | # to_item_id() would convert this to a normal ItemId, but the service wants a ParentItemId | |
24 | parent_item = ParentItemId(parent_item.id, parent_item.changekey) | |
25 | set_xml_value(payload, to_item_id(parent_item, ParentItemId, version=version), version=version) | |
22 | 26 | attachments = create_element('m:Attachments') |
23 | 27 | for item in items: |
24 | 28 | set_xml_value(attachments, item, version=self.account.version) |
17 | 17 | element_container_name = '{%s}Items' % MNS |
18 | 18 | |
19 | 19 | def call(self, items, folder, message_disposition, send_meeting_invitations): |
20 | from ..folders import BaseFolder, FolderId, DistinguishedFolderId | |
21 | from ..items import SAVE_ONLY, SEND_AND_SAVE_COPY, SEND_ONLY, SEND_MEETING_INVITATIONS_CHOICES, \ | |
22 | MESSAGE_DISPOSITION_CHOICES | |
23 | if message_disposition not in MESSAGE_DISPOSITION_CHOICES: | |
24 | raise ValueError("'message_disposition' %s must be one of %s" % ( | |
25 | message_disposition, MESSAGE_DISPOSITION_CHOICES | |
26 | )) | |
27 | if send_meeting_invitations not in SEND_MEETING_INVITATIONS_CHOICES: | |
28 | raise ValueError("'send_meeting_invitations' %s must be one of %s" % ( | |
29 | send_meeting_invitations, SEND_MEETING_INVITATIONS_CHOICES | |
30 | )) | |
31 | if folder is not None: | |
32 | if not isinstance(folder, (BaseFolder, FolderId, DistinguishedFolderId)): | |
33 | raise ValueError("'folder' %r must be a Folder or FolderId instance" % folder) | |
34 | if folder.account != self.account: | |
35 | raise ValueError('"Folder must belong to this account') | |
36 | if message_disposition == SAVE_ONLY and folder is None: | |
37 | raise AttributeError("Folder must be supplied when in save-only mode") | |
38 | if message_disposition == SEND_AND_SAVE_COPY and folder is None: | |
39 | folder = self.account.sent # 'Sent' is default EWS behaviour | |
40 | if message_disposition == SEND_ONLY and folder is not None: | |
41 | raise AttributeError("Folder must be None in send-ony mode") | |
20 | 42 | return self._pool_requests(payload_func=self.get_payload, **dict( |
21 | 43 | items=items, |
22 | 44 | folder=folder, |
16 | 16 | element_container_name = None # DeleteItem doesn't return a response object, just status in XML attrs |
17 | 17 | |
18 | 18 | def call(self, items, delete_type, send_meeting_cancellations, affected_task_occurrences, suppress_read_receipts): |
19 | from ..items import DELETE_TYPE_CHOICES, SEND_MEETING_CANCELLATIONS_CHOICES, AFFECTED_TASK_OCCURRENCES_CHOICES | |
20 | if delete_type not in DELETE_TYPE_CHOICES: | |
21 | raise ValueError("'delete_type' %s must be one of %s" % ( | |
22 | delete_type, DELETE_TYPE_CHOICES | |
23 | )) | |
24 | if send_meeting_cancellations not in SEND_MEETING_CANCELLATIONS_CHOICES: | |
25 | raise ValueError("'send_meeting_cancellations' %s must be one of %s" % ( | |
26 | send_meeting_cancellations, SEND_MEETING_CANCELLATIONS_CHOICES | |
27 | )) | |
28 | if affected_task_occurrences not in AFFECTED_TASK_OCCURRENCES_CHOICES: | |
29 | raise ValueError("'affected_task_occurrences' %s must be one of %s" % ( | |
30 | affected_task_occurrences, AFFECTED_TASK_OCCURRENCES_CHOICES | |
31 | )) | |
32 | if suppress_read_receipts not in (True, False): | |
33 | raise ValueError("'suppress_read_receipts' %s must be True or False" % suppress_read_receipts) | |
19 | 34 | return self._pool_requests(payload_func=self.get_payload, **dict( |
20 | 35 | items=items, |
21 | 36 | delete_type=delete_type, |
47 | 47 | return super()._get_soap_messages(body, **parse_opts) |
48 | 48 | |
49 | 49 | # 'body' is actually the raw response passed on by '_get_soap_parts' |
50 | r = body | |
50 | 51 | from ..attachments import FileAttachment |
51 | 52 | parser = StreamingBase64Parser() |
52 | 53 | field = FileAttachment.get_field_by_fieldname('_content') |
53 | 54 | handler = StreamingContentHandler(parser=parser, ns=field.namespace, element_name=field.field_uri) |
54 | 55 | parser.setContentHandler(handler) |
55 | return parser.parse(body) | |
56 | return parser.parse(r) | |
56 | 57 | |
57 | 58 | def stream_file_content(self, attachment_id): |
58 | 59 | # The streaming XML parser can only stream content of one attachment |
14 | 14 | '%r is only supported for Exchange 2007 SP1 servers and later' % self.SERVICE_NAME) |
15 | 15 | from ..properties import DLMailbox, DelegateUser # The service expects a Mailbox element in the MNS namespace |
16 | 16 | |
17 | for elem in self._pool_requests( | |
18 | items=user_ids, | |
19 | payload_func=self.get_payload, | |
20 | **dict( | |
17 | if user_ids: | |
18 | # Pool requests to avoid arbitrarily large requests when user_ids is huge | |
19 | res = self._pool_requests( | |
20 | items=user_ids, | |
21 | payload_func=self.get_payload, | |
22 | **dict( | |
23 | mailbox=DLMailbox(email_address=self.account.primary_smtp_address), | |
24 | include_permissions=include_permissions, | |
25 | ) | |
26 | ) | |
27 | else: | |
28 | # Pooling expects an iterable of items but we have None. Just call _get_elements directly. | |
29 | res = self._get_elements(payload=self.get_payload( | |
21 | 30 | mailbox=DLMailbox(email_address=self.account.primary_smtp_address), |
31 | user_ids=user_ids, | |
22 | 32 | include_permissions=include_permissions, |
23 | ) | |
24 | ): | |
33 | )) | |
34 | ||
35 | for elem in res: | |
25 | 36 | if isinstance(elem, Exception): |
26 | 37 | raise elem |
27 | 38 | yield DelegateUser.from_xml(elem=elem, account=self.account) |
19 | 19 | |
20 | 20 | def get_payload(self, persona): |
21 | 21 | from ..properties import PersonaId |
22 | version = self.protocol.version | |
22 | 23 | payload = create_element('m:%s' % self.SERVICE_NAME) |
23 | set_xml_value(payload, to_item_id(persona, PersonaId), version=self.protocol.version) | |
24 | set_xml_value(payload, to_item_id(persona, PersonaId, version=version), version=version) | |
24 | 25 | return payload |
25 | 26 | |
26 | 27 | @classmethod |
9 | 9 | element_container_name = '{%s}Items' % MNS |
10 | 10 | |
11 | 11 | def call(self, items, to_folder): |
12 | from ..folders import BaseFolder, FolderId, DistinguishedFolderId | |
13 | if not isinstance(to_folder, (BaseFolder, FolderId, DistinguishedFolderId)): | |
14 | raise ValueError("'to_folder' %r must be a Folder or FolderId instance" % to_folder) | |
12 | 15 | return self._get_elements(payload=self.get_payload( |
13 | 16 | items=items, |
14 | 17 | to_folder=to_folder, |
15 | 15 | |
16 | 16 | def call(self, unresolved_entries, parent_folders=None, return_full_contact_data=False, search_scope=None, |
17 | 17 | contact_data_shape=None): |
18 | from ..items import Contact | |
18 | from ..items import Contact, SHAPE_CHOICES, SEARCH_SCOPE_CHOICES | |
19 | if search_scope: | |
20 | if search_scope not in SEARCH_SCOPE_CHOICES: | |
21 | raise ValueError("'search_scope' %s must be one if %s" % (search_scope, SEARCH_SCOPE_CHOICES)) | |
22 | if contact_data_shape: | |
23 | if contact_data_shape not in SHAPE_CHOICES: | |
24 | raise ValueError("'shape' %s must be one if %s" % (contact_data_shape, SHAPE_CHOICES)) | |
19 | 25 | from ..properties import Mailbox |
20 | 26 | elements = self._get_elements(payload=self.get_payload( |
21 | 27 | unresolved_entries=unresolved_entries, |
9 | 9 | element_container_name = None # SendItem doesn't return a response object, just status in XML attrs |
10 | 10 | |
11 | 11 | def call(self, items, saved_item_folder): |
12 | from ..folders import BaseFolder, FolderId, DistinguishedFolderId | |
13 | if saved_item_folder and not isinstance(saved_item_folder, (BaseFolder, FolderId, DistinguishedFolderId)): | |
14 | raise ValueError("'saved_item_folder' %r must be a Folder or FolderId instance" % saved_item_folder) | |
12 | 15 | return self._get_elements(payload=self.get_payload(items=items, saved_item_folder=saved_item_folder)) |
13 | 16 | |
14 | 17 | def get_payload(self, items, saved_item_folder): |
9 | 9 | SERVICE_NAME = 'SetUserOofSettings' |
10 | 10 | |
11 | 11 | def call(self, oof_settings, mailbox): |
12 | res = list(self._get_elements(payload=self.get_payload(oof_settings=oof_settings, mailbox=mailbox))) | |
13 | if len(res) != 1: | |
14 | raise ValueError("Expected 'res' length 1, got %s" % res) | |
15 | return res[0] | |
12 | from ..settings import OofSettings | |
13 | from ..properties import Mailbox | |
14 | if not isinstance(oof_settings, OofSettings): | |
15 | raise ValueError("'oof_settings' %r must be an OofSettings instance" % oof_settings) | |
16 | if not isinstance(mailbox, Mailbox): | |
17 | raise ValueError("'mailbox' %r must be an Mailbox instance" % mailbox) | |
18 | return self._get_elements(payload=self.get_payload(oof_settings=oof_settings, mailbox=mailbox)) | |
16 | 19 | |
17 | 20 | def get_payload(self, oof_settings, mailbox): |
18 | 21 | from ..properties import AvailabilityMailbox |
65 | 65 | from ..folders import BaseFolder, FolderId, DistinguishedFolderId |
66 | 66 | updatefolder = create_element('m:%s' % self.SERVICE_NAME) |
67 | 67 | folderchanges = create_element('m:FolderChanges') |
68 | version = self.account.version | |
68 | 69 | for folder, fieldnames in folders: |
69 | log.debug('Updating folder %s', folder) | |
70 | log.debug('Updating folder %s fields %s', folder, fieldnames) | |
70 | 71 | folderchange = create_element('t:FolderChange') |
71 | 72 | if not isinstance(folder, (BaseFolder, FolderId, DistinguishedFolderId)): |
72 | folder = to_item_id(folder, FolderId) | |
73 | set_xml_value(folderchange, folder, version=self.account.version) | |
73 | folder = to_item_id(folder, FolderId, version=version) | |
74 | set_xml_value(folderchange, folder, version=version) | |
74 | 75 | updates = create_element('t:Updates') |
75 | 76 | for elem in self._get_folder_update_elems(folder=folder, fieldnames=fieldnames): |
76 | 77 | updates.append(elem) |
0 | 0 | from collections import OrderedDict |
1 | 1 | import logging |
2 | 2 | |
3 | from ..ewsdatetime import EWSDate | |
3 | 4 | from ..util import create_element, set_xml_value, MNS |
4 | 5 | from ..version import EXCHANGE_2010, EXCHANGE_2013_SP1 |
5 | from .common import EWSAccountService, EWSPooledMixIn | |
6 | from .common import EWSAccountService, EWSPooledMixIn, to_item_id | |
6 | 7 | |
7 | 8 | log = logging.getLogger(__name__) |
8 | 9 | |
16 | 17 | |
17 | 18 | def call(self, items, conflict_resolution, message_disposition, send_meeting_invitations_or_cancellations, |
18 | 19 | suppress_read_receipts): |
20 | from ..items import CONFLICT_RESOLUTION_CHOICES, MESSAGE_DISPOSITION_CHOICES, \ | |
21 | SEND_MEETING_INVITATIONS_AND_CANCELLATIONS_CHOICES, SEND_ONLY | |
22 | if conflict_resolution not in CONFLICT_RESOLUTION_CHOICES: | |
23 | raise ValueError("'conflict_resolution' %s must be one of %s" % ( | |
24 | conflict_resolution, CONFLICT_RESOLUTION_CHOICES | |
25 | )) | |
26 | if message_disposition not in MESSAGE_DISPOSITION_CHOICES: | |
27 | raise ValueError("'message_disposition' %s must be one of %s" % ( | |
28 | message_disposition, MESSAGE_DISPOSITION_CHOICES | |
29 | )) | |
30 | if send_meeting_invitations_or_cancellations not in SEND_MEETING_INVITATIONS_AND_CANCELLATIONS_CHOICES: | |
31 | raise ValueError("'send_meeting_invitations_or_cancellations' %s must be one of %s" % ( | |
32 | send_meeting_invitations_or_cancellations, SEND_MEETING_INVITATIONS_AND_CANCELLATIONS_CHOICES | |
33 | )) | |
34 | if suppress_read_receipts not in (True, False): | |
35 | raise ValueError("'suppress_read_receipts' %s must be True or False" % suppress_read_receipts) | |
36 | if message_disposition == SEND_ONLY: | |
37 | raise ValueError('Cannot send-only existing objects. Use SendItem service instead') | |
19 | 38 | return self._pool_requests(payload_func=self.get_payload, **dict( |
20 | 39 | items=items, |
21 | 40 | conflict_resolution=conflict_resolution, |
87 | 106 | value = field.clean(getattr(item, field.name), version=self.account.version) # Make sure the value is OK |
88 | 107 | if item.__class__ == CalendarItem: |
89 | 108 | # For CalendarItem items where we update 'start' or 'end', we want to send values in the local timezone |
109 | if field.name in ('start', 'end') and type(value) == EWSDate: | |
110 | # EWS always expects a datetime | |
111 | return item.date_to_datetime(field_name=field.name) | |
90 | 112 | if self.account.version.build < EXCHANGE_2010: |
91 | 113 | if field.name in ('start', 'end'): |
92 | 114 | value = value.astimezone(getattr(item, meeting_tz_field.name)) |
158 | 180 | ]) |
159 | 181 | ) |
160 | 182 | itemchanges = create_element('m:ItemChanges') |
183 | version = self.account.version | |
161 | 184 | for item, fieldnames in items: |
162 | 185 | if not fieldnames: |
163 | 186 | raise ValueError('"fieldnames" must not be empty') |
164 | 187 | itemchange = create_element('t:ItemChange') |
165 | log.debug('Updating item %s values %s', item.id, fieldnames) | |
166 | set_xml_value(itemchange, ItemId(item.id, item.changekey), version=self.account.version) | |
188 | log.debug('Updating item %s fields %s', item, fieldnames) | |
189 | set_xml_value(itemchange, to_item_id(item, ItemId, version=version), version=version) | |
167 | 190 | updates = create_element('t:Updates') |
168 | 191 | for elem in self._get_item_update_elems(item=item, fieldnames=fieldnames): |
169 | 192 | updates.append(elem) |
12 | 12 | SERVICE_NAME = 'UploadItems' |
13 | 13 | element_container_name = '{%s}ItemId' % MNS |
14 | 14 | |
15 | def call(self, data): | |
15 | def call(self, items): | |
16 | 16 | # _pool_requests expects 'items', not 'data' |
17 | return self._pool_requests(payload_func=self.get_payload, **dict(items=data)) | |
17 | return self._pool_requests(payload_func=self.get_payload, **dict(items=items)) | |
18 | 18 | |
19 | 19 | def get_payload(self, items): |
20 | 20 | """Upload given items to given account |
0 | 0 | from .ewsdatetime import UTC_NOW |
1 | 1 | from .fields import DateTimeField, MessageField, ChoiceField, Choice |
2 | from .properties import EWSElement, OutOfOffice | |
2 | from .properties import EWSElement, OutOfOffice, Fields | |
3 | 3 | from .util import create_element, set_xml_value |
4 | 4 | |
5 | 5 | |
11 | 11 | ENABLED = 'Enabled' |
12 | 12 | SCHEDULED = 'Scheduled' |
13 | 13 | DISABLED = 'Disabled' |
14 | FIELDS = [ | |
14 | FIELDS = Fields( | |
15 | 15 | ChoiceField('state', field_uri='OofState', is_required=True, |
16 | 16 | choices={Choice(ENABLED), Choice(SCHEDULED), Choice(DISABLED)}), |
17 | 17 | ChoiceField('external_audience', field_uri='ExternalAudience', |
20 | 20 | DateTimeField('end', field_uri='EndTime'), |
21 | 21 | MessageField('internal_reply', field_uri='InternalReply'), |
22 | 22 | MessageField('external_reply', field_uri='ExternalReply'), |
23 | ] | |
23 | ) | |
24 | 24 | |
25 | 25 | __slots__ = tuple(f.name for f in FIELDS) |
26 | 26 |
4 | 4 | import requests_ntlm |
5 | 5 | import requests_oauthlib |
6 | 6 | |
7 | from .credentials import IMPERSONATION | |
8 | 7 | from .errors import UnauthorizedError, TransportError |
9 | 8 | from .util import create_element, add_xml_child, xml_to_str, ns_translation, _may_retry_on_error, _back_off_if_needed, \ |
10 | 9 | DummyResponse, CONNECTION_ERRORS |
19 | 18 | GSSAPI = 'gssapi' |
20 | 19 | SSPI = 'sspi' |
21 | 20 | OAUTH2 = 'OAuth 2.0' |
21 | CBA = 'CBA' # Certificate Based Authentication | |
22 | ||
23 | # The auth types that must be accompanied by a credentials object | |
24 | CREDENTIALS_REQUIRED = (NTLM, BASIC, DIGEST, OAUTH2) | |
22 | 25 | |
23 | 26 | AUTH_TYPE_MAP = { |
24 | 27 | NTLM: requests_ntlm.HttpNtlmAuth, |
25 | 28 | BASIC: requests.auth.HTTPBasicAuth, |
26 | 29 | DIGEST: requests.auth.HTTPDigestAuth, |
27 | 30 | OAUTH2: requests_oauthlib.OAuth2, |
31 | CBA: None, | |
28 | 32 | NOAUTH: None, |
29 | 33 | } |
30 | 34 | try: |
44 | 48 | DEFAULT_HEADERS = {'Content-Type': 'text/xml; charset=%s' % DEFAULT_ENCODING, 'Accept-Encoding': 'gzip, deflate'} |
45 | 49 | |
46 | 50 | |
47 | def extra_headers(account): | |
51 | def extra_headers(primary_smtp_address): | |
48 | 52 | """Generate extra HTTP headers |
49 | 53 | """ |
50 | if account: | |
54 | if primary_smtp_address: | |
51 | 55 | # See |
52 | 56 | # https://blogs.msdn.microsoft.com/webdav_101/2015/05/11/best-practices-ews-authentication-and-access-issues/ |
53 | return {'X-AnchorMailbox': account.primary_smtp_address} | |
57 | return {'X-AnchorMailbox': primary_smtp_address} | |
54 | 58 | return None |
55 | 59 | |
56 | 60 | |
57 | def wrap(content, api_version, account=None): | |
61 | def wrap(content, api_version, account_to_impersonate=None, timezone=None): | |
58 | 62 | """ |
59 | 63 | Generate the necessary boilerplate XML for a raw SOAP request. The XML is specific to the server version. |
60 | 64 | ExchangeImpersonation allows to act as the user we want to impersonate. |
65 | ||
66 | RequestServerVersion element on MSDN: | |
67 | https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/requestserverversion | |
68 | ||
69 | ExchangeImpersonation element on MSDN: | |
70 | https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/exchangeimpersonation | |
71 | ||
72 | TimeZoneContent element on MSDN: | |
73 | https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/timezonecontext | |
61 | 74 | """ |
62 | 75 | envelope = create_element('s:Envelope', nsmap=ns_translation) |
63 | 76 | header = create_element('s:Header') |
64 | 77 | requestserverversion = create_element('t:RequestServerVersion', attrs=dict(Version=api_version)) |
65 | 78 | header.append(requestserverversion) |
66 | if account: | |
67 | if account.access_type == IMPERSONATION: | |
68 | exchangeimpersonation = create_element('t:ExchangeImpersonation') | |
69 | connectingsid = create_element('t:ConnectingSID') | |
70 | add_xml_child(connectingsid, 't:PrimarySmtpAddress', account.primary_smtp_address) | |
71 | exchangeimpersonation.append(connectingsid) | |
72 | header.append(exchangeimpersonation) | |
79 | if account_to_impersonate: | |
80 | exchangeimpersonation = create_element('t:ExchangeImpersonation') | |
81 | connectingsid = create_element('t:ConnectingSID') | |
82 | # We have multiple options for uniquely identifying the user. Here's a prioritized list in accordance with | |
83 | # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/connectingsid | |
84 | for attr, tag in ( | |
85 | ('sid', 'SID'), | |
86 | ('upn', 'PrincipalName'), | |
87 | ('smtp_address', 'SmtpAddress'), | |
88 | ('primary_smtp_address', 'PrimarySmtpAddress'), | |
89 | ): | |
90 | val = getattr(account_to_impersonate, attr) | |
91 | if val: | |
92 | add_xml_child(connectingsid, 't:%s' % tag, val) | |
93 | break | |
94 | exchangeimpersonation.append(connectingsid) | |
95 | header.append(exchangeimpersonation) | |
96 | if timezone: | |
73 | 97 | timezonecontext = create_element('t:TimeZoneContext') |
74 | timezonedefinition = create_element('t:TimeZoneDefinition', attrs=dict(Id=account.default_timezone.ms_id)) | |
98 | timezonedefinition = create_element('t:TimeZoneDefinition', attrs=dict(Id=timezone.ms_id)) | |
75 | 99 | timezonecontext.append(timezonedefinition) |
76 | 100 | header.append(timezonecontext) |
77 | 101 | envelope.append(header) |
118 | 142 | try: |
119 | 143 | r = s.post(url=service_endpoint, headers=headers, data=data, allow_redirects=False, |
120 | 144 | timeout=BaseProtocol.TIMEOUT) |
145 | r.close() # Release memory | |
121 | 146 | break |
122 | 147 | except CONNECTION_ERRORS as e: |
123 | 148 | # Don't retry on TLS errors. They will most likely be persistent. |
2 | 2 | from collections import OrderedDict |
3 | 3 | import datetime |
4 | 4 | from decimal import Decimal |
5 | from functools import wraps | |
5 | 6 | import io |
6 | 7 | import itertools |
7 | 8 | import logging |
10 | 11 | from threading import get_ident |
11 | 12 | import time |
12 | 13 | from urllib.parse import urlparse |
13 | import xml.sax.handler | |
14 | ||
15 | # Import _etree via defusedxml instead of directly from lxml.etree, to silence overly strict linters | |
16 | from defusedxml.lxml import parse, tostring, GlobalParserTLS, RestrictedElement, _etree | |
14 | import xml.sax.handler # nosec | |
15 | ||
16 | import lxml.etree # nosec | |
17 | 17 | from defusedxml.expatreader import DefusedExpatParser |
18 | 18 | from defusedxml.sax import _InputSource |
19 | 19 | import dns.resolver |
30 | 30 | log = logging.getLogger(__name__) |
31 | 31 | |
32 | 32 | |
33 | class ParseError(_etree.ParseError): | |
33 | def require_account(f): | |
34 | @wraps(f) | |
35 | def wrapper(self, *args, **kwargs): | |
36 | if not self.account: | |
37 | raise ValueError('%s must have an account' % self.__class__.__name__) | |
38 | return f(self, *args, **kwargs) | |
39 | return wrapper | |
40 | ||
41 | ||
42 | def require_id(f): | |
43 | @wraps(f) | |
44 | def wrapper(self, *args, **kwargs): | |
45 | if not self.account: | |
46 | raise ValueError('%s must have an account' % self.__class__.__name__) | |
47 | if not self.id: | |
48 | raise ValueError('%s must have an ID' % self.__class__.__name__) | |
49 | return f(self, *args, **kwargs) | |
50 | return wrapper | |
51 | ||
52 | ||
53 | class ParseError(lxml.etree.ParseError): | |
34 | 54 | """Used to wrap lxml ParseError in our own class""" |
35 | 55 | pass |
36 | 56 | |
59 | 79 | ('t', TNS), |
60 | 80 | ]) |
61 | 81 | for item in ns_translation.items(): |
62 | _etree.register_namespace(*item) | |
82 | lxml.etree.register_namespace(*item) | |
63 | 83 | |
64 | 84 | |
65 | 85 | def is_iterable(value, generators_allowed=False): |
123 | 143 | if xml_declaration and not encoding: |
124 | 144 | raise ValueError("'xml_declaration' is not supported when 'encoding' is None") |
125 | 145 | if encoding: |
126 | return tostring(tree, encoding=encoding, xml_declaration=True) | |
127 | return tostring(tree, encoding=str, xml_declaration=False) | |
146 | return lxml.etree.tostring(tree, encoding=encoding, xml_declaration=True) | |
147 | return lxml.etree.tostring(tree, encoding=str, xml_declaration=False) | |
128 | 148 | |
129 | 149 | |
130 | 150 | def get_xml_attr(tree, name): |
190 | 210 | from .version import Version |
191 | 211 | if isinstance(value, (str, bool, bytes, int, Decimal, datetime.time, EWSDate, EWSDateTime)): |
192 | 212 | elem.text = value_to_xml_text(value) |
193 | elif isinstance(value, RestrictedElement): | |
213 | elif isinstance(value, _element_class): | |
194 | 214 | elem.append(value) |
195 | 215 | elif is_iterable(value, generators_allowed=True): |
196 | 216 | for v in value: |
200 | 220 | if not isinstance(version, Version): |
201 | 221 | raise ValueError("'version' %r must be a Version instance" % version) |
202 | 222 | elem.append(v.to_xml(version=version)) |
203 | elif isinstance(v, RestrictedElement): | |
223 | elif isinstance(v, _element_class): | |
204 | 224 | elem.append(v) |
205 | 225 | elif isinstance(v, str): |
206 | 226 | add_xml_child(elem, 't:String', v) |
227 | 247 | if ':' in name: |
228 | 248 | ns, name = name.split(':') |
229 | 249 | name = '{%s}%s' % (ns_translation[ns], name) |
230 | elem = RestrictedElement(nsmap=nsmap) | |
250 | elem = _forgiving_parser.makeelement(name, nsmap=nsmap) | |
231 | 251 | if attrs: |
232 | 252 | # Try hard to keep attribute order, to ensure deterministic output. This simplifies testing. |
233 | 253 | for k, v in attrs.items(): |
234 | 254 | elem.set(k, v) |
235 | elem.tag = name | |
236 | 255 | return elem |
237 | 256 | |
238 | 257 | |
299 | 318 | self.buffer = None |
300 | 319 | self.element_found = None |
301 | 320 | |
302 | def parse(self, source): | |
303 | raw_source = source.raw | |
321 | def parse(self, r): | |
322 | raw_source = r.raw | |
304 | 323 | # Like upstream but yields the return value of self.feed() |
305 | 324 | raw_source = prepare_input_source(raw_source) |
306 | 325 | self.prepareParser(raw_source) |
317 | 336 | buffer = file.read(self._bufsize) |
318 | 337 | # Any remaining data in self.buffer should be padding chars now |
319 | 338 | self.buffer = None |
320 | source.close() | |
339 | r.close() # Release memory | |
321 | 340 | self.close() |
322 | 341 | if not self.element_found: |
323 | 342 | data = bytes(collected_data) |
343 | 362 | self.buffer = [remainder] if remainder else [] |
344 | 363 | |
345 | 364 | |
346 | class ForgivingParser(GlobalParserTLS): | |
347 | parser_config = { | |
348 | 'resolve_entities': False, | |
349 | 'recover': True, # This setting is non-default | |
350 | 'huge_tree': True, # This setting enables parsing huge attachments, mime_content and other large data | |
351 | } | |
352 | ||
353 | ||
354 | _forgiving_parser = ForgivingParser() | |
365 | _forgiving_parser = lxml.etree.XMLParser( | |
366 | resolve_entities=False, # This setting is recommended by lxml for safety | |
367 | recover=True, # This setting is non-default | |
368 | huge_tree=True, # This setting enables parsing huge attachments, mime_content and other large data | |
369 | ) | |
370 | _element_class = _forgiving_parser.makeelement('x').__class__ | |
355 | 371 | |
356 | 372 | |
357 | 373 | class BytesGeneratorIO(io.RawIOBase): |
409 | 425 | stream = io.BytesIO(bytes_content) |
410 | 426 | else: |
411 | 427 | stream = BytesGeneratorIO(bytes_content) |
412 | forgiving_parser = _forgiving_parser.getDefaultParser() | |
413 | 428 | try: |
414 | return parse(stream, parser=forgiving_parser) | |
429 | res = lxml.etree.parse(stream, parser=_forgiving_parser) # nosec | |
415 | 430 | except AssertionError as e: |
416 | 431 | raise ParseError(e.args[0], '<not from file>', -1, 0) |
417 | except _etree.ParseError as e: | |
432 | except lxml.etree.ParseError as e: | |
418 | 433 | if hasattr(e, 'position'): |
419 | 434 | e.lineno, e.offset = e.position |
420 | 435 | if not e.lineno: |
427 | 442 | else: |
428 | 443 | offending_excerpt = offending_line[max(0, e.offset - 20):e.offset + 20] |
429 | 444 | msg = '%s\nOffending text: [...]%s[...]' % (str(e), offending_excerpt) |
430 | raise ParseError(msg, e.lineno, e.offset) | |
445 | raise ParseError(msg, '<not from file>', e.lineno, e.offset) | |
431 | 446 | except TypeError: |
432 | 447 | try: |
433 | 448 | stream.seek(0) |
434 | 449 | except (IndexError, io.UnsupportedOperation): |
435 | 450 | pass |
436 | 451 | raise ParseError('This is not XML: %r' % stream.read(), '<not from file>', -1, 0) |
452 | ||
453 | if res.getroot() is None: | |
454 | try: | |
455 | stream.seek(0) | |
456 | msg = 'No root element found: %r' % stream.read() | |
457 | except (IndexError, io.UnsupportedOperation): | |
458 | msg = 'No root element found' | |
459 | raise ParseError(msg, '<not from file>', -1, 0) | |
460 | return res | |
437 | 461 | |
438 | 462 | |
439 | 463 | def is_xml(text): |
451 | 475 | """A steaming log handler that prettifies log statements containing XML when output is a terminal""" |
452 | 476 | @staticmethod |
453 | 477 | def parse_bytes(xml_bytes): |
454 | return parse(io.BytesIO(xml_bytes)) | |
478 | return lxml.etree.parse(io.BytesIO(xml_bytes), parser=_forgiving_parser) # nosec | |
455 | 479 | |
456 | 480 | @classmethod |
457 | 481 | def prettify_xml(cls, xml_bytes): |
458 | 482 | # Re-formats an XML document to a consistent style |
459 | return tostring( | |
483 | return lxml.etree.tostring( | |
460 | 484 | cls.parse_bytes(xml_bytes), |
461 | 485 | xml_declaration=True, |
462 | 486 | encoding='utf-8', |
504 | 528 | super().__init__(*args, **kwargs) |
505 | 529 | |
506 | 530 | def parse_bytes(self, xml_bytes): |
507 | root = parse(io.BytesIO(xml_bytes)) | |
531 | root = lxml.etree.parse(io.BytesIO(xml_bytes), parser=_forgiving_parser) # nosec | |
508 | 532 | for elem in root.iter(): |
509 | 533 | for attr in set(elem.keys()) & {'RootItemId', 'ItemId', 'Id', 'RootItemChangeKey', 'ChangeKey'}: |
510 | 534 | elem.set(attr, 'DEADBEEF=') |
519 | 543 | |
520 | 544 | |
521 | 545 | class DummyResponse: |
522 | def __init__(self, url, headers, request_headers, content=b'', status_code=503): | |
546 | def __init__(self, url, headers, request_headers, content=b'', status_code=503, history=None): | |
523 | 547 | self.status_code = status_code |
524 | 548 | self.url = url |
525 | 549 | self.headers = headers |
526 | 550 | self.content = content |
527 | 551 | self.text = content.decode('utf-8', errors='ignore') |
528 | 552 | self.request = DummyRequest(headers=request_headers) |
553 | self.history = history | |
529 | 554 | |
530 | 555 | def iter_content(self): |
531 | 556 | return self.content |
557 | ||
558 | def close(self): | |
559 | pass | |
532 | 560 | |
533 | 561 | |
534 | 562 | def get_domain(email): |
707 | 735 | ) |
708 | 736 | log.debug(log_msg, log_vals) |
709 | 737 | if _need_new_credentials(response=r): |
738 | r.close() # Release memory | |
710 | 739 | session = protocol.refresh_credentials(session) |
711 | 740 | continue |
712 | 741 | total_wait = time.monotonic() - t_start |
713 | 742 | if _may_retry_on_error(response=r, retry_policy=protocol.retry_policy, wait=total_wait): |
743 | r.close() # Release memory | |
714 | 744 | log.info("Session %s thread %s: Connection error on URL %s (code %s). Cool down %s secs", |
715 | 745 | session.session_id, thread_id, r.url, r.status_code, wait) |
716 | 746 | protocol.retry_policy.back_off(wait) |
718 | 748 | wait *= 2 # Increase delay for every retry |
719 | 749 | continue |
720 | 750 | if r.status_code in (301, 302): |
721 | if stream: | |
722 | r.close() | |
751 | r.close() # Release memory | |
723 | 752 | url, redirects = _redirect_or_fail(r, redirects, allow_redirects) |
724 | 753 | continue |
725 | 754 | break |
737 | 766 | log.debug('Got status code %s but trying to parse content anyway', r.status_code) |
738 | 767 | elif r.status_code != 200: |
739 | 768 | protocol.retire_session(session) |
740 | try: | |
741 | _raise_response_errors(r, protocol, log_msg, log_vals) # Always raises an exception | |
742 | finally: | |
743 | if stream: | |
744 | r.close() | |
769 | _raise_response_errors(r, protocol, log_msg, log_vals) # Always raises an exception | |
745 | 770 | log.debug('Session %s thread %s: Useful response from %s', session.session_id, thread_id, url) |
746 | 771 | return r, session |
747 | 772 | |
818 | 843 | raise TransportError('The service account is currently locked out') |
819 | 844 | if response.status_code == 401 and protocol.retry_policy.fail_fast: |
820 | 845 | # This is a login failure |
821 | raise UnauthorizedError('Wrong username or password for %s' % response.url) | |
846 | raise UnauthorizedError('Invalid credentials for %s' % response.url) | |
822 | 847 | if 'TimeoutException' in response.headers: |
823 | 848 | raise response.headers['TimeoutException'] |
824 | 849 | # This could be anything. Let higher layers handle this. Add full context for better debugging. |
257 | 257 | api_version_from_server = requested_api_version |
258 | 258 | else: |
259 | 259 | # Trust API version from server response |
260 | log.info('API version "%s" worked but server reports version "%s". Using "%s"', requested_api_version, | |
261 | api_version_from_server, api_version_from_server) | |
260 | log.debug('API version "%s" worked but server reports version "%s". Using "%s"', requested_api_version, | |
261 | api_version_from_server, api_version_from_server) | |
262 | 262 | return cls(build=build, api_version=api_version_from_server) |
263 | 263 | |
264 | 264 | def __eq__(self, other): |
0 | 0 | """ A dict to translate from pytz location name to Windows timezone name. Translations taken from |
1 | 1 | http://unicode.org/repos/cldr/trunk/common/supplemental/windowsZones.xml """ |
2 | import re | |
3 | ||
2 | 4 | import requests |
3 | 5 | |
4 | 6 | from .util import to_xml |
14 | 16 | raise ValueError('Unexpected response: %s' % r) |
15 | 17 | tz_map = {} |
16 | 18 | for e in to_xml(r.content).find('windowsZones').find('mapTimezones').findall('mapZone'): |
17 | for location in e.get('type').split(' '): | |
19 | for location in re.split(r'\s+', e.get('type')): | |
18 | 20 | if e.get('territory') == DEFAULT_TERRITORY or location not in tz_map: |
19 | 21 | # Prefer default territory. This is so MS_TIMEZONE_TO_PYTZ_MAP maps from MS timezone ID back to the |
20 | 22 | # "preferred" region/location timezone name. |
23 | if not location: | |
24 | raise ValueError('Expected location') | |
21 | 25 | tz_map[location] = e.get('other'), e.get('territory') |
22 | 26 | return tz_map |
23 | 27 | |
119 | 123 | 'America/Cuiaba': ('Central Brazilian Standard Time', '001'), |
120 | 124 | 'America/Curacao': ('SA Western Standard Time', 'CW'), |
121 | 125 | 'America/Danmarkshavn': ('UTC', 'GL'), |
122 | 'America/Dawson': ('Pacific Standard Time', 'CA'), | |
126 | 'America/Dawson': ('US Mountain Standard Time', 'CA'), | |
123 | 127 | 'America/Dawson_Creek': ('US Mountain Standard Time', 'CA'), |
124 | 128 | 'America/Denver': ('Mountain Standard Time', '001'), |
125 | 129 | 'America/Detroit': ('Eastern Standard Time', 'US'), |
224 | 228 | 'America/Toronto': ('Eastern Standard Time', 'CA'), |
225 | 229 | 'America/Tortola': ('SA Western Standard Time', 'VG'), |
226 | 230 | 'America/Vancouver': ('Pacific Standard Time', 'CA'), |
227 | 'America/Whitehorse': ('Pacific Standard Time', 'CA'), | |
231 | 'America/Whitehorse': ('US Mountain Standard Time', 'CA'), | |
228 | 232 | 'America/Winnipeg': ('Central Standard Time', 'CA'), |
229 | 233 | 'America/Yakutat': ('Alaskan Standard Time', 'US'), |
230 | 234 | 'America/Yellowknife': ('Mountain Standard Time', 'CA'), |
45 | 45 | 'sspi': ['requests_negotiate_sspi'], # Only for Win32 environments |
46 | 46 | 'complete': ['requests_kerberos', 'requests_negotiate_sspi'], # Only for Win32 environments |
47 | 47 | }, |
48 | packages=find_packages(exclude=('tests',)), | |
48 | packages=find_packages(exclude=('tests', 'tests.*')), | |
49 | 49 | tests_require=['PyYAML', 'requests_mock', 'psutil', 'flake8'], |
50 | 50 | python_requires=">=3.5", |
51 | 51 | test_suite='tests', |
19 | 19 | from exchangelib.fields import BooleanField, IntegerField, DecimalField, TextField, EmailAddressField, URIField, \ |
20 | 20 | ChoiceField, BodyField, DateTimeField, Base64Field, PhoneNumberField, EmailAddressesField, TimeZoneField, \ |
21 | 21 | PhysicalAddressField, ExtendedPropertyField, MailboxField, AttendeesField, AttachmentField, CharListField, \ |
22 | MailboxListField, EWSElementField, CultureField, CharField, TextListField, PermissionSetField, MimeContentField | |
22 | MailboxListField, EWSElementField, CultureField, CharField, TextListField, PermissionSetField, MimeContentField, \ | |
23 | DateField, DateTimeBackedDateField | |
23 | 24 | from exchangelib.indexed_properties import EmailAddress, PhysicalAddress, PhoneNumber |
24 | 25 | from exchangelib.properties import Attendee, Mailbox, PermissionSet, Permission, UserId |
25 | 26 | from exchangelib.protocol import BaseProtocol, NoVerifyHTTPAdapter, FaultTolerance |
26 | 27 | from exchangelib.recurrence import Recurrence, DailyPattern |
28 | from exchangelib.util import DummyResponse | |
27 | 29 | |
28 | 30 | mock_account = namedtuple('mock_account', ('protocol', 'version')) |
29 | 31 | mock_protocol = namedtuple('mock_protocol', ('version', 'service_endpoint')) |
31 | 33 | |
32 | 34 | |
33 | 35 | def mock_post(url, status_code, headers, text=''): |
34 | req = namedtuple('request', ['headers'])(headers={}) | |
35 | c = text.encode('utf-8') | |
36 | return lambda **kwargs: namedtuple( | |
37 | 'response', ['status_code', 'headers', 'text', 'content', 'request', 'history', 'url'] | |
38 | )(status_code=status_code, headers=headers, text=text, content=c, request=req, history=None, url=url) | |
36 | return lambda **kwargs: DummyResponse( | |
37 | url=url, headers=headers, request_headers={}, content=text.encode('utf-8'), status_code=status_code | |
38 | ) | |
39 | 39 | |
40 | 40 | |
41 | 41 | def mock_session_exception(exc_cls): |
45 | 45 | return raise_exc |
46 | 46 | |
47 | 47 | |
48 | class MockResponse: | |
48 | class MockResponse(DummyResponse): | |
49 | 49 | def __init__(self, c): |
50 | self.c = c | |
51 | ||
52 | def iter_content(self): | |
53 | return self.c | |
50 | super().__init__(url='', headers={}, request_headers={}, content=c) | |
54 | 51 | |
55 | 52 | |
56 | 53 | class TimedTestCase(unittest.TestCase): |
158 | 155 | return get_random_decimal(field.min or 1, field.max or 99) |
159 | 156 | if isinstance(field, IntegerField): |
160 | 157 | return get_random_int(field.min or 0, field.max or 256) |
158 | if isinstance(field, DateField): | |
159 | return get_random_date() | |
160 | if isinstance(field, DateTimeBackedDateField): | |
161 | return get_random_date() | |
161 | 162 | if isinstance(field, DateTimeField): |
162 | 163 | return get_random_datetime(tz=self.account.default_timezone) |
163 | 164 | if isinstance(field, AttachmentField): |
85 | 85 | item = Message(folder=self.account.inbox, subject='XXX', categories=self.categories).save() |
86 | 86 | attachment = FileAttachment(name='pickle_me.txt', content=b'') |
87 | 87 | for o in ( |
88 | Credentials('XXX', 'YYY'), | |
89 | 88 | FaultTolerance(max_wait=3600), |
90 | 89 | self.account.protocol, |
91 | 90 | attachment, |
107 | 106 | self.assertEqual(self.account.mail_tips.recipient_address, self.account.primary_smtp_address) |
108 | 107 | |
109 | 108 | def test_delegate(self): |
110 | # The test server does not have any delegate info. Mock instead. | |
109 | # The test server does not have any delegate info. Test that account.delegates works, and mock to test parsing | |
110 | # of a non-empty response. | |
111 | self.assertGreaterEqual( | |
112 | len(self.account.delegates), | |
113 | 0 | |
114 | ) | |
115 | ||
111 | 116 | xml = b'''<?xml version="1.0" encoding="utf-8"?> |
112 | 117 | <soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/" |
113 | 118 | xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" |
200 | 205 | |
201 | 206 | def _mock2(response, protocol, log_msg, log_vals): |
202 | 207 | if response.status_code == 401: |
203 | raise UnauthorizedError('Wrong username or password for %s' % response.url) | |
208 | raise UnauthorizedError('Invalid credentials for %s' % response.url) | |
204 | 209 | return _orig2(response, protocol, log_msg, log_vals) |
205 | 210 | |
206 | 211 | exchangelib.util._may_retry_on_error = _mock1 |
4 | 4 | from exchangelib.services import GetAttachment |
5 | 5 | from exchangelib.util import chunkify, TNS |
6 | 6 | |
7 | from .test_items import BaseItemTest | |
7 | from .test_items.test_basics import BaseItemTest | |
8 | 8 | from .common import get_random_string |
9 | 9 | |
10 | 10 |
0 | from exchangelib import Credentials | |
0 | import pickle | |
1 | ||
2 | from exchangelib import Credentials, OAuth2Credentials, OAuth2AuthorizationCodeCredentials, Identity | |
1 | 3 | |
2 | 4 | from .common import TimedTestCase |
3 | 5 | |
18 | 20 | self.assertEqual(Credentials('a', 'b').type, Credentials.UPN) |
19 | 21 | self.assertEqual(Credentials('a@example.com', 'b').type, Credentials.EMAIL) |
20 | 22 | self.assertEqual(Credentials('a\\n', 'b').type, Credentials.DOMAIN) |
23 | ||
24 | def test_pickle(self): | |
25 | # Test that we can pickle, hash, repr, str and compare various credentials types | |
26 | for o in ( | |
27 | Credentials('XXX', 'YYY'), | |
28 | OAuth2Credentials('XXX', 'YYY', 'ZZZZ'), | |
29 | OAuth2Credentials('XXX', 'YYY', 'ZZZZ', identity=Identity('AAA')), | |
30 | OAuth2AuthorizationCodeCredentials(), | |
31 | OAuth2AuthorizationCodeCredentials('WWW', 'XXX', 'YYY', {'access_token': 'ZZZ'}), | |
32 | ): | |
33 | with self.subTest(o=o): | |
34 | pickled_o = pickle.dumps(o) | |
35 | unpickled_o = pickle.loads(pickled_o) | |
36 | self.assertIsInstance(unpickled_o, type(o)) | |
37 | self.assertEqual(o, unpickled_o) | |
38 | self.assertEqual(hash(o), hash(unpickled_o)) | |
39 | self.assertEqual(repr(o), repr(unpickled_o)) | |
40 | self.assertEqual(str(o), str(unpickled_o)) |
2 | 2 | from exchangelib.folders import Inbox |
3 | 3 | |
4 | 4 | from .common import get_random_int |
5 | from .test_items import BaseItemTest | |
5 | from .test_items.test_basics import BaseItemTest | |
6 | 6 | |
7 | 7 | |
8 | 8 | class ExtendedPropertyTest(BaseItemTest): |
7 | 7 | Base64Field, TimeZoneField, ExtendedPropertyField, CharListField, Choice, DateField, EnumField, EnumListField, \ |
8 | 8 | CharField |
9 | 9 | from exchangelib.indexed_properties import SingleFieldIndexedElement |
10 | from exchangelib.properties import Fields | |
10 | 11 | from exchangelib.version import EXCHANGE_2007, EXCHANGE_2010, EXCHANGE_2013 |
11 | 12 | from exchangelib.util import to_xml, TNS |
12 | 13 | |
226 | 227 | def test_single_field_indexed_element(self): |
227 | 228 | # A SingleFieldIndexedElement must have only one field defined |
228 | 229 | class TestField(SingleFieldIndexedElement): |
229 | FIELDS = [CharField('a'), CharField('b')] | |
230 | FIELDS = Fields(CharField('a'), CharField('b')) | |
230 | 231 | |
231 | 232 | with self.assertRaises(ValueError): |
232 | 233 | TestField.value_field() |
273 | 273 | def test_glob(self): |
274 | 274 | self.assertGreaterEqual(len(list(self.account.root.glob('*'))), 5) |
275 | 275 | self.assertEqual(len(list(self.account.contacts.glob('GAL*'))), 1) |
276 | self.assertEqual(len(list(self.account.contacts.glob('gal*'))), 1) # Test case-insensitivity | |
276 | 277 | self.assertGreaterEqual(len(list(self.account.contacts.glob('/'))), 5) |
277 | 278 | self.assertGreaterEqual(len(list(self.account.contacts.glob('../*'))), 5) |
278 | 279 | self.assertEqual(len(list(self.account.root.glob('**/%s' % self.account.contacts.name))), 1) |
0 | import datetime | |
1 | from decimal import Decimal | |
2 | from keyword import kwlist | |
3 | import time | |
4 | import unittest | |
5 | import unittest.util | |
6 | ||
7 | from dateutil.relativedelta import relativedelta | |
8 | from exchangelib.errors import ErrorItemNotFound, ErrorUnsupportedPathForQuery, ErrorInvalidValueForProperty, \ | |
9 | ErrorPropertyUpdate, ErrorInvalidPropertySet | |
10 | from exchangelib.ewsdatetime import UTC_NOW | |
11 | from exchangelib.extended_properties import ExternId | |
12 | from exchangelib.fields import TextField, BodyField, FieldPath, CultureField, IdField, ChoiceField, AttachmentField,\ | |
13 | BooleanField | |
14 | from exchangelib.indexed_properties import SingleFieldIndexedElement, MultiFieldIndexedElement | |
15 | from exchangelib.items import CalendarItem, Contact, Task, DistributionList, BaseItem | |
16 | from exchangelib.properties import Mailbox, Attendee | |
17 | from exchangelib.queryset import Q | |
18 | from exchangelib.util import value_to_xml_text | |
19 | ||
20 | from ..common import EWSTest, get_random_string, get_random_datetime_range, get_random_date, \ | |
21 | get_random_decimal, get_random_choice, get_random_int | |
22 | ||
23 | ||
24 | class BaseItemTest(EWSTest): | |
25 | TEST_FOLDER = None | |
26 | FOLDER_CLASS = None | |
27 | ITEM_CLASS = None | |
28 | ||
29 | @classmethod | |
30 | def setUpClass(cls): | |
31 | if cls is BaseItemTest: | |
32 | raise unittest.SkipTest("Skip BaseItemTest, it's only for inheritance") | |
33 | super().setUpClass() | |
34 | ||
35 | def setUp(self): | |
36 | super().setUp() | |
37 | self.test_folder = getattr(self.account, self.TEST_FOLDER) | |
38 | self.assertEqual(type(self.test_folder), self.FOLDER_CLASS) | |
39 | self.assertEqual(self.test_folder.DISTINGUISHED_FOLDER_ID, self.TEST_FOLDER) | |
40 | ||
41 | def tearDown(self): | |
42 | # Delete all test items and delivery receipts | |
43 | self.test_folder.filter( | |
44 | Q(categories__contains=self.categories) | Q(subject__startswith='Delivered: Subject: ') | |
45 | ).delete() | |
46 | super().tearDown() | |
47 | ||
48 | def get_random_insert_kwargs(self): | |
49 | insert_kwargs = {} | |
50 | for f in self.ITEM_CLASS.FIELDS: | |
51 | if not f.supports_version(self.account.version): | |
52 | # Cannot be used with this EWS version | |
53 | continue | |
54 | if self.ITEM_CLASS == CalendarItem and f in CalendarItem.timezone_fields(): | |
55 | # Timezone fields will (and must) be populated automatically from the timestamp | |
56 | continue | |
57 | if f.is_read_only: | |
58 | # These cannot be created | |
59 | continue | |
60 | if f.name == 'mime_content': | |
61 | # This needs special formatting. See separate test_mime_content() test | |
62 | continue | |
63 | if f.name == 'attachments': | |
64 | # Testing attachments is heavy. Leave this to specific tests | |
65 | insert_kwargs[f.name] = [] | |
66 | continue | |
67 | if f.name == 'resources': | |
68 | # The test server doesn't have any resources | |
69 | insert_kwargs[f.name] = [] | |
70 | continue | |
71 | if f.name == 'optional_attendees': | |
72 | # 'optional_attendees' and 'required_attendees' are mutually exclusive | |
73 | insert_kwargs[f.name] = None | |
74 | continue | |
75 | if f.name == 'start': | |
76 | start = get_random_date() | |
77 | insert_kwargs[f.name], insert_kwargs['end'] = \ | |
78 | get_random_datetime_range(start_date=start, end_date=start, tz=self.account.default_timezone) | |
79 | insert_kwargs['recurrence'] = self.random_val(self.ITEM_CLASS.get_field_by_fieldname('recurrence')) | |
80 | insert_kwargs['recurrence'].boundary.start = insert_kwargs[f.name].date() | |
81 | continue | |
82 | if f.name == 'end': | |
83 | continue | |
84 | if f.name == 'is_all_day': | |
85 | # For CalendarItem instances, the 'is_all_day' attribute affects the 'start' and 'end' values. Changing | |
86 | # from 'false' to 'true' removes the time part of these datetimes. | |
87 | insert_kwargs['is_all_day'] = False | |
88 | continue | |
89 | if f.name == 'recurrence': | |
90 | continue | |
91 | if f.name == 'due_date': | |
92 | # start_date must be before due_date | |
93 | insert_kwargs['start_date'], insert_kwargs[f.name] = \ | |
94 | get_random_datetime_range(tz=self.account.default_timezone) | |
95 | continue | |
96 | if f.name == 'start_date': | |
97 | continue | |
98 | if f.name == 'status': | |
99 | # Start with an incomplete task | |
100 | status = get_random_choice(set(f.supported_choices(version=self.account.version)) - {Task.COMPLETED}) | |
101 | insert_kwargs[f.name] = status | |
102 | if status == Task.NOT_STARTED: | |
103 | insert_kwargs['percent_complete'] = Decimal(0) | |
104 | else: | |
105 | insert_kwargs['percent_complete'] = get_random_decimal(1, 99) | |
106 | continue | |
107 | if f.name == 'percent_complete': | |
108 | continue | |
109 | insert_kwargs[f.name] = self.random_val(f) | |
110 | return insert_kwargs | |
111 | ||
112 | def get_item_fields(self): | |
113 | return [self.ITEM_CLASS.get_field_by_fieldname('id'), self.ITEM_CLASS.get_field_by_fieldname('changekey')] \ | |
114 | + [f for f in self.ITEM_CLASS.FIELDS if f.name != '_id'] | |
115 | ||
116 | def get_random_update_kwargs(self, item, insert_kwargs): | |
117 | update_kwargs = {} | |
118 | now = UTC_NOW() | |
119 | for f in self.ITEM_CLASS.FIELDS: | |
120 | if not f.supports_version(self.account.version): | |
121 | # Cannot be used with this EWS version | |
122 | continue | |
123 | if self.ITEM_CLASS == CalendarItem and f in CalendarItem.timezone_fields(): | |
124 | # Timezone fields will (and must) be populated automatically from the timestamp | |
125 | continue | |
126 | if f.is_read_only: | |
127 | # These cannot be changed | |
128 | continue | |
129 | if not item.is_draft and f.is_read_only_after_send: | |
130 | # These cannot be changed when the item is no longer a draft | |
131 | continue | |
132 | if f.name == 'message_id' and f.is_read_only_after_send: | |
133 | # Cannot be updated, regardless of draft status | |
134 | continue | |
135 | if f.name == 'attachments': | |
136 | # Testing attachments is heavy. Leave this to specific tests | |
137 | update_kwargs[f.name] = [] | |
138 | continue | |
139 | if f.name == 'resources': | |
140 | # The test server doesn't have any resources | |
141 | update_kwargs[f.name] = [] | |
142 | continue | |
143 | if isinstance(f, AttachmentField): | |
144 | # Attachments are handled separately | |
145 | continue | |
146 | if f.name == 'start': | |
147 | start = get_random_date(start_date=insert_kwargs['end'].date()) | |
148 | update_kwargs[f.name], update_kwargs['end'] = \ | |
149 | get_random_datetime_range(start_date=start, end_date=start, tz=self.account.default_timezone) | |
150 | update_kwargs['recurrence'] = self.random_val(self.ITEM_CLASS.get_field_by_fieldname('recurrence')) | |
151 | update_kwargs['recurrence'].boundary.start = update_kwargs[f.name].date() | |
152 | continue | |
153 | if f.name == 'end': | |
154 | continue | |
155 | if f.name == 'recurrence': | |
156 | continue | |
157 | if f.name == 'due_date': | |
158 | # start_date must be before due_date, and before complete_date which must be in the past | |
159 | update_kwargs['start_date'], update_kwargs[f.name] = \ | |
160 | get_random_datetime_range(end_date=now.date(), tz=self.account.default_timezone) | |
161 | continue | |
162 | if f.name == 'start_date': | |
163 | continue | |
164 | if f.name == 'status': | |
165 | # Update task to a completed state. complete_date must be a date in the past, and < than start_date | |
166 | update_kwargs[f.name] = Task.COMPLETED | |
167 | update_kwargs['percent_complete'] = Decimal(100) | |
168 | continue | |
169 | if f.name == 'percent_complete': | |
170 | continue | |
171 | if f.name == 'reminder_is_set': | |
172 | if self.ITEM_CLASS == Task: | |
173 | # Task type doesn't allow updating 'reminder_is_set' to True | |
174 | update_kwargs[f.name] = False | |
175 | else: | |
176 | update_kwargs[f.name] = not insert_kwargs[f.name] | |
177 | continue | |
178 | if isinstance(f, BooleanField): | |
179 | update_kwargs[f.name] = not insert_kwargs[f.name] | |
180 | continue | |
181 | if f.value_cls in (Mailbox, Attendee): | |
182 | if insert_kwargs[f.name] is None: | |
183 | update_kwargs[f.name] = self.random_val(f) | |
184 | else: | |
185 | update_kwargs[f.name] = None | |
186 | continue | |
187 | update_kwargs[f.name] = self.random_val(f) | |
188 | if self.ITEM_CLASS == CalendarItem: | |
189 | # EWS always sets due date to 'start' | |
190 | update_kwargs['reminder_due_by'] = update_kwargs['start'] | |
191 | if update_kwargs.get('is_all_day', False): | |
192 | # For is_all_day items, EWS will remove the time part of start and end values | |
193 | update_kwargs['start'] = update_kwargs['start'].date() | |
194 | update_kwargs['end'] = (update_kwargs['end'] + datetime.timedelta(days=1)).date() | |
195 | return update_kwargs | |
196 | ||
197 | def get_test_item(self, folder=None, categories=None): | |
198 | item_kwargs = self.get_random_insert_kwargs() | |
199 | item_kwargs['categories'] = categories or self.categories | |
200 | return self.ITEM_CLASS(folder=folder or self.test_folder, **item_kwargs) | |
201 | ||
202 | ||
203 | class CommonItemTest(BaseItemTest): | |
204 | @classmethod | |
205 | def setUpClass(cls): | |
206 | if cls is CommonItemTest: | |
207 | raise unittest.SkipTest("Skip CommonItemTest, it's only for inheritance") | |
208 | super().setUpClass() | |
209 | ||
210 | def test_field_names(self): | |
211 | # Test that fieldnames don't clash with Python keywords | |
212 | for f in self.ITEM_CLASS.FIELDS: | |
213 | self.assertNotIn(f.name, kwlist) | |
214 | ||
215 | def test_magic(self): | |
216 | item = self.get_test_item() | |
217 | self.assertIn('subject=', str(item)) | |
218 | self.assertIn(item.__class__.__name__, repr(item)) | |
219 | ||
220 | def test_queryset_nonsearchable_fields(self): | |
221 | for f in self.get_item_fields(): | |
222 | with self.subTest(f=f): | |
223 | if f.is_searchable or isinstance(f, IdField) or not f.supports_version(self.account.version): | |
224 | continue | |
225 | if f.name in ('percent_complete', 'allow_new_time_proposal'): | |
226 | # These fields don't raise an error when used in a filter, but also don't match anything in a filter | |
227 | continue | |
228 | try: | |
229 | filter_val = f.clean(self.random_val(f)) | |
230 | filter_kwargs = {'%s__in' % f.name: filter_val} if f.is_list else {f.name: filter_val} | |
231 | ||
232 | # We raise ValueError when searching on an is_searchable=False field | |
233 | with self.assertRaises(ValueError): | |
234 | list(self.test_folder.filter(**filter_kwargs)) | |
235 | ||
236 | # Make sure the is_searchable=False setting is correct by searching anyway and testing that this | |
237 | # fails server-side. This only works for values that we are actually able to convert to a search | |
238 | # string. | |
239 | try: | |
240 | value_to_xml_text(filter_val) | |
241 | except NotImplementedError: | |
242 | continue | |
243 | ||
244 | f.is_searchable = True | |
245 | if f.name in ('reminder_due_by',): | |
246 | # Filtering is accepted but doesn't work | |
247 | self.assertEqual( | |
248 | self.test_folder.filter(**filter_kwargs).count(), | |
249 | 0 | |
250 | ) | |
251 | else: | |
252 | with self.assertRaises((ErrorUnsupportedPathForQuery, ErrorInvalidValueForProperty)): | |
253 | list(self.test_folder.filter(**filter_kwargs)) | |
254 | finally: | |
255 | f.is_searchable = False | |
256 | ||
257 | def test_filter_on_all_fields(self): | |
258 | # Test that we can filter on all field names | |
259 | # TODO: Test filtering on subfields of IndexedField | |
260 | item = self.get_test_item().save() | |
261 | common_qs = self.test_folder.filter(categories__contains=self.categories) | |
262 | for f in self.get_item_fields(): | |
263 | if not f.supports_version(self.account.version): | |
264 | # Cannot be used with this EWS version | |
265 | continue | |
266 | if not f.is_searchable: | |
267 | # Cannot be used in a QuerySet | |
268 | continue | |
269 | val = getattr(item, f.name) | |
270 | if val is None: | |
271 | # We cannot filter on None values | |
272 | continue | |
273 | if self.ITEM_CLASS == Contact and f.name in ('body', 'display_name'): | |
274 | # filtering 'body' or 'display_name' on Contact items doesn't work at all. Error in EWS? | |
275 | continue | |
276 | if f.is_list: | |
277 | # Filter multi-value fields with =, __in and __contains | |
278 | if issubclass(f.value_cls, MultiFieldIndexedElement): | |
279 | # For these, we need to filter on the subfield | |
280 | filter_kwargs = [] | |
281 | for v in val: | |
282 | for subfield in f.value_cls.supported_fields(version=self.account.version): | |
283 | field_path = FieldPath(field=f, label=v.label, subfield=subfield) | |
284 | path, subval = field_path.path, field_path.get_value(item) | |
285 | if subval is None: | |
286 | continue | |
287 | filter_kwargs.extend([ | |
288 | {path: subval}, {'%s__in' % path: [subval]}, {'%s__contains' % path: [subval]} | |
289 | ]) | |
290 | elif issubclass(f.value_cls, SingleFieldIndexedElement): | |
291 | # For these, we may filter by item or subfield value | |
292 | filter_kwargs = [] | |
293 | for v in val: | |
294 | for subfield in f.value_cls.supported_fields(version=self.account.version): | |
295 | field_path = FieldPath(field=f, label=v.label, subfield=subfield) | |
296 | path, subval = field_path.path, field_path.get_value(item) | |
297 | if subval is None: | |
298 | continue | |
299 | filter_kwargs.extend([ | |
300 | {f.name: v}, {path: subval}, | |
301 | {'%s__in' % path: [subval]}, {'%s__contains' % path: [subval]} | |
302 | ]) | |
303 | else: | |
304 | filter_kwargs = [{'%s__in' % f.name: val}, {'%s__contains' % f.name: val}] | |
305 | else: | |
306 | # Filter all others with =, __in and __contains. We could have more filters here, but these should | |
307 | # always match. | |
308 | filter_kwargs = [{f.name: val}, {'%s__in' % f.name: [val]}] | |
309 | if isinstance(f, TextField) and not isinstance(f, ChoiceField): | |
310 | # Choice fields cannot be filtered using __contains. Sort of makes sense. | |
311 | random_start = get_random_int(min_val=0, max_val=len(val)//2) | |
312 | random_end = get_random_int(min_val=len(val)//2+1, max_val=len(val)) | |
313 | filter_kwargs.append({'%s__contains' % f.name: val[random_start:random_end]}) | |
314 | for kw in filter_kwargs: | |
315 | with self.subTest(f=f, kw=kw): | |
316 | matches = common_qs.filter(**kw).count() | |
317 | if isinstance(f, TextField) and f.is_complex: | |
318 | # Complex text fields sometimes fail a search using generated data. In production, | |
319 | # they almost always work anyway. Give it one more try after 10 seconds; it seems EWS does | |
320 | # some sort of indexing that needs to catch up. | |
321 | if not matches: | |
322 | time.sleep(10) | |
323 | matches = common_qs.filter(**kw).count() | |
324 | if not matches and isinstance(f, BodyField): | |
325 | # The body field is particularly nasty in this area. Give up | |
326 | continue | |
327 | self.assertEqual(matches, 1, (f.name, val, kw)) | |
328 | ||
329 | def test_text_field_settings(self): | |
330 | # Test that the max_length and is_complex field settings are correctly set for text fields | |
331 | item = self.get_test_item().save() | |
332 | for f in self.ITEM_CLASS.FIELDS: | |
333 | with self.subTest(f=f): | |
334 | if not f.supports_version(self.account.version): | |
335 | # Cannot be used with this EWS version | |
336 | continue | |
337 | if not isinstance(f, TextField): | |
338 | continue | |
339 | if isinstance(f, ChoiceField): | |
340 | # This one can't contain random values | |
341 | continue | |
342 | if isinstance(f, CultureField): | |
343 | # This one can't contain random values | |
344 | continue | |
345 | if f.is_read_only: | |
346 | continue | |
347 | if f.name == 'categories': | |
348 | # We're filtering on this one, so leave it alone | |
349 | continue | |
350 | old_max_length = getattr(f, 'max_length', None) | |
351 | old_is_complex = f.is_complex | |
352 | try: | |
353 | # Set a string long enough to not be handled by FindItems | |
354 | f.max_length = 4000 | |
355 | if f.is_list: | |
356 | setattr(item, f.name, [get_random_string(f.max_length) for _ in range(len(getattr(item, f.name)))]) | |
357 | else: | |
358 | setattr(item, f.name, get_random_string(f.max_length)) | |
359 | try: | |
360 | item.save(update_fields=[f.name]) | |
361 | except ErrorPropertyUpdate: | |
362 | # Some fields throw this error when updated to a huge value | |
363 | self.assertIn(f.name, ['given_name', 'middle_name', 'surname']) | |
364 | continue | |
365 | except ErrorInvalidPropertySet: | |
366 | # Some fields can not be updated after save | |
367 | self.assertTrue(f.is_read_only_after_send) | |
368 | continue | |
369 | # is_complex=True forces the query to use GetItems which will always get the full value | |
370 | f.is_complex = True | |
371 | new_full_item = self.test_folder.all().only(f.name).get(categories__contains=self.categories) | |
372 | new_full = getattr(new_full_item, f.name) | |
373 | if old_max_length: | |
374 | if f.is_list: | |
375 | for s in new_full: | |
376 | self.assertLessEqual(len(s), old_max_length, (f.name, len(s), old_max_length)) | |
377 | else: | |
378 | self.assertLessEqual(len(new_full), old_max_length, (f.name, len(new_full), old_max_length)) | |
379 | ||
380 | # is_complex=False forces the query to use FindItems which will only get the short value | |
381 | f.is_complex = False | |
382 | new_short_item = self.test_folder.all().only(f.name).get(categories__contains=self.categories) | |
383 | new_short = getattr(new_short_item, f.name) | |
384 | ||
385 | if not old_is_complex: | |
386 | self.assertEqual(new_short, new_full, (f.name, new_short, new_full)) | |
387 | finally: | |
388 | if old_max_length: | |
389 | f.max_length = old_max_length | |
390 | else: | |
391 | delattr(f, 'max_length') | |
392 | f.is_complex = old_is_complex | |
393 | ||
394 | def test_save_and_delete(self): | |
395 | # Test that we can create, update and delete single items using methods directly on the item. | |
396 | insert_kwargs = self.get_random_insert_kwargs() | |
397 | insert_kwargs['categories'] = self.categories | |
398 | item = self.ITEM_CLASS(folder=self.test_folder, **insert_kwargs) | |
399 | self.assertIsNone(item.id) | |
400 | self.assertIsNone(item.changekey) | |
401 | ||
402 | # Create | |
403 | item.save() | |
404 | self.assertIsNotNone(item.id) | |
405 | self.assertIsNotNone(item.changekey) | |
406 | for k, v in insert_kwargs.items(): | |
407 | self.assertEqual(getattr(item, k), v, (k, getattr(item, k), v)) | |
408 | # Test that whatever we have locally also matches whatever is in the DB | |
409 | fresh_item = list(self.account.fetch(ids=[item]))[0] | |
410 | for f in self.ITEM_CLASS.FIELDS: | |
411 | with self.subTest(f=f): | |
412 | old, new = getattr(item, f.name), getattr(fresh_item, f.name) | |
413 | if f.is_read_only and old is None: | |
414 | # Some fields are automatically set server-side | |
415 | continue | |
416 | if f.name == 'reminder_due_by': | |
417 | # EWS sets a default value if it is not set on insert. Ignore | |
418 | continue | |
419 | if f.name == 'mime_content': | |
420 | # This will change depending on other contents fields | |
421 | continue | |
422 | if f.is_list: | |
423 | old, new = set(old or ()), set(new or ()) | |
424 | self.assertEqual(old, new, (f.name, old, new)) | |
425 | ||
426 | # Update | |
427 | update_kwargs = self.get_random_update_kwargs(item=item, insert_kwargs=insert_kwargs) | |
428 | for k, v in update_kwargs.items(): | |
429 | setattr(item, k, v) | |
430 | item.save() | |
431 | for k, v in update_kwargs.items(): | |
432 | self.assertEqual(getattr(item, k), v, (k, getattr(item, k), v)) | |
433 | # Test that whatever we have locally also matches whatever is in the DB | |
434 | fresh_item = list(self.account.fetch(ids=[item]))[0] | |
435 | for f in self.ITEM_CLASS.FIELDS: | |
436 | with self.subTest(f=f): | |
437 | old, new = getattr(item, f.name), getattr(fresh_item, f.name) | |
438 | if f.is_read_only and old is None: | |
439 | # Some fields are automatically updated server-side | |
440 | continue | |
441 | if f.name == 'mime_content': | |
442 | # This will change depending on other contents fields | |
443 | continue | |
444 | if f.name == 'reminder_due_by': | |
445 | if new is None: | |
446 | # EWS does not always return a value if reminder_is_set is False. | |
447 | continue | |
448 | if old is not None: | |
449 | # EWS sometimes randomly sets the new reminder due date to one month before or after we | |
450 | # wanted it, and sometimes 30 days before or after. But only sometimes... | |
451 | old_date = old.astimezone(self.account.default_timezone).date() | |
452 | new_date = new.astimezone(self.account.default_timezone).date() | |
453 | if getattr(item, 'is_all_day', False) and old_date == new_date: | |
454 | # There is some weirdness with the time part of the reminder_due_by value for all-day events | |
455 | item.reminder_due_by = new | |
456 | continue | |
457 | if relativedelta(month=1) + new_date == old_date: | |
458 | item.reminder_due_by = new | |
459 | continue | |
460 | if relativedelta(month=1) + old_date == new_date: | |
461 | item.reminder_due_by = new | |
462 | continue | |
463 | if abs(old_date - new_date) == datetime.timedelta(days=30): | |
464 | item.reminder_due_by = new | |
465 | continue | |
466 | if f.is_list: | |
467 | old, new = set(old or ()), set(new or ()) | |
468 | self.assertEqual(old, new, (f.name, old, new)) | |
469 | ||
470 | # Hard delete | |
471 | item_id = (item.id, item.changekey) | |
472 | item.delete() | |
473 | for e in self.account.fetch(ids=[item_id]): | |
474 | # It's gone from the account | |
475 | self.assertIsInstance(e, ErrorItemNotFound) | |
476 | # Really gone, not just changed ItemId | |
477 | items = self.test_folder.filter(categories__contains=item.categories) | |
478 | self.assertEqual(items.count(), 0) | |
479 | ||
480 | def test_item(self): | |
481 | # Test insert | |
482 | insert_kwargs = self.get_random_insert_kwargs() | |
483 | insert_kwargs['categories'] = self.categories | |
484 | item = self.ITEM_CLASS(folder=self.test_folder, **insert_kwargs) | |
485 | # Test with generator as argument | |
486 | insert_ids = self.test_folder.bulk_create(items=(i for i in [item])) | |
487 | self.assertEqual(len(insert_ids), 1) | |
488 | self.assertIsInstance(insert_ids[0], BaseItem) | |
489 | find_ids = list(self.test_folder.filter(categories__contains=item.categories).values_list('id', 'changekey')) | |
490 | self.assertEqual(len(find_ids), 1) | |
491 | self.assertEqual(len(find_ids[0]), 2, find_ids[0]) | |
492 | self.assertEqual(insert_ids, find_ids) | |
493 | # Test with generator as argument | |
494 | item = list(self.account.fetch(ids=(i for i in find_ids)))[0] | |
495 | for f in self.ITEM_CLASS.FIELDS: | |
496 | with self.subTest(f=f): | |
497 | if not f.supports_version(self.account.version): | |
498 | # Cannot be used with this EWS version | |
499 | continue | |
500 | if self.ITEM_CLASS == CalendarItem and f in CalendarItem.timezone_fields(): | |
501 | # Timezone fields will (and must) be populated automatically from the timestamp | |
502 | continue | |
503 | if f.is_read_only: | |
504 | continue | |
505 | if f.name == 'reminder_due_by': | |
506 | # EWS sets a default value if it is not set on insert. Ignore | |
507 | continue | |
508 | if f.name == 'mime_content': | |
509 | # This will change depending on other contents fields | |
510 | continue | |
511 | old, new = getattr(item, f.name), insert_kwargs[f.name] | |
512 | if f.is_list: | |
513 | old, new = set(old or ()), set(new or ()) | |
514 | self.assertEqual(old, new, (f.name, old, new)) | |
515 | ||
516 | # Test update | |
517 | update_kwargs = self.get_random_update_kwargs(item=item, insert_kwargs=insert_kwargs) | |
518 | if self.ITEM_CLASS in (Contact, DistributionList): | |
519 | # Contact and DistributionList don't support mime_type updates at all | |
520 | update_kwargs.pop('mime_content', None) | |
521 | update_fieldnames = [f for f in update_kwargs.keys() if f != 'attachments'] | |
522 | for k, v in update_kwargs.items(): | |
523 | setattr(item, k, v) | |
524 | # Test with generator as argument | |
525 | update_ids = self.account.bulk_update(items=(i for i in [(item, update_fieldnames)])) | |
526 | self.assertEqual(len(update_ids), 1) | |
527 | self.assertEqual(len(update_ids[0]), 2, update_ids) | |
528 | self.assertEqual(insert_ids[0].id, update_ids[0][0]) # ID should be the same | |
529 | self.assertNotEqual(insert_ids[0].changekey, update_ids[0][1]) # Changekey should change when item is updated | |
530 | item = list(self.account.fetch(update_ids))[0] | |
531 | for f in self.ITEM_CLASS.FIELDS: | |
532 | with self.subTest(f=f): | |
533 | if not f.supports_version(self.account.version): | |
534 | # Cannot be used with this EWS version | |
535 | continue | |
536 | if self.ITEM_CLASS == CalendarItem and f in CalendarItem.timezone_fields(): | |
537 | # Timezone fields will (and must) be populated automatically from the timestamp | |
538 | continue | |
539 | if f.is_read_only or f.is_read_only_after_send: | |
540 | # These cannot be changed | |
541 | continue | |
542 | if f.name == 'mime_content': | |
543 | # This will change depending on other contents fields | |
544 | continue | |
545 | old, new = getattr(item, f.name), update_kwargs[f.name] | |
546 | if f.name == 'reminder_due_by': | |
547 | if old is None: | |
548 | # EWS does not always return a value if reminder_is_set is False. Set one now | |
549 | item.reminder_due_by = new | |
550 | continue | |
551 | if new is not None: | |
552 | # EWS sometimes randomly sets the new reminder due date to one month before or after we | |
553 | # wanted it, and sometimes 30 days before or after. But only sometimes... | |
554 | old_date = old.astimezone(self.account.default_timezone).date() | |
555 | new_date = new.astimezone(self.account.default_timezone).date() | |
556 | if getattr(item, 'is_all_day', False) and old_date == new_date: | |
557 | # There is some weirdness with the time part of the reminder_due_by value for all-day events | |
558 | item.reminder_due_by = new | |
559 | continue | |
560 | if relativedelta(month=1) + new_date == old_date: | |
561 | item.reminder_due_by = new | |
562 | continue | |
563 | if relativedelta(month=1) + old_date == new_date: | |
564 | item.reminder_due_by = new | |
565 | continue | |
566 | if abs(old_date - new_date) == datetime.timedelta(days=30): | |
567 | item.reminder_due_by = new | |
568 | continue | |
569 | if f.is_list: | |
570 | old, new = set(old or ()), set(new or ()) | |
571 | self.assertEqual(old, new, (f.name, old, new)) | |
572 | ||
573 | # Test wiping or removing fields | |
574 | wipe_kwargs = {} | |
575 | for f in self.ITEM_CLASS.FIELDS: | |
576 | if not f.supports_version(self.account.version): | |
577 | # Cannot be used with this EWS version | |
578 | continue | |
579 | if self.ITEM_CLASS == CalendarItem and f in CalendarItem.timezone_fields(): | |
580 | # Timezone fields will (and must) be populated automatically from the timestamp | |
581 | continue | |
582 | if f.is_required or f.is_required_after_save: | |
583 | # These cannot be deleted | |
584 | continue | |
585 | if f.is_read_only or f.is_read_only_after_send: | |
586 | # These cannot be changed | |
587 | continue | |
588 | wipe_kwargs[f.name] = None | |
589 | for k, v in wipe_kwargs.items(): | |
590 | setattr(item, k, v) | |
591 | wipe_ids = self.account.bulk_update([(item, update_fieldnames), ]) | |
592 | self.assertEqual(len(wipe_ids), 1) | |
593 | self.assertEqual(len(wipe_ids[0]), 2, wipe_ids) | |
594 | self.assertEqual(insert_ids[0].id, wipe_ids[0][0]) # ID should be the same | |
595 | self.assertNotEqual(insert_ids[0].changekey, | |
596 | wipe_ids[0][1]) # Changekey should not be the same when item is updated | |
597 | item = list(self.account.fetch(wipe_ids))[0] | |
598 | for f in self.ITEM_CLASS.FIELDS: | |
599 | with self.subTest(f=f): | |
600 | if not f.supports_version(self.account.version): | |
601 | # Cannot be used with this EWS version | |
602 | continue | |
603 | if self.ITEM_CLASS == CalendarItem and f in CalendarItem.timezone_fields(): | |
604 | # Timezone fields will (and must) be populated automatically from the timestamp | |
605 | continue | |
606 | if f.is_required or f.is_required_after_save: | |
607 | continue | |
608 | if f.is_read_only or f.is_read_only_after_send: | |
609 | continue | |
610 | old, new = getattr(item, f.name), wipe_kwargs[f.name] | |
611 | if f.is_list: | |
612 | old, new = set(old or ()), set(new or ()) | |
613 | self.assertEqual(old, new, (f.name, old, new)) | |
614 | ||
615 | try: | |
616 | self.ITEM_CLASS.register('extern_id', ExternId) | |
617 | # Test extern_id = None, which deletes the extended property entirely | |
618 | extern_id = None | |
619 | item.extern_id = extern_id | |
620 | wipe2_ids = self.account.bulk_update([(item, ['extern_id']), ]) | |
621 | self.assertEqual(len(wipe2_ids), 1) | |
622 | self.assertEqual(len(wipe2_ids[0]), 2, wipe2_ids) | |
623 | self.assertEqual(insert_ids[0].id, wipe2_ids[0][0]) # ID must be the same | |
624 | self.assertNotEqual(insert_ids[0].changekey, wipe2_ids[0][1]) # Changekey must change when item is updated | |
625 | item = list(self.account.fetch(wipe2_ids))[0] | |
626 | self.assertEqual(item.extern_id, extern_id) | |
627 | finally: | |
628 | self.ITEM_CLASS.deregister('extern_id') |
0 | from exchangelib.errors import ErrorItemNotFound, ErrorInvalidChangeKey, ErrorInvalidIdMalformed | |
1 | from exchangelib.fields import FieldPath | |
2 | from exchangelib.folders import Inbox, Folder | |
3 | from exchangelib.items import Item, Message, SAVE_ONLY, SEND_ONLY, SEND_AND_SAVE_COPY | |
4 | ||
5 | from .test_basics import BaseItemTest | |
6 | ||
7 | ||
8 | class BulkMethodTest(BaseItemTest): | |
9 | TEST_FOLDER = 'inbox' | |
10 | FOLDER_CLASS = Inbox | |
11 | ITEM_CLASS = Message | |
12 | ||
13 | def test_fetch(self): | |
14 | item = self.get_test_item() | |
15 | self.test_folder.bulk_create(items=[item, item]) | |
16 | ids = self.test_folder.filter(categories__contains=item.categories) | |
17 | items = list(self.account.fetch(ids=ids)) | |
18 | for item in items: | |
19 | self.assertIsInstance(item, self.ITEM_CLASS) | |
20 | self.assertEqual(len(items), 2) | |
21 | ||
22 | items = list(self.account.fetch(ids=ids, only_fields=['subject'])) | |
23 | self.assertEqual(len(items), 2) | |
24 | ||
25 | items = list(self.account.fetch(ids=ids, only_fields=[FieldPath.from_string('subject', self.test_folder)])) | |
26 | self.assertEqual(len(items), 2) | |
27 | ||
28 | items = list(self.account.fetch(ids=ids, only_fields=['id', 'changekey'])) | |
29 | self.assertEqual(len(items), 2) | |
30 | ||
31 | def test_empty_args(self): | |
32 | # We allow empty sequences for these methods | |
33 | self.assertEqual(self.test_folder.bulk_create(items=[]), []) | |
34 | self.assertEqual(list(self.account.fetch(ids=[])), []) | |
35 | self.assertEqual(self.account.bulk_create(folder=self.test_folder, items=[]), []) | |
36 | self.assertEqual(self.account.bulk_update(items=[]), []) | |
37 | self.assertEqual(self.account.bulk_delete(ids=[]), []) | |
38 | self.assertEqual(self.account.bulk_send(ids=[]), []) | |
39 | self.assertEqual(self.account.bulk_copy(ids=[], to_folder=self.account.trash), []) | |
40 | self.assertEqual(self.account.bulk_move(ids=[], to_folder=self.account.trash), []) | |
41 | self.assertEqual(self.account.upload(data=[]), []) | |
42 | self.assertEqual(self.account.export(items=[]), []) | |
43 | ||
44 | def test_qs_args(self): | |
45 | # We allow querysets for these methods | |
46 | qs = self.test_folder.none() | |
47 | self.assertEqual(list(self.account.fetch(ids=qs)), []) | |
48 | with self.assertRaises(ValueError): | |
49 | # bulk_create() does not allow queryset input | |
50 | self.account.bulk_create(folder=self.test_folder, items=qs) | |
51 | with self.assertRaises(ValueError): | |
52 | # bulk_update() does not allow queryset input | |
53 | self.account.bulk_update(items=qs) | |
54 | self.assertEqual(self.account.bulk_delete(ids=qs), []) | |
55 | self.assertEqual(self.account.bulk_send(ids=qs), []) | |
56 | self.assertEqual(self.account.bulk_copy(ids=qs, to_folder=self.account.trash), []) | |
57 | self.assertEqual(self.account.bulk_move(ids=qs, to_folder=self.account.trash), []) | |
58 | self.assertEqual(self.account.upload(data=qs), []) | |
59 | self.assertEqual(self.account.export(items=qs), []) | |
60 | ||
61 | def test_no_kwargs(self): | |
62 | self.assertEqual(self.test_folder.bulk_create([]), []) | |
63 | self.assertEqual(list(self.account.fetch([])), []) | |
64 | self.assertEqual(self.account.bulk_create(self.test_folder, []), []) | |
65 | self.assertEqual(self.account.bulk_update([]), []) | |
66 | self.assertEqual(self.account.bulk_delete([]), []) | |
67 | self.assertEqual(self.account.bulk_send([]), []) | |
68 | self.assertEqual(self.account.bulk_copy([], to_folder=self.account.trash), []) | |
69 | self.assertEqual(self.account.bulk_move([], to_folder=self.account.trash), []) | |
70 | self.assertEqual(self.account.upload([]), []) | |
71 | self.assertEqual(self.account.export([]), []) | |
72 | ||
73 | def test_invalid_bulk_args(self): | |
74 | # Test bulk_create | |
75 | with self.assertRaises(ValueError): | |
76 | # Folder must belong to account | |
77 | self.account.bulk_create(folder=Folder(root=None), items=[1]) | |
78 | with self.assertRaises(AttributeError): | |
79 | # Must have folder on save | |
80 | self.account.bulk_create(folder=None, items=[1], message_disposition=SAVE_ONLY) | |
81 | # Test that we can send_and_save with a default folder | |
82 | self.account.bulk_create(folder=None, items=[], message_disposition=SEND_AND_SAVE_COPY) | |
83 | with self.assertRaises(AttributeError): | |
84 | # Must not have folder on send-only | |
85 | self.account.bulk_create(folder=self.test_folder, items=[1], message_disposition=SEND_ONLY) | |
86 | ||
87 | # Test bulk_update | |
88 | with self.assertRaises(ValueError): | |
89 | # Cannot update in send-only mode | |
90 | self.account.bulk_update(items=[1], message_disposition=SEND_ONLY) | |
91 | ||
92 | def test_bulk_failure(self): | |
93 | # Test that bulk_* can handle EWS errors and return the errors in order without losing non-failure results | |
94 | items1 = [self.get_test_item().save() for _ in range(3)] | |
95 | items1[1].changekey = 'XXX' | |
96 | for i, res in enumerate(self.account.bulk_delete(items1)): | |
97 | if i == 1: | |
98 | self.assertIsInstance(res, ErrorInvalidChangeKey) | |
99 | else: | |
100 | self.assertEqual(res, True) | |
101 | items2 = [self.get_test_item().save() for _ in range(3)] | |
102 | items2[1].id = 'AAAA==' | |
103 | for i, res in enumerate(self.account.bulk_delete(items2)): | |
104 | if i == 1: | |
105 | self.assertIsInstance(res, ErrorInvalidIdMalformed) | |
106 | else: | |
107 | self.assertEqual(res, True) | |
108 | items3 = [self.get_test_item().save() for _ in range(3)] | |
109 | items3[1].id = items1[0].id | |
110 | for i, res in enumerate(self.account.fetch(items3)): | |
111 | if i == 1: | |
112 | self.assertIsInstance(res, ErrorItemNotFound) | |
113 | else: | |
114 | self.assertIsInstance(res, Item) |
0 | import datetime | |
1 | ||
2 | from exchangelib.errors import ErrorInvalidOperation, ErrorItemNotFound | |
3 | from exchangelib.ewsdatetime import EWSDateTime, UTC | |
4 | from exchangelib.folders import Calendar | |
5 | from exchangelib.items import CalendarItem | |
6 | from exchangelib.items.calendar_item import SINGLE, OCCURRENCE, EXCEPTION, RECURRING_MASTER | |
7 | from exchangelib.recurrence import Recurrence, DailyPattern, Occurrence, FirstOccurrence, LastOccurrence, \ | |
8 | DeletedOccurrence | |
9 | ||
10 | from ..common import get_random_string, get_random_datetime_range, get_random_date | |
11 | from .test_basics import CommonItemTest | |
12 | ||
13 | ||
14 | class CalendarTest(CommonItemTest): | |
15 | TEST_FOLDER = 'calendar' | |
16 | FOLDER_CLASS = Calendar | |
17 | ITEM_CLASS = CalendarItem | |
18 | ||
19 | def match_cat(self, i): | |
20 | return set(i.categories or []) == set(self.categories) | |
21 | ||
22 | def test_updating_timestamps(self): | |
23 | # Test that we can update an item without changing anything, and maintain the hidden timezone fields as local | |
24 | # timezones, and that returned timestamps are in UTC. | |
25 | item = self.get_test_item() | |
26 | item.reminder_is_set = True | |
27 | item.is_all_day = False | |
28 | item.recurrence = None | |
29 | item.save() | |
30 | item.refresh() | |
31 | self.assertEqual(item.type, SINGLE) | |
32 | for i in self.account.calendar.filter(categories__contains=self.categories).only('start', 'end', 'categories'): | |
33 | self.assertEqual(i.start, item.start) | |
34 | self.assertEqual(i.start.tzinfo, UTC) | |
35 | self.assertEqual(i.end, item.end) | |
36 | self.assertEqual(i.end.tzinfo, UTC) | |
37 | self.assertEqual(i._start_timezone, self.account.default_timezone) | |
38 | self.assertEqual(i._end_timezone, self.account.default_timezone) | |
39 | i.save(update_fields=['start', 'end']) | |
40 | self.assertEqual(i.start, item.start) | |
41 | self.assertEqual(i.start.tzinfo, UTC) | |
42 | self.assertEqual(i.end, item.end) | |
43 | self.assertEqual(i.end.tzinfo, UTC) | |
44 | self.assertEqual(i._start_timezone, self.account.default_timezone) | |
45 | self.assertEqual(i._end_timezone, self.account.default_timezone) | |
46 | for i in self.account.calendar.filter(categories__contains=self.categories).only('start', 'end', 'categories'): | |
47 | self.assertEqual(i.start, item.start) | |
48 | self.assertEqual(i.start.tzinfo, UTC) | |
49 | self.assertEqual(i.end, item.end) | |
50 | self.assertEqual(i.end.tzinfo, UTC) | |
51 | self.assertEqual(i._start_timezone, self.account.default_timezone) | |
52 | self.assertEqual(i._end_timezone, self.account.default_timezone) | |
53 | i.delete() | |
54 | ||
55 | def test_update_to_non_utc_datetime(self): | |
56 | # Test updating with non-UTC datetime values. This is a separate code path in UpdateItem code | |
57 | item = self.get_test_item() | |
58 | item.reminder_is_set = True | |
59 | item.is_all_day = False | |
60 | item.save() | |
61 | # Update start, end and recurrence with timezoned datetimes. For some reason, EWS throws | |
62 | # 'ErrorOccurrenceTimeSpanTooBig' is we go back in time. | |
63 | start = get_random_date(start_date=item.start.date() + datetime.timedelta(days=1)) | |
64 | dt_start, dt_end = [ | |
65 | dt.astimezone(self.account.default_timezone) for dt in | |
66 | get_random_datetime_range(start_date=start, end_date=start, tz=self.account.default_timezone) | |
67 | ] | |
68 | item.start, item.end = dt_start, dt_end | |
69 | item.recurrence.boundary.start = dt_start.date() | |
70 | item.save() | |
71 | item.refresh() | |
72 | self.assertEqual(item.start, dt_start) | |
73 | self.assertEqual(item.end, dt_end) | |
74 | ||
75 | def test_all_day_datetimes(self): | |
76 | # Test that we can use plain dates for start and end values for all-day items | |
77 | start = get_random_date() | |
78 | start_dt, end_dt = get_random_datetime_range( | |
79 | start_date=start, | |
80 | end_date=start + datetime.timedelta(days=365), | |
81 | tz=self.account.default_timezone | |
82 | ) | |
83 | # Assign datetimes for start and end | |
84 | item = self.ITEM_CLASS(folder=self.test_folder, start=start_dt, end=end_dt, is_all_day=True, | |
85 | categories=self.categories).save() | |
86 | ||
87 | # Returned item start and end values should be EWSDate instances | |
88 | item = self.test_folder.all().only('is_all_day', 'start', 'end').get(id=item.id, changekey=item.changekey) | |
89 | self.assertEqual(item.is_all_day, True) | |
90 | self.assertEqual(item.start, start_dt.date()) | |
91 | self.assertEqual(item.end, end_dt.date()) | |
92 | item.save() # Make sure we can update | |
93 | item.delete() | |
94 | ||
95 | # We are also allowed to assign plain dates as values for all-day items | |
96 | item = self.ITEM_CLASS(folder=self.test_folder, start=start_dt.date(), end=end_dt.date(), is_all_day=True, | |
97 | categories=self.categories).save() | |
98 | ||
99 | # Returned item start and end values should be EWSDate instances | |
100 | item = self.test_folder.all().only('is_all_day', 'start', 'end').get(id=item.id, changekey=item.changekey) | |
101 | self.assertEqual(item.is_all_day, True) | |
102 | self.assertEqual(item.start, start_dt.date()) | |
103 | self.assertEqual(item.end, end_dt.date()) | |
104 | item.save() # Make sure we can update | |
105 | ||
106 | def test_view(self): | |
107 | item1 = self.ITEM_CLASS( | |
108 | account=self.account, | |
109 | folder=self.test_folder, | |
110 | subject=get_random_string(16), | |
111 | start=self.account.default_timezone.localize(EWSDateTime(2016, 1, 1, 8)), | |
112 | end=self.account.default_timezone.localize(EWSDateTime(2016, 1, 1, 10)), | |
113 | categories=self.categories, | |
114 | ) | |
115 | item2 = self.ITEM_CLASS( | |
116 | account=self.account, | |
117 | folder=self.test_folder, | |
118 | subject=get_random_string(16), | |
119 | start=self.account.default_timezone.localize(EWSDateTime(2016, 2, 1, 8)), | |
120 | end=self.account.default_timezone.localize(EWSDateTime(2016, 2, 1, 10)), | |
121 | categories=self.categories, | |
122 | ) | |
123 | self.test_folder.bulk_create(items=[item1, item2]) | |
124 | ||
125 | # Test missing args | |
126 | with self.assertRaises(TypeError): | |
127 | self.test_folder.view() | |
128 | # Test bad args | |
129 | with self.assertRaises(ValueError): | |
130 | list(self.test_folder.view(start=item1.end, end=item1.start)) | |
131 | with self.assertRaises(TypeError): | |
132 | list(self.test_folder.view(start='xxx', end=item1.end)) | |
133 | with self.assertRaises(ValueError): | |
134 | list(self.test_folder.view(start=item1.start, end=item1.end, max_items=0)) | |
135 | ||
136 | # Test dates | |
137 | self.assertEqual( | |
138 | len([i for i in self.test_folder.view(start=item1.start, end=item1.end) if self.match_cat(i)]), | |
139 | 1 | |
140 | ) | |
141 | self.assertEqual( | |
142 | len([i for i in self.test_folder.view(start=item1.start, end=item2.end) if self.match_cat(i)]), | |
143 | 2 | |
144 | ) | |
145 | # Edge cases. Get view from end of item1 to start of item2. Should logically return 0 items, but Exchange wants | |
146 | # it differently and returns item1 even though there is no overlap. | |
147 | self.assertEqual( | |
148 | len([i for i in self.test_folder.view(start=item1.end, end=item2.start) if self.match_cat(i)]), | |
149 | 1 | |
150 | ) | |
151 | self.assertEqual( | |
152 | len([i for i in self.test_folder.view(start=item1.start, end=item2.start) if self.match_cat(i)]), | |
153 | 1 | |
154 | ) | |
155 | ||
156 | # Test max_items | |
157 | self.assertEqual( | |
158 | len([i for i in self.test_folder.view(start=item1.start, end=item2.end, max_items=9999) if self.match_cat(i)]), | |
159 | 2 | |
160 | ) | |
161 | self.assertEqual( | |
162 | self.test_folder.view(start=item1.start, end=item2.end, max_items=1).count(), | |
163 | 1 | |
164 | ) | |
165 | ||
166 | # Test chaining | |
167 | qs = self.test_folder.view(start=item1.start, end=item2.end) | |
168 | self.assertTrue(qs.count() >= 2) | |
169 | with self.assertRaises(ErrorInvalidOperation): | |
170 | qs.filter(subject=item1.subject).count() # EWS does not allow restrictions | |
171 | self.assertListEqual( | |
172 | [i for i in qs.order_by('subject').values('subject') if i['subject'] in (item1.subject, item2.subject)], | |
173 | [{'subject': s} for s in sorted([item1.subject, item2.subject])] | |
174 | ) | |
175 | ||
176 | def test_recurring_item(self): | |
177 | # Create a recurring calendar item. Test that occurrence fields are correct on the master item | |
178 | ||
179 | # Create a master item with 4 daily occurrences from 8:00 to 10:00. 'start' and 'end' are values for the first | |
180 | # occurrence. | |
181 | start = self.account.default_timezone.localize(EWSDateTime(2016, 1, 1, 8)) | |
182 | end = self.account.default_timezone.localize(EWSDateTime(2016, 1, 1, 10)) | |
183 | master_item = self.ITEM_CLASS( | |
184 | folder=self.test_folder, | |
185 | start=start, | |
186 | end=end, | |
187 | recurrence=Recurrence(pattern=DailyPattern(interval=1), start=start.date(), number=4), | |
188 | categories=self.categories, | |
189 | ).save() | |
190 | ||
191 | master_item.refresh() | |
192 | self.assertEqual(master_item.is_recurring, False) | |
193 | self.assertEqual(master_item.type, RECURRING_MASTER) | |
194 | self.assertIsInstance(master_item.first_occurrence, FirstOccurrence) | |
195 | self.assertEqual(master_item.first_occurrence.start, start) | |
196 | self.assertEqual(master_item.first_occurrence.end, end) | |
197 | self.assertIsInstance(master_item.last_occurrence, LastOccurrence) | |
198 | self.assertEqual(master_item.last_occurrence.start, start + datetime.timedelta(days=3)) | |
199 | self.assertEqual(master_item.last_occurrence.end, end + datetime.timedelta(days=3)) | |
200 | self.assertEqual(master_item.modified_occurrences, None) | |
201 | self.assertEqual(master_item.deleted_occurrences, None) | |
202 | ||
203 | # Test occurrences as full calendar items, unfolded from the master | |
204 | range_start, range_end = start, end + datetime.timedelta(days=3) | |
205 | unfolded = [i for i in self.test_folder.view(start=range_start, end=range_end) if self.match_cat(i)] | |
206 | self.assertEqual(len(unfolded), 4) | |
207 | for item in unfolded: | |
208 | self.assertEqual(item.type, OCCURRENCE) | |
209 | self.assertEqual(item.is_recurring, True) | |
210 | ||
211 | first_occurrence = unfolded[0] | |
212 | self.assertEqual(first_occurrence.id, master_item.first_occurrence.id) | |
213 | self.assertEqual(first_occurrence.start, master_item.first_occurrence.start) | |
214 | self.assertEqual(first_occurrence.end, master_item.first_occurrence.end) | |
215 | ||
216 | second_occurrence = unfolded[1] | |
217 | self.assertEqual(second_occurrence.start, master_item.start + datetime.timedelta(days=1)) | |
218 | self.assertEqual(second_occurrence.end, master_item.end + datetime.timedelta(days=1)) | |
219 | ||
220 | third_occurrence = unfolded[2] | |
221 | self.assertEqual(third_occurrence.start, master_item.start + datetime.timedelta(days=2)) | |
222 | self.assertEqual(third_occurrence.end, master_item.end + datetime.timedelta(days=2)) | |
223 | ||
224 | last_occurrence = unfolded[3] | |
225 | self.assertEqual(last_occurrence.id, master_item.last_occurrence.id) | |
226 | self.assertEqual(last_occurrence.start, master_item.last_occurrence.start) | |
227 | self.assertEqual(last_occurrence.end, master_item.last_occurrence.end) | |
228 | ||
229 | def test_change_occurrence(self): | |
230 | # Test that we can make changes to individual occurrences and see the effect on the master item. | |
231 | start = self.account.default_timezone.localize(EWSDateTime(2016, 1, 1, 8)) | |
232 | end = self.account.default_timezone.localize(EWSDateTime(2016, 1, 1, 10)) | |
233 | master_item = self.ITEM_CLASS( | |
234 | folder=self.test_folder, | |
235 | start=start, | |
236 | end=end, | |
237 | recurrence=Recurrence(pattern=DailyPattern(interval=1), start=start.date(), number=4), | |
238 | categories=self.categories, | |
239 | ).save() | |
240 | master_item.refresh() | |
241 | ||
242 | # Test occurrences as full calendar items, unfolded from the master | |
243 | range_start, range_end = start, end + datetime.timedelta(days=3) | |
244 | unfolded = [i for i in self.test_folder.view(start=range_start, end=range_end) if self.match_cat(i)] | |
245 | ||
246 | # Change the start and end of the second occurrence | |
247 | second_occurrence = unfolded[1] | |
248 | second_occurrence.start += datetime.timedelta(hours=1) | |
249 | second_occurrence.end += datetime.timedelta(hours=1) | |
250 | second_occurrence.save() | |
251 | ||
252 | # Test change on the master item | |
253 | master_item.refresh() | |
254 | self.assertEqual(len(master_item.modified_occurrences), 1) | |
255 | modified_occurrence = master_item.modified_occurrences[0] | |
256 | self.assertIsInstance(modified_occurrence, Occurrence) | |
257 | self.assertEqual(modified_occurrence.id, second_occurrence.id) | |
258 | self.assertEqual(modified_occurrence.start, second_occurrence.start) | |
259 | self.assertEqual(modified_occurrence.end, second_occurrence.end) | |
260 | self.assertEqual(modified_occurrence.original_start, second_occurrence.start - datetime.timedelta(hours=1)) | |
261 | self.assertEqual(master_item.deleted_occurrences, None) | |
262 | ||
263 | # Test change on the unfolded item | |
264 | unfolded = [i for i in self.test_folder.view(start=range_start, end=range_end) if self.match_cat(i)] | |
265 | self.assertEqual(len(unfolded), 4) | |
266 | self.assertEqual(unfolded[1].type, EXCEPTION) | |
267 | self.assertEqual(unfolded[1].start, second_occurrence.start) | |
268 | self.assertEqual(unfolded[1].end, second_occurrence.end) | |
269 | self.assertEqual(unfolded[1].original_start, second_occurrence.start - datetime.timedelta(hours=1)) | |
270 | ||
271 | def test_delete_occurrence(self): | |
272 | # Test that we can delete an occurrence and see the cange on the master item | |
273 | start = self.account.default_timezone.localize(EWSDateTime(2016, 1, 1, 8)) | |
274 | end = self.account.default_timezone.localize(EWSDateTime(2016, 1, 1, 10)) | |
275 | master_item = self.ITEM_CLASS( | |
276 | folder=self.test_folder, | |
277 | start=start, | |
278 | end=end, | |
279 | recurrence=Recurrence(pattern=DailyPattern(interval=1), start=start.date(), number=4), | |
280 | categories=self.categories, | |
281 | ).save() | |
282 | master_item.refresh() | |
283 | ||
284 | # Test occurrences as full calendar items, unfolded from the master | |
285 | range_start, range_end = start, end + datetime.timedelta(days=3) | |
286 | unfolded = [i for i in self.test_folder.view(start=range_start, end=range_end) if self.match_cat(i)] | |
287 | ||
288 | # Delete the third occurrence | |
289 | third_occurrence = unfolded[2] | |
290 | third_occurrence.delete() | |
291 | ||
292 | # Test change on the master item | |
293 | master_item.refresh() | |
294 | self.assertEqual(master_item.modified_occurrences, None) | |
295 | self.assertEqual(len(master_item.deleted_occurrences), 1) | |
296 | deleted_occurrence = master_item.deleted_occurrences[0] | |
297 | self.assertIsInstance(deleted_occurrence, DeletedOccurrence) | |
298 | self.assertEqual(deleted_occurrence.start, third_occurrence.start) | |
299 | ||
300 | # Test change on the unfolded items | |
301 | unfolded = [i for i in self.test_folder.view(start=range_start, end=range_end) if self.match_cat(i)] | |
302 | self.assertEqual(len(unfolded), 3) | |
303 | ||
304 | def test_change_occurrence_via_index(self): | |
305 | # Test updating occurrences via occurrence index without knowing the ID of the occurrence. | |
306 | start = self.account.default_timezone.localize(EWSDateTime(2016, 1, 1, 8)) | |
307 | end = self.account.default_timezone.localize(EWSDateTime(2016, 1, 1, 10)) | |
308 | master_item = self.ITEM_CLASS( | |
309 | folder=self.test_folder, | |
310 | start=start, | |
311 | end=end, | |
312 | subject=get_random_string(16), | |
313 | recurrence=Recurrence(pattern=DailyPattern(interval=1), start=start.date(), number=4), | |
314 | categories=self.categories, | |
315 | ).save() | |
316 | ||
317 | # Change the start and end of the second occurrence | |
318 | second_occurrence = master_item.occurrence(index=2) | |
319 | second_occurrence.start = start + datetime.timedelta(days=1, hours=1) | |
320 | second_occurrence.end = end + datetime.timedelta(days=1, hours=1) | |
321 | second_occurrence.save(update_fields=['start', 'end']) # Test that UpdateItem works with only a few fields | |
322 | ||
323 | second_occurrence = master_item.occurrence(index=2) | |
324 | second_occurrence.refresh() | |
325 | self.assertEqual(second_occurrence.subject, master_item.subject) | |
326 | second_occurrence.start += datetime.timedelta(hours=1) | |
327 | second_occurrence.end += datetime.timedelta(hours=1) | |
328 | second_occurrence.save(update_fields=['start', 'end']) # Test that UpdateItem works after refresh | |
329 | ||
330 | # Test change on the master item | |
331 | master_item.refresh() | |
332 | self.assertEqual(len(master_item.modified_occurrences), 1) | |
333 | modified_occurrence = master_item.modified_occurrences[0] | |
334 | self.assertIsInstance(modified_occurrence, Occurrence) | |
335 | self.assertEqual(modified_occurrence.id, second_occurrence.id) | |
336 | self.assertEqual(modified_occurrence.start, second_occurrence.start) | |
337 | self.assertEqual(modified_occurrence.end, second_occurrence.end) | |
338 | self.assertEqual(modified_occurrence.original_start, second_occurrence.start - datetime.timedelta(hours=2)) | |
339 | self.assertEqual(master_item.deleted_occurrences, None) | |
340 | ||
341 | def test_delete_occurrence_via_index(self): | |
342 | # Test deleting occurrences via occurrence index without knowing the ID of the occurrence. | |
343 | start = self.account.default_timezone.localize(EWSDateTime(2016, 1, 1, 8)) | |
344 | end = self.account.default_timezone.localize(EWSDateTime(2016, 1, 1, 10)) | |
345 | master_item = self.ITEM_CLASS( | |
346 | folder=self.test_folder, | |
347 | start=start, | |
348 | end=end, | |
349 | subject=get_random_string(16), | |
350 | recurrence=Recurrence(pattern=DailyPattern(interval=1), start=start.date(), number=4), | |
351 | categories=self.categories, | |
352 | ).save() | |
353 | ||
354 | # Delete the third occurrence | |
355 | third_occurrence = master_item.occurrence(index=3) | |
356 | third_occurrence.refresh() # Test that GetItem works | |
357 | ||
358 | third_occurrence = master_item.occurrence(index=3) | |
359 | third_occurrence.delete() # Test that DeleteItem works | |
360 | ||
361 | # Test change on the master item | |
362 | master_item.refresh() | |
363 | self.assertEqual(master_item.modified_occurrences, None) | |
364 | self.assertEqual(len(master_item.deleted_occurrences), 1) | |
365 | deleted_occurrence = master_item.deleted_occurrences[0] | |
366 | self.assertIsInstance(deleted_occurrence, DeletedOccurrence) | |
367 | self.assertEqual(deleted_occurrence.start, start + datetime.timedelta(days=2)) | |
368 | ||
369 | def test_get_master_recurrence(self): | |
370 | # Test getting the master recurrence via an occurrence | |
371 | start = self.account.default_timezone.localize(EWSDateTime(2016, 1, 1, 8)) | |
372 | end = self.account.default_timezone.localize(EWSDateTime(2016, 1, 1, 10)) | |
373 | master_item = self.ITEM_CLASS( | |
374 | folder=self.test_folder, | |
375 | start=start, | |
376 | end=end, | |
377 | subject=get_random_string(16), | |
378 | recurrence=Recurrence(pattern=DailyPattern(interval=1), start=start.date(), number=4), | |
379 | categories=self.categories, | |
380 | ).save() | |
381 | ||
382 | # Get the master from an occurrence | |
383 | range_start, range_end = start, end + datetime.timedelta(days=3) | |
384 | unfolded = [i for i in self.test_folder.view(start=range_start, end=range_end) if self.match_cat(i)] | |
385 | third_occurrence = unfolded[2] | |
386 | master_from_occurrence = third_occurrence.recurring_master() | |
387 | ||
388 | master_from_occurrence.refresh() # Test that GetItem works | |
389 | self.assertEqual(master_from_occurrence.subject, master_item.subject) | |
390 | ||
391 | master_from_occurrence = third_occurrence.recurring_master() | |
392 | master_from_occurrence.subject = get_random_string(16) | |
393 | master_from_occurrence.save(update_fields=['subject']) # Test that UpdateItem works | |
394 | master_from_occurrence.delete() # Test that DeleteItem works | |
395 | ||
396 | with self.assertRaises(ErrorItemNotFound): | |
397 | master_item.delete() # Item is gone from the server, so this should fail |
0 | from exchangelib.errors import ErrorInvalidIdMalformed | |
1 | from exchangelib.folders import Contacts, FolderCollection | |
2 | from exchangelib.indexed_properties import EmailAddress, PhysicalAddress | |
3 | from exchangelib.items import Contact, DistributionList, Persona | |
4 | from exchangelib.properties import Mailbox, Member | |
5 | from exchangelib.queryset import QuerySet | |
6 | from exchangelib.services import GetPersona | |
7 | ||
8 | from ..common import get_random_string, get_random_email | |
9 | from .test_basics import CommonItemTest | |
10 | ||
11 | ||
12 | class ContactsTest(CommonItemTest): | |
13 | TEST_FOLDER = 'contacts' | |
14 | FOLDER_CLASS = Contacts | |
15 | ITEM_CLASS = Contact | |
16 | ||
17 | def test_order_by_on_indexed_field(self): | |
18 | # Test order_by() on IndexedField (simple and multi-subfield). Only Contact items have these | |
19 | test_items = [] | |
20 | label = self.random_val(EmailAddress.get_field_by_fieldname('label')) | |
21 | for i in range(4): | |
22 | item = self.get_test_item() | |
23 | item.email_addresses = [EmailAddress(email='%s@foo.com' % i, label=label)] | |
24 | test_items.append(item) | |
25 | self.test_folder.bulk_create(items=test_items) | |
26 | qs = QuerySet( | |
27 | folder_collection=FolderCollection(account=self.account, folders=[self.test_folder]) | |
28 | ).filter(categories__contains=self.categories) | |
29 | self.assertEqual( | |
30 | [i[0].email for i in qs.order_by('email_addresses__%s' % label) | |
31 | .values_list('email_addresses', flat=True)], | |
32 | ['0@foo.com', '1@foo.com', '2@foo.com', '3@foo.com'] | |
33 | ) | |
34 | self.assertEqual( | |
35 | [i[0].email for i in qs.order_by('-email_addresses__%s' % label) | |
36 | .values_list('email_addresses', flat=True)], | |
37 | ['3@foo.com', '2@foo.com', '1@foo.com', '0@foo.com'] | |
38 | ) | |
39 | self.bulk_delete(qs) | |
40 | ||
41 | test_items = [] | |
42 | label = self.random_val(PhysicalAddress.get_field_by_fieldname('label')) | |
43 | for i in range(4): | |
44 | item = self.get_test_item() | |
45 | item.physical_addresses = [PhysicalAddress(street='Elm St %s' % i, label=label)] | |
46 | test_items.append(item) | |
47 | self.test_folder.bulk_create(items=test_items) | |
48 | qs = QuerySet( | |
49 | folder_collection=FolderCollection(account=self.account, folders=[self.test_folder]) | |
50 | ).filter(categories__contains=self.categories) | |
51 | self.assertEqual( | |
52 | [i[0].street for i in qs.order_by('physical_addresses__%s__street' % label) | |
53 | .values_list('physical_addresses', flat=True)], | |
54 | ['Elm St 0', 'Elm St 1', 'Elm St 2', 'Elm St 3'] | |
55 | ) | |
56 | self.assertEqual( | |
57 | [i[0].street for i in qs.order_by('-physical_addresses__%s__street' % label) | |
58 | .values_list('physical_addresses', flat=True)], | |
59 | ['Elm St 3', 'Elm St 2', 'Elm St 1', 'Elm St 0'] | |
60 | ) | |
61 | self.bulk_delete(qs) | |
62 | ||
63 | def test_order_by_failure(self): | |
64 | # Test error handling on indexed properties with labels and subfields | |
65 | qs = QuerySet( | |
66 | folder_collection=FolderCollection(account=self.account, folders=[self.test_folder]) | |
67 | ).filter(categories__contains=self.categories) | |
68 | with self.assertRaises(ValueError): | |
69 | qs.order_by('email_addresses') # Must have label | |
70 | with self.assertRaises(ValueError): | |
71 | qs.order_by('email_addresses__FOO') # Must have a valid label | |
72 | with self.assertRaises(ValueError): | |
73 | qs.order_by('email_addresses__EmailAddress1__FOO') # Must not have a subfield | |
74 | with self.assertRaises(ValueError): | |
75 | qs.order_by('physical_addresses__Business') # Must have a subfield | |
76 | with self.assertRaises(ValueError): | |
77 | qs.order_by('physical_addresses__Business__FOO') # Must have a valid subfield | |
78 | ||
79 | def test_distribution_lists(self): | |
80 | dl = DistributionList(folder=self.test_folder, display_name=get_random_string(255), categories=self.categories) | |
81 | dl.save() | |
82 | new_dl = self.test_folder.get(categories__contains=dl.categories) | |
83 | self.assertEqual(new_dl.display_name, dl.display_name) | |
84 | self.assertEqual(new_dl.members, None) | |
85 | dl.refresh() | |
86 | ||
87 | dl.members = set( | |
88 | # We set mailbox_type to OneOff because otherwise the email address must be an actual account | |
89 | Member(mailbox=Mailbox(email_address=get_random_email(), mailbox_type='OneOff')) for _ in range(4) | |
90 | ) | |
91 | dl.save() | |
92 | new_dl = self.test_folder.get(categories__contains=dl.categories) | |
93 | self.assertEqual({m.mailbox.email_address for m in new_dl.members}, dl.members) | |
94 | ||
95 | dl.delete() | |
96 | ||
97 | def test_find_people(self): | |
98 | # The test server may not have any contacts. Just test that the FindPeople service and helpers work | |
99 | self.assertGreaterEqual(len(list(self.test_folder.people())), 0) | |
100 | self.assertGreaterEqual( | |
101 | len(list( | |
102 | self.test_folder.people().only('display_name').filter(display_name='john').order_by('display_name') | |
103 | )), | |
104 | 0 | |
105 | ) | |
106 | ||
107 | def test_get_persona(self): | |
108 | # The test server may not have any personas. Just test that the service response with something we can parse | |
109 | persona = Persona(id='AAA=', changekey='xxx') | |
110 | try: | |
111 | GetPersona(protocol=self.account.protocol).call(persona=persona) | |
112 | except ErrorInvalidIdMalformed: | |
113 | pass |
0 | import datetime | |
1 | ||
2 | from exchangelib.attachments import ItemAttachment | |
3 | from exchangelib.errors import ErrorItemNotFound | |
4 | from exchangelib.ewsdatetime import UTC_NOW | |
5 | from exchangelib.extended_properties import ExtendedProperty, ExternId | |
6 | from exchangelib.fields import ExtendedPropertyField, CharField | |
7 | from exchangelib.folders import Inbox, FolderCollection | |
8 | from exchangelib.items import CalendarItem, Message | |
9 | from exchangelib.queryset import QuerySet | |
10 | from exchangelib.restriction import Restriction, Q | |
11 | from exchangelib.version import Build, EXCHANGE_2007, EXCHANGE_2013 | |
12 | ||
13 | from ..common import get_random_string, mock_version | |
14 | from .test_basics import CommonItemTest | |
15 | ||
16 | ||
17 | class GenericItemTest(CommonItemTest): | |
18 | # Tests that don't need to be run for every single folder type | |
19 | TEST_FOLDER = 'inbox' | |
20 | FOLDER_CLASS = Inbox | |
21 | ITEM_CLASS = Message | |
22 | ||
23 | def test_validation(self): | |
24 | item = self.get_test_item() | |
25 | item.clean() | |
26 | for f in self.ITEM_CLASS.FIELDS: | |
27 | with self.subTest(f=f): | |
28 | # Test field max_length | |
29 | if isinstance(f, CharField) and f.max_length: | |
30 | with self.assertRaises(ValueError): | |
31 | setattr(item, f.name, 'a' * (f.max_length + 1)) | |
32 | item.clean() | |
33 | setattr(item, f.name, 'a') | |
34 | ||
35 | def test_invalid_direct_args(self): | |
36 | with self.assertRaises(ValueError): | |
37 | item = self.get_test_item() | |
38 | item.account = None | |
39 | item.save() # Must have account on save | |
40 | with self.assertRaises(ValueError): | |
41 | item = self.get_test_item() | |
42 | item.id = 'XXX' # Fake a saved item | |
43 | item.account = None | |
44 | item.save() # Must have account on update | |
45 | with self.assertRaises(ValueError): | |
46 | item = self.get_test_item() | |
47 | item.save(update_fields=['foo', 'bar']) # update_fields is only valid on update | |
48 | ||
49 | with self.assertRaises(ValueError): | |
50 | item = self.get_test_item() | |
51 | item.account = None | |
52 | item.refresh() # Must have account on refresh | |
53 | with self.assertRaises(ValueError): | |
54 | item = self.get_test_item() | |
55 | item.refresh() # Refresh an item that has not been saved | |
56 | with self.assertRaises(ErrorItemNotFound): | |
57 | item = self.get_test_item() | |
58 | item.save() | |
59 | item_id, changekey = item.id, item.changekey | |
60 | item.delete() | |
61 | item.id, item.changekey = item_id, changekey | |
62 | item.refresh() # Refresh an item that doesn't exist | |
63 | ||
64 | with self.assertRaises(ValueError): | |
65 | item = self.get_test_item() | |
66 | item.account = None | |
67 | item.copy(to_folder=self.test_folder) # Must have an account on copy | |
68 | with self.assertRaises(ValueError): | |
69 | item = self.get_test_item() | |
70 | item.copy(to_folder=self.test_folder) # Must be an existing item | |
71 | with self.assertRaises(ErrorItemNotFound): | |
72 | item = self.get_test_item() | |
73 | item.save() | |
74 | item_id, changekey = item.id, item.changekey | |
75 | item.delete() | |
76 | item.id, item.changekey = item_id, changekey | |
77 | item.copy(to_folder=self.test_folder) # Item disappeared | |
78 | ||
79 | with self.assertRaises(ValueError): | |
80 | item = self.get_test_item() | |
81 | item.account = None | |
82 | item.move(to_folder=self.test_folder) # Must have an account on move | |
83 | with self.assertRaises(ValueError): | |
84 | item = self.get_test_item() | |
85 | item.move(to_folder=self.test_folder) # Must be an existing item | |
86 | with self.assertRaises(ErrorItemNotFound): | |
87 | item = self.get_test_item() | |
88 | item.save() | |
89 | item_id, changekey = item.id, item.changekey | |
90 | item.delete() | |
91 | item.id, item.changekey = item_id, changekey | |
92 | item.move(to_folder=self.test_folder) # Item disappeared | |
93 | ||
94 | with self.assertRaises(ValueError): | |
95 | item = self.get_test_item() | |
96 | item.account = None | |
97 | item.delete() # Must have an account | |
98 | with self.assertRaises(ValueError): | |
99 | item = self.get_test_item() | |
100 | item.delete() # Must be an existing item | |
101 | with self.assertRaises(ErrorItemNotFound): | |
102 | item = self.get_test_item() | |
103 | item.save() | |
104 | item_id, changekey = item.id, item.changekey | |
105 | item.delete() | |
106 | item.id, item.changekey = item_id, changekey | |
107 | item.delete() # Item disappeared | |
108 | ||
109 | def test_invalid_kwargs_on_send(self): | |
110 | # Only Message class has the send() method | |
111 | with self.assertRaises(ValueError): | |
112 | item = self.get_test_item() | |
113 | item.account = None | |
114 | item.send() # Must have account on send | |
115 | with self.assertRaises(ErrorItemNotFound): | |
116 | item = self.get_test_item() | |
117 | item.save() | |
118 | item_id, changekey = item.id, item.changekey | |
119 | item.delete() | |
120 | item.id, item.changekey = item_id, changekey | |
121 | item.send() # Item disappeared | |
122 | with self.assertRaises(AttributeError): | |
123 | item = self.get_test_item() | |
124 | item.send(copy_to_folder=self.account.trash, save_copy=False) # Inconsistent args | |
125 | ||
126 | def test_unsupported_fields(self): | |
127 | # Create a field that is not supported by any current versions. Test that we fail when using this field | |
128 | class UnsupportedProp(ExtendedProperty): | |
129 | property_set_id = 'deadcafe-beef-beef-beef-deadcafebeef' | |
130 | property_name = 'Unsupported Property' | |
131 | property_type = 'String' | |
132 | ||
133 | attr_name = 'unsupported_property' | |
134 | self.ITEM_CLASS.register(attr_name=attr_name, attr_cls=UnsupportedProp) | |
135 | try: | |
136 | for f in self.ITEM_CLASS.FIELDS: | |
137 | if f.name == attr_name: | |
138 | f.supported_from = Build(99, 99, 99, 99) | |
139 | ||
140 | with self.assertRaises(ValueError): | |
141 | self.test_folder.get(**{attr_name: 'XXX'}) | |
142 | with self.assertRaises(ValueError): | |
143 | list(self.test_folder.filter(**{attr_name: 'XXX'})) | |
144 | with self.assertRaises(ValueError): | |
145 | list(self.test_folder.all().only(attr_name)) | |
146 | with self.assertRaises(ValueError): | |
147 | list(self.test_folder.all().values(attr_name)) | |
148 | with self.assertRaises(ValueError): | |
149 | list(self.test_folder.all().values_list(attr_name)) | |
150 | finally: | |
151 | self.ITEM_CLASS.deregister(attr_name=attr_name) | |
152 | ||
153 | def test_order_by(self): | |
154 | # Test order_by() on normal field | |
155 | test_items = [] | |
156 | for i in range(4): | |
157 | item = self.get_test_item() | |
158 | item.subject = 'Subj %s' % i | |
159 | test_items.append(item) | |
160 | self.test_folder.bulk_create(items=test_items) | |
161 | qs = QuerySet( | |
162 | folder_collection=FolderCollection(account=self.account, folders=[self.test_folder]) | |
163 | ).filter(categories__contains=self.categories) | |
164 | self.assertEqual( | |
165 | [i for i in qs.order_by('subject').values_list('subject', flat=True)], | |
166 | ['Subj 0', 'Subj 1', 'Subj 2', 'Subj 3'] | |
167 | ) | |
168 | self.assertEqual( | |
169 | [i for i in qs.order_by('-subject').values_list('subject', flat=True)], | |
170 | ['Subj 3', 'Subj 2', 'Subj 1', 'Subj 0'] | |
171 | ) | |
172 | self.bulk_delete(qs) | |
173 | ||
174 | try: | |
175 | self.ITEM_CLASS.register('extern_id', ExternId) | |
176 | # Test order_by() on ExtendedProperty | |
177 | test_items = [] | |
178 | for i in range(4): | |
179 | item = self.get_test_item() | |
180 | item.extern_id = 'ID %s' % i | |
181 | test_items.append(item) | |
182 | self.test_folder.bulk_create(items=test_items) | |
183 | qs = QuerySet( | |
184 | folder_collection=FolderCollection(account=self.account, folders=[self.test_folder]) | |
185 | ).filter(categories__contains=self.categories) | |
186 | self.assertEqual( | |
187 | [i for i in qs.order_by('extern_id').values_list('extern_id', flat=True)], | |
188 | ['ID 0', 'ID 1', 'ID 2', 'ID 3'] | |
189 | ) | |
190 | self.assertEqual( | |
191 | [i for i in qs.order_by('-extern_id').values_list('extern_id', flat=True)], | |
192 | ['ID 3', 'ID 2', 'ID 1', 'ID 0'] | |
193 | ) | |
194 | finally: | |
195 | self.ITEM_CLASS.deregister('extern_id') | |
196 | self.bulk_delete(qs) | |
197 | ||
198 | # Test sorting on multiple fields | |
199 | try: | |
200 | self.ITEM_CLASS.register('extern_id', ExternId) | |
201 | test_items = [] | |
202 | for i in range(2): | |
203 | for j in range(2): | |
204 | item = self.get_test_item() | |
205 | item.subject = 'Subj %s' % i | |
206 | item.extern_id = 'ID %s' % j | |
207 | test_items.append(item) | |
208 | self.test_folder.bulk_create(items=test_items) | |
209 | qs = QuerySet( | |
210 | folder_collection=FolderCollection(account=self.account, folders=[self.test_folder]) | |
211 | ).filter(categories__contains=self.categories) | |
212 | self.assertEqual( | |
213 | [i for i in qs.order_by('subject', 'extern_id').values('subject', 'extern_id')], | |
214 | [{'subject': 'Subj 0', 'extern_id': 'ID 0'}, | |
215 | {'subject': 'Subj 0', 'extern_id': 'ID 1'}, | |
216 | {'subject': 'Subj 1', 'extern_id': 'ID 0'}, | |
217 | {'subject': 'Subj 1', 'extern_id': 'ID 1'}] | |
218 | ) | |
219 | self.assertEqual( | |
220 | [i for i in qs.order_by('-subject', 'extern_id').values('subject', 'extern_id')], | |
221 | [{'subject': 'Subj 1', 'extern_id': 'ID 0'}, | |
222 | {'subject': 'Subj 1', 'extern_id': 'ID 1'}, | |
223 | {'subject': 'Subj 0', 'extern_id': 'ID 0'}, | |
224 | {'subject': 'Subj 0', 'extern_id': 'ID 1'}] | |
225 | ) | |
226 | self.assertEqual( | |
227 | [i for i in qs.order_by('subject', '-extern_id').values('subject', 'extern_id')], | |
228 | [{'subject': 'Subj 0', 'extern_id': 'ID 1'}, | |
229 | {'subject': 'Subj 0', 'extern_id': 'ID 0'}, | |
230 | {'subject': 'Subj 1', 'extern_id': 'ID 1'}, | |
231 | {'subject': 'Subj 1', 'extern_id': 'ID 0'}] | |
232 | ) | |
233 | self.assertEqual( | |
234 | [i for i in qs.order_by('-subject', '-extern_id').values('subject', 'extern_id')], | |
235 | [{'subject': 'Subj 1', 'extern_id': 'ID 1'}, | |
236 | {'subject': 'Subj 1', 'extern_id': 'ID 0'}, | |
237 | {'subject': 'Subj 0', 'extern_id': 'ID 1'}, | |
238 | {'subject': 'Subj 0', 'extern_id': 'ID 0'}] | |
239 | ) | |
240 | finally: | |
241 | self.ITEM_CLASS.deregister('extern_id') | |
242 | ||
243 | def test_finditems(self): | |
244 | now = UTC_NOW() | |
245 | ||
246 | # Test argument types | |
247 | item = self.get_test_item() | |
248 | ids = self.test_folder.bulk_create(items=[item]) | |
249 | # No arguments. There may be leftover items in the folder, so just make sure there's at least one. | |
250 | self.assertGreaterEqual( | |
251 | self.test_folder.filter().count(), | |
252 | 1 | |
253 | ) | |
254 | # Q object | |
255 | self.assertEqual( | |
256 | self.test_folder.filter(Q(subject=item.subject)).count(), | |
257 | 1 | |
258 | ) | |
259 | # Multiple Q objects | |
260 | self.assertEqual( | |
261 | self.test_folder.filter(Q(subject=item.subject), ~Q(subject=item.subject[:-3] + 'XXX')).count(), | |
262 | 1 | |
263 | ) | |
264 | # Multiple Q object and kwargs | |
265 | self.assertEqual( | |
266 | self.test_folder.filter(Q(subject=item.subject), categories__contains=item.categories).count(), | |
267 | 1 | |
268 | ) | |
269 | self.bulk_delete(ids) | |
270 | ||
271 | # Test categories which are handled specially - only '__contains' and '__in' lookups are supported | |
272 | item = self.get_test_item(categories=['TestA', 'TestB']) | |
273 | ids = self.test_folder.bulk_create(items=[item]) | |
274 | common_qs = self.test_folder.filter(subject=item.subject) # Guard against other simultaneous runs | |
275 | self.assertEqual( | |
276 | common_qs.filter(categories__contains='ci6xahH1').count(), # Plain string | |
277 | 0 | |
278 | ) | |
279 | self.assertEqual( | |
280 | common_qs.filter(categories__contains=['ci6xahH1']).count(), # Same, but as list | |
281 | 0 | |
282 | ) | |
283 | self.assertEqual( | |
284 | common_qs.filter(categories__contains=['TestA', 'TestC']).count(), # One wrong category | |
285 | 0 | |
286 | ) | |
287 | self.assertEqual( | |
288 | common_qs.filter(categories__contains=['TESTA']).count(), # Test case insensitivity | |
289 | 1 | |
290 | ) | |
291 | self.assertEqual( | |
292 | common_qs.filter(categories__contains=['testa']).count(), # Test case insensitivity | |
293 | 1 | |
294 | ) | |
295 | self.assertEqual( | |
296 | common_qs.filter(categories__contains=['TestA']).count(), # Partial | |
297 | 1 | |
298 | ) | |
299 | self.assertEqual( | |
300 | common_qs.filter(categories__contains=item.categories).count(), # Exact match | |
301 | 1 | |
302 | ) | |
303 | with self.assertRaises(ValueError): | |
304 | common_qs.filter(categories__in='ci6xahH1').count() # Plain string is not supported | |
305 | self.assertEqual( | |
306 | common_qs.filter(categories__in=['ci6xahH1']).count(), # Same, but as list | |
307 | 0 | |
308 | ) | |
309 | self.assertEqual( | |
310 | common_qs.filter(categories__in=['TestA', 'TestC']).count(), # One wrong category | |
311 | 1 | |
312 | ) | |
313 | self.assertEqual( | |
314 | common_qs.filter(categories__in=['TestA']).count(), # Partial | |
315 | 1 | |
316 | ) | |
317 | self.assertEqual( | |
318 | common_qs.filter(categories__in=item.categories).count(), # Exact match | |
319 | 1 | |
320 | ) | |
321 | self.bulk_delete(ids) | |
322 | ||
323 | common_qs = self.test_folder.filter(categories__contains=self.categories) | |
324 | one_hour = datetime.timedelta(hours=1) | |
325 | two_hours = datetime.timedelta(hours=2) | |
326 | # Test 'exists' | |
327 | ids = self.test_folder.bulk_create(items=[self.get_test_item()]) | |
328 | self.assertEqual( | |
329 | common_qs.filter(datetime_created__exists=True).count(), | |
330 | 1 | |
331 | ) | |
332 | self.assertEqual( | |
333 | common_qs.filter(datetime_created__exists=False).count(), | |
334 | 0 | |
335 | ) | |
336 | self.bulk_delete(ids) | |
337 | ||
338 | # Test 'range' | |
339 | ids = self.test_folder.bulk_create(items=[self.get_test_item()]) | |
340 | self.assertEqual( | |
341 | common_qs.filter(datetime_created__range=(now + one_hour, now + two_hours)).count(), | |
342 | 0 | |
343 | ) | |
344 | self.assertEqual( | |
345 | common_qs.filter(datetime_created__range=(now - one_hour, now + one_hour)).count(), | |
346 | 1 | |
347 | ) | |
348 | self.bulk_delete(ids) | |
349 | ||
350 | # Test '>' | |
351 | ids = self.test_folder.bulk_create(items=[self.get_test_item()]) | |
352 | self.assertEqual( | |
353 | common_qs.filter(datetime_created__gt=now + one_hour).count(), | |
354 | 0 | |
355 | ) | |
356 | self.assertEqual( | |
357 | common_qs.filter(datetime_created__gt=now - one_hour).count(), | |
358 | 1 | |
359 | ) | |
360 | self.bulk_delete(ids) | |
361 | ||
362 | # Test '>=' | |
363 | ids = self.test_folder.bulk_create(items=[self.get_test_item()]) | |
364 | self.assertEqual( | |
365 | common_qs.filter(datetime_created__gte=now + one_hour).count(), | |
366 | 0 | |
367 | ) | |
368 | self.assertEqual( | |
369 | common_qs.filter(datetime_created__gte=now - one_hour).count(), | |
370 | 1 | |
371 | ) | |
372 | self.bulk_delete(ids) | |
373 | ||
374 | # Test '<' | |
375 | ids = self.test_folder.bulk_create(items=[self.get_test_item()]) | |
376 | self.assertEqual( | |
377 | common_qs.filter(datetime_created__lt=now - one_hour).count(), | |
378 | 0 | |
379 | ) | |
380 | self.assertEqual( | |
381 | common_qs.filter(datetime_created__lt=now + one_hour).count(), | |
382 | 1 | |
383 | ) | |
384 | self.bulk_delete(ids) | |
385 | ||
386 | # Test '<=' | |
387 | ids = self.test_folder.bulk_create(items=[self.get_test_item()]) | |
388 | self.assertEqual( | |
389 | common_qs.filter(datetime_created__lte=now - one_hour).count(), | |
390 | 0 | |
391 | ) | |
392 | self.assertEqual( | |
393 | common_qs.filter(datetime_created__lte=now + one_hour).count(), | |
394 | 1 | |
395 | ) | |
396 | self.bulk_delete(ids) | |
397 | ||
398 | # Test '=' | |
399 | item = self.get_test_item() | |
400 | ids = self.test_folder.bulk_create(items=[item]) | |
401 | self.assertEqual( | |
402 | common_qs.filter(subject=item.subject[:-3] + 'XXX').count(), | |
403 | 0 | |
404 | ) | |
405 | self.assertEqual( | |
406 | common_qs.filter(subject=item.subject).count(), | |
407 | 1 | |
408 | ) | |
409 | self.bulk_delete(ids) | |
410 | ||
411 | # Test '!=' | |
412 | item = self.get_test_item() | |
413 | ids = self.test_folder.bulk_create(items=[item]) | |
414 | self.assertEqual( | |
415 | common_qs.filter(subject__not=item.subject).count(), | |
416 | 0 | |
417 | ) | |
418 | self.assertEqual( | |
419 | common_qs.filter(subject__not=item.subject[:-3] + 'XXX').count(), | |
420 | 1 | |
421 | ) | |
422 | self.bulk_delete(ids) | |
423 | ||
424 | # Test 'exact' | |
425 | item = self.get_test_item() | |
426 | item.subject = 'aA' + item.subject[2:] | |
427 | ids = self.test_folder.bulk_create(items=[item]) | |
428 | self.assertEqual( | |
429 | common_qs.filter(subject__exact=item.subject[:-3] + 'XXX').count(), | |
430 | 0 | |
431 | ) | |
432 | self.assertEqual( | |
433 | common_qs.filter(subject__exact=item.subject.lower()).count(), | |
434 | 0 | |
435 | ) | |
436 | self.assertEqual( | |
437 | common_qs.filter(subject__exact=item.subject.upper()).count(), | |
438 | 0 | |
439 | ) | |
440 | self.assertEqual( | |
441 | common_qs.filter(subject__exact=item.subject).count(), | |
442 | 1 | |
443 | ) | |
444 | self.bulk_delete(ids) | |
445 | ||
446 | # Test 'iexact' | |
447 | item = self.get_test_item() | |
448 | item.subject = 'aA' + item.subject[2:] | |
449 | ids = self.test_folder.bulk_create(items=[item]) | |
450 | self.assertEqual( | |
451 | common_qs.filter(subject__iexact=item.subject[:-3] + 'XXX').count(), | |
452 | 0 | |
453 | ) | |
454 | self.assertIn( | |
455 | common_qs.filter(subject__iexact=item.subject.lower()).count(), | |
456 | (0, 1) # iexact search is broken on some EWS versions | |
457 | ) | |
458 | self.assertIn( | |
459 | common_qs.filter(subject__iexact=item.subject.upper()).count(), | |
460 | (0, 1) # iexact search is broken on some EWS versions | |
461 | ) | |
462 | self.assertEqual( | |
463 | common_qs.filter(subject__iexact=item.subject).count(), | |
464 | 1 | |
465 | ) | |
466 | self.bulk_delete(ids) | |
467 | ||
468 | # Test 'contains' | |
469 | item = self.get_test_item() | |
470 | item.subject = item.subject[2:8] + 'aA' + item.subject[8:] | |
471 | ids = self.test_folder.bulk_create(items=[item]) | |
472 | self.assertEqual( | |
473 | common_qs.filter(subject__contains=item.subject[2:14] + 'XXX').count(), | |
474 | 0 | |
475 | ) | |
476 | self.assertEqual( | |
477 | common_qs.filter(subject__contains=item.subject[2:14].lower()).count(), | |
478 | 0 | |
479 | ) | |
480 | self.assertEqual( | |
481 | common_qs.filter(subject__contains=item.subject[2:14].upper()).count(), | |
482 | 0 | |
483 | ) | |
484 | self.assertEqual( | |
485 | common_qs.filter(subject__contains=item.subject[2:14]).count(), | |
486 | 1 | |
487 | ) | |
488 | self.bulk_delete(ids) | |
489 | ||
490 | # Test 'icontains' | |
491 | item = self.get_test_item() | |
492 | item.subject = item.subject[2:8] + 'aA' + item.subject[8:] | |
493 | ids = self.test_folder.bulk_create(items=[item]) | |
494 | self.assertEqual( | |
495 | common_qs.filter(subject__icontains=item.subject[2:14] + 'XXX').count(), | |
496 | 0 | |
497 | ) | |
498 | self.assertIn( | |
499 | common_qs.filter(subject__icontains=item.subject[2:14].lower()).count(), | |
500 | (0, 1) # icontains search is broken on some EWS versions | |
501 | ) | |
502 | self.assertIn( | |
503 | common_qs.filter(subject__icontains=item.subject[2:14].upper()).count(), | |
504 | (0, 1) # icontains search is broken on some EWS versions | |
505 | ) | |
506 | self.assertEqual( | |
507 | common_qs.filter(subject__icontains=item.subject[2:14]).count(), | |
508 | 1 | |
509 | ) | |
510 | self.bulk_delete(ids) | |
511 | ||
512 | # Test 'startswith' | |
513 | item = self.get_test_item() | |
514 | item.subject = 'aA' + item.subject[2:] | |
515 | ids = self.test_folder.bulk_create(items=[item]) | |
516 | self.assertEqual( | |
517 | common_qs.filter(subject__startswith='XXX' + item.subject[:12]).count(), | |
518 | 0 | |
519 | ) | |
520 | self.assertEqual( | |
521 | common_qs.filter(subject__startswith=item.subject[:12].lower()).count(), | |
522 | 0 | |
523 | ) | |
524 | self.assertEqual( | |
525 | common_qs.filter(subject__startswith=item.subject[:12].upper()).count(), | |
526 | 0 | |
527 | ) | |
528 | self.assertEqual( | |
529 | common_qs.filter(subject__startswith=item.subject[:12]).count(), | |
530 | 1 | |
531 | ) | |
532 | self.bulk_delete(ids) | |
533 | ||
534 | # Test 'istartswith' | |
535 | item = self.get_test_item() | |
536 | item.subject = 'aA' + item.subject[2:] | |
537 | ids = self.test_folder.bulk_create(items=[item]) | |
538 | self.assertEqual( | |
539 | common_qs.filter(subject__istartswith='XXX' + item.subject[:12]).count(), | |
540 | 0 | |
541 | ) | |
542 | self.assertIn( | |
543 | common_qs.filter(subject__istartswith=item.subject[:12].lower()).count(), | |
544 | (0, 1) # istartswith search is broken on some EWS versions | |
545 | ) | |
546 | self.assertIn( | |
547 | common_qs.filter(subject__istartswith=item.subject[:12].upper()).count(), | |
548 | (0, 1) # istartswith search is broken on some EWS versions | |
549 | ) | |
550 | self.assertEqual( | |
551 | common_qs.filter(subject__istartswith=item.subject[:12]).count(), | |
552 | 1 | |
553 | ) | |
554 | self.bulk_delete(ids) | |
555 | ||
556 | def test_filter_with_querystring(self): | |
557 | # QueryString is only supported from Exchange 2010 | |
558 | with self.assertRaises(NotImplementedError): | |
559 | Q('Subject:XXX').to_xml(self.test_folder, version=mock_version(build=EXCHANGE_2007), | |
560 | applies_to=Restriction.ITEMS) | |
561 | ||
562 | # We don't allow QueryString in combination with other restrictions | |
563 | with self.assertRaises(ValueError): | |
564 | self.test_folder.filter('Subject:XXX', foo='bar') | |
565 | with self.assertRaises(ValueError): | |
566 | self.test_folder.filter('Subject:XXX').filter(foo='bar') | |
567 | with self.assertRaises(ValueError): | |
568 | self.test_folder.filter(foo='bar').filter('Subject:XXX') | |
569 | ||
570 | item = self.get_test_item() | |
571 | item.subject = get_random_string(length=8, spaces=False, special=False) | |
572 | item.save() | |
573 | # For some reason, the querystring search doesn't work instantly. We may have to wait for up to 60 seconds. | |
574 | # I'm too impatient for that, so also allow empty results. This makes the test almost worthless but I blame EWS. | |
575 | self.assertIn( | |
576 | self.test_folder.filter('Subject:%s' % item.subject).count(), | |
577 | (0, 1) | |
578 | ) | |
579 | ||
580 | def test_complex_fields(self): | |
581 | # Test that complex fields can be fetched using only(). This is a test for #141. | |
582 | item = self.get_test_item().save() | |
583 | for f in self.ITEM_CLASS.FIELDS: | |
584 | with self.subTest(f=f): | |
585 | if not f.supports_version(self.account.version): | |
586 | # Cannot be used with this EWS version | |
587 | continue | |
588 | if f.name in ('optional_attendees', 'required_attendees', 'resources'): | |
589 | continue | |
590 | if f.is_read_only: | |
591 | continue | |
592 | if f.name == 'reminder_due_by': | |
593 | # EWS sets a default value if it is not set on insert. Ignore | |
594 | continue | |
595 | if f.name == 'mime_content': | |
596 | # This will change depending on other contents fields | |
597 | continue | |
598 | old = getattr(item, f.name) | |
599 | # Test field as single element in only() | |
600 | fresh_item = self.test_folder.all().only(f.name).get(categories__contains=item.categories) | |
601 | new = getattr(fresh_item, f.name) | |
602 | if f.is_list: | |
603 | old, new = set(old or ()), set(new or ()) | |
604 | self.assertEqual(old, new, (f.name, old, new)) | |
605 | # Test field as one of the elements in only() | |
606 | fresh_item = self.test_folder.all().only('subject', f.name).get(categories__contains=item.categories) | |
607 | new = getattr(fresh_item, f.name) | |
608 | if f.is_list: | |
609 | old, new = set(old or ()), set(new or ()) | |
610 | self.assertEqual(old, new, (f.name, old, new)) | |
611 | ||
612 | def test_text_body(self): | |
613 | if self.account.version.build < EXCHANGE_2013: | |
614 | raise self.skipTest('Exchange version too old') | |
615 | item = self.get_test_item() | |
616 | item.body = 'X' * 500 # Make body longer than the normal 256 char text field limit | |
617 | item.save() | |
618 | fresh_item = self.test_folder.filter(categories__contains=item.categories).only('text_body')[0] | |
619 | self.assertEqual(fresh_item.text_body, item.body) | |
620 | ||
621 | def test_only_fields(self): | |
622 | item = self.get_test_item().save() | |
623 | item = self.test_folder.get(categories__contains=item.categories) | |
624 | self.assertIsInstance(item, self.ITEM_CLASS) | |
625 | for f in self.ITEM_CLASS.FIELDS: | |
626 | with self.subTest(f=f): | |
627 | self.assertTrue(hasattr(item, f.name)) | |
628 | if not f.supports_version(self.account.version): | |
629 | # Cannot be used with this EWS version | |
630 | continue | |
631 | if f.name in ('optional_attendees', 'required_attendees', 'resources'): | |
632 | continue | |
633 | if f.name == 'reminder_due_by' and not item.reminder_is_set: | |
634 | # We delete the due date if reminder is not set | |
635 | continue | |
636 | elif f.is_read_only: | |
637 | continue | |
638 | self.assertIsNotNone(getattr(item, f.name), (f, getattr(item, f.name))) | |
639 | only_fields = ('subject', 'body', 'categories') | |
640 | item = self.test_folder.all().only(*only_fields).get(categories__contains=item.categories) | |
641 | self.assertIsInstance(item, self.ITEM_CLASS) | |
642 | for f in self.ITEM_CLASS.FIELDS: | |
643 | with self.subTest(f=f): | |
644 | self.assertTrue(hasattr(item, f.name)) | |
645 | if not f.supports_version(self.account.version): | |
646 | # Cannot be used with this EWS version | |
647 | continue | |
648 | if f.name in only_fields: | |
649 | self.assertIsNotNone(getattr(item, f.name), (f.name, getattr(item, f.name))) | |
650 | elif f.is_required: | |
651 | v = getattr(item, f.name) | |
652 | if f.name == 'attachments': | |
653 | self.assertEqual(v, [], (f.name, v)) | |
654 | elif f.default is None: | |
655 | self.assertIsNone(v, (f.name, v)) | |
656 | else: | |
657 | self.assertEqual(v, f.default, (f.name, v)) | |
658 | ||
659 | def test_export_and_upload(self): | |
660 | # 15 new items which we will attempt to export and re-upload | |
661 | items = [self.get_test_item().save() for _ in range(15)] | |
662 | ids = [(i.id, i.changekey) for i in items] | |
663 | # re-fetch items because there will be some extra fields added by the server | |
664 | items = list(self.account.fetch(items)) | |
665 | ||
666 | # Try exporting and making sure we get the right response | |
667 | export_results = self.account.export(items) | |
668 | self.assertEqual(len(items), len(export_results)) | |
669 | for result in export_results: | |
670 | self.assertIsInstance(result, str) | |
671 | ||
672 | # Try reuploading our results | |
673 | upload_results = self.account.upload([(self.test_folder, data) for data in export_results]) | |
674 | self.assertEqual(len(items), len(upload_results), (items, upload_results)) | |
675 | for result in upload_results: | |
676 | # Must be a completely new ItemId | |
677 | self.assertIsInstance(result, tuple) | |
678 | self.assertNotIn(result, ids) | |
679 | ||
680 | # Check the items uploaded are the same as the original items | |
681 | def to_dict(item): | |
682 | dict_item = {} | |
683 | # fieldnames is everything except the ID so we'll use it to compare | |
684 | for f in self.ITEM_CLASS.FIELDS: | |
685 | # datetime_created and last_modified_time aren't copied, but instead are added to the new item after | |
686 | # uploading. This means mime_content and size can also change. Items also get new IDs on upload. And | |
687 | # meeting_count values are dependent on contents of current calendar. Form query strings contain the | |
688 | # item ID and will also change. | |
689 | if f.name in {'_id', 'first_occurrence', 'last_occurrence', 'datetime_created', | |
690 | 'last_modified_time', 'mime_content', 'size', 'conversation_id', | |
691 | 'adjacent_meeting_count', 'conflicting_meeting_count', | |
692 | 'web_client_read_form_query_string', 'web_client_edit_form_query_string'}: | |
693 | continue | |
694 | dict_item[f.name] = getattr(item, f.name) | |
695 | if f.name == 'attachments': | |
696 | # Attachments get new IDs on upload. Wipe them here so we can compare the other fields | |
697 | for a in dict_item[f.name]: | |
698 | a.attachment_id = None | |
699 | return dict_item | |
700 | ||
701 | uploaded_items = sorted([to_dict(item) for item in self.account.fetch(upload_results)], | |
702 | key=lambda i: i['subject']) | |
703 | original_items = sorted([to_dict(item) for item in items], key=lambda i: i['subject']) | |
704 | self.assertListEqual(original_items, uploaded_items) | |
705 | ||
706 | def test_export_with_error(self): | |
707 | # 15 new items which we will attempt to export and re-upload | |
708 | items = [self.get_test_item().save() for _ in range(15)] | |
709 | # Use id tuples for export here because deleting an item clears it's | |
710 | # id. | |
711 | ids = [(item.id, item.changekey) for item in items] | |
712 | # Delete one of the items, this will cause an error | |
713 | items[3].delete() | |
714 | ||
715 | export_results = self.account.export(ids) | |
716 | self.assertEqual(len(items), len(export_results)) | |
717 | for idx, result in enumerate(export_results): | |
718 | if idx == 3: | |
719 | # If it is the one returning the error | |
720 | self.assertIsInstance(result, ErrorItemNotFound) | |
721 | else: | |
722 | self.assertIsInstance(result, str) | |
723 | ||
724 | # Clean up after yourself | |
725 | del ids[3] # Sending the deleted one through will cause an error | |
726 | ||
727 | def test_item_attachments(self): | |
728 | item = self.get_test_item(folder=self.test_folder) | |
729 | item.attachments = [] | |
730 | ||
731 | attached_item1 = self.get_test_item(folder=self.test_folder) | |
732 | attached_item1.attachments = [] | |
733 | attached_item1.save() | |
734 | attachment1 = ItemAttachment(name='attachment1', item=attached_item1) | |
735 | item.attach(attachment1) | |
736 | ||
737 | self.assertEqual(len(item.attachments), 1) | |
738 | item.save() | |
739 | fresh_item = list(self.account.fetch(ids=[item]))[0] | |
740 | self.assertEqual(len(fresh_item.attachments), 1) | |
741 | fresh_attachments = sorted(fresh_item.attachments, key=lambda a: a.name) | |
742 | self.assertEqual(fresh_attachments[0].name, 'attachment1') | |
743 | self.assertIsInstance(fresh_attachments[0].item, self.ITEM_CLASS) | |
744 | ||
745 | for f in self.ITEM_CLASS.FIELDS: | |
746 | with self.subTest(f=f): | |
747 | # Normalize some values we don't control | |
748 | if f.is_read_only: | |
749 | continue | |
750 | if self.ITEM_CLASS == CalendarItem and f in CalendarItem.timezone_fields(): | |
751 | # Timezone fields will (and must) be populated automatically from the timestamp | |
752 | continue | |
753 | if isinstance(f, ExtendedPropertyField): | |
754 | # Attachments don't have these values. It may be possible to request it if we can find the FieldURI | |
755 | continue | |
756 | if f.name == 'is_read': | |
757 | # This is always true for item attachments? | |
758 | continue | |
759 | if f.name == 'reminder_due_by': | |
760 | # EWS sets a default value if it is not set on insert. Ignore | |
761 | continue | |
762 | if f.name == 'mime_content': | |
763 | # This will change depending on other contents fields | |
764 | continue | |
765 | old_val = getattr(attached_item1, f.name) | |
766 | new_val = getattr(fresh_attachments[0].item, f.name) | |
767 | if f.is_list: | |
768 | old_val, new_val = set(old_val or ()), set(new_val or ()) | |
769 | self.assertEqual(old_val, new_val, (f.name, old_val, new_val)) | |
770 | ||
771 | # Test attach on saved object | |
772 | attached_item2 = self.get_test_item(folder=self.test_folder) | |
773 | attached_item2.attachments = [] | |
774 | attached_item2.save() | |
775 | attachment2 = ItemAttachment(name='attachment2', item=attached_item2) | |
776 | item.attach(attachment2) | |
777 | ||
778 | self.assertEqual(len(item.attachments), 2) | |
779 | fresh_item = list(self.account.fetch(ids=[item]))[0] | |
780 | self.assertEqual(len(fresh_item.attachments), 2) | |
781 | fresh_attachments = sorted(fresh_item.attachments, key=lambda a: a.name) | |
782 | self.assertEqual(fresh_attachments[0].name, 'attachment1') | |
783 | self.assertIsInstance(fresh_attachments[0].item, self.ITEM_CLASS) | |
784 | ||
785 | for f in self.ITEM_CLASS.FIELDS: | |
786 | with self.subTest(f=f): | |
787 | # Normalize some values we don't control | |
788 | if f.is_read_only: | |
789 | continue | |
790 | if self.ITEM_CLASS == CalendarItem and f in CalendarItem.timezone_fields(): | |
791 | # Timezone fields will (and must) be populated automatically from the timestamp | |
792 | continue | |
793 | if isinstance(f, ExtendedPropertyField): | |
794 | # Attachments don't have these values. It may be possible to request it if we can find the FieldURI | |
795 | continue | |
796 | if f.name == 'reminder_due_by': | |
797 | # EWS sets a default value if it is not set on insert. Ignore | |
798 | continue | |
799 | if f.name == 'is_read': | |
800 | # This is always true for item attachments? | |
801 | continue | |
802 | if f.name == 'mime_content': | |
803 | # This will change depending on other contents fields | |
804 | continue | |
805 | old_val = getattr(attached_item1, f.name) | |
806 | new_val = getattr(fresh_attachments[0].item, f.name) | |
807 | if f.is_list: | |
808 | old_val, new_val = set(old_val or ()), set(new_val or ()) | |
809 | self.assertEqual(old_val, new_val, (f.name, old_val, new_val)) | |
810 | ||
811 | self.assertEqual(fresh_attachments[1].name, 'attachment2') | |
812 | self.assertIsInstance(fresh_attachments[1].item, self.ITEM_CLASS) | |
813 | ||
814 | for f in self.ITEM_CLASS.FIELDS: | |
815 | # Normalize some values we don't control | |
816 | if f.is_read_only: | |
817 | continue | |
818 | if self.ITEM_CLASS == CalendarItem and f in CalendarItem.timezone_fields(): | |
819 | # Timezone fields will (and must) be populated automatically from the timestamp | |
820 | continue | |
821 | if isinstance(f, ExtendedPropertyField): | |
822 | # Attachments don't have these values. It may be possible to request it if we can find the FieldURI | |
823 | continue | |
824 | if f.name == 'reminder_due_by': | |
825 | # EWS sets a default value if it is not set on insert. Ignore | |
826 | continue | |
827 | if f.name == 'is_read': | |
828 | # This is always true for item attachments? | |
829 | continue | |
830 | if f.name == 'mime_content': | |
831 | # This will change depending on other contents fields | |
832 | continue | |
833 | old_val = getattr(attached_item2, f.name) | |
834 | new_val = getattr(fresh_attachments[1].item, f.name) | |
835 | if f.is_list: | |
836 | old_val, new_val = set(old_val or ()), set(new_val or ()) | |
837 | self.assertEqual(old_val, new_val, (f.name, old_val, new_val)) | |
838 | ||
839 | # Test detach | |
840 | item.detach(attachment2) | |
841 | self.assertTrue(attachment2.attachment_id is None) | |
842 | self.assertTrue(attachment2.parent_item is None) | |
843 | fresh_item = list(self.account.fetch(ids=[item]))[0] | |
844 | self.assertEqual(len(fresh_item.attachments), 1) | |
845 | fresh_attachments = sorted(fresh_item.attachments, key=lambda a: a.name) | |
846 | ||
847 | for f in self.ITEM_CLASS.FIELDS: | |
848 | with self.subTest(f=f): | |
849 | # Normalize some values we don't control | |
850 | if f.is_read_only: | |
851 | continue | |
852 | if self.ITEM_CLASS == CalendarItem and f in CalendarItem.timezone_fields(): | |
853 | # Timezone fields will (and must) be populated automatically from the timestamp | |
854 | continue | |
855 | if isinstance(f, ExtendedPropertyField): | |
856 | # Attachments don't have these values. It may be possible to request it if we can find the FieldURI | |
857 | continue | |
858 | if f.name == 'reminder_due_by': | |
859 | # EWS sets a default value if it is not set on insert. Ignore | |
860 | continue | |
861 | if f.name == 'is_read': | |
862 | # This is always true for item attachments? | |
863 | continue | |
864 | if f.name == 'mime_content': | |
865 | # This will change depending on other contents fields | |
866 | continue | |
867 | old_val = getattr(attached_item1, f.name) | |
868 | new_val = getattr(fresh_attachments[0].item, f.name) | |
869 | if f.is_list: | |
870 | old_val, new_val = set(old_val or ()), set(new_val or ()) | |
871 | self.assertEqual(old_val, new_val, (f.name, old_val, new_val)) | |
872 | ||
873 | # Test attach with non-saved item | |
874 | attached_item3 = self.get_test_item(folder=self.test_folder) | |
875 | attached_item3.attachments = [] | |
876 | attachment3 = ItemAttachment(name='attachment2', item=attached_item3) | |
877 | item.attach(attachment3) | |
878 | item.detach(attachment3) |
0 | from exchangelib.errors import ErrorItemNotFound | |
1 | from exchangelib.folders import Inbox | |
2 | from exchangelib.items import Message | |
3 | ||
4 | from .test_basics import BaseItemTest | |
5 | ||
6 | ||
7 | class ItemHelperTest(BaseItemTest): | |
8 | TEST_FOLDER = 'inbox' | |
9 | FOLDER_CLASS = Inbox | |
10 | ITEM_CLASS = Message | |
11 | ||
12 | def test_save_with_update_fields(self): | |
13 | item = self.get_test_item() | |
14 | with self.assertRaises(ValueError): | |
15 | item.save(update_fields=['subject']) # update_fields does not work on item creation | |
16 | item.save() | |
17 | item.subject = 'XXX' | |
18 | item.body = 'YYY' | |
19 | item.save(update_fields=['subject']) | |
20 | item.refresh() | |
21 | self.assertEqual(item.subject, 'XXX') | |
22 | self.assertNotEqual(item.body, 'YYY') | |
23 | ||
24 | # Test invalid 'update_fields' input | |
25 | with self.assertRaises(ValueError) as e: | |
26 | item.save(update_fields=['xxx']) | |
27 | self.assertEqual( | |
28 | e.exception.args[0], | |
29 | "Field name(s) 'xxx' are not valid for a '%s' item" % self.ITEM_CLASS.__name__ | |
30 | ) | |
31 | with self.assertRaises(ValueError) as e: | |
32 | item.save(update_fields='subject') | |
33 | self.assertEqual( | |
34 | e.exception.args[0], | |
35 | "Field name(s) 's', 'u', 'b', 'j', 'e', 'c', 't' are not valid for a '%s' item" % self.ITEM_CLASS.__name__ | |
36 | ) | |
37 | ||
38 | def test_soft_delete(self): | |
39 | # First, empty trash bin | |
40 | self.account.trash.filter(categories__contains=self.categories).delete() | |
41 | self.account.recoverable_items_deletions.filter(categories__contains=self.categories).delete() | |
42 | item = self.get_test_item().save() | |
43 | item_id = (item.id, item.changekey) | |
44 | # Soft delete | |
45 | item.soft_delete() | |
46 | for e in self.account.fetch(ids=[item_id]): | |
47 | # It's gone from the test folder | |
48 | self.assertIsInstance(e, ErrorItemNotFound) | |
49 | # Really gone, not just changed ItemId | |
50 | self.assertEqual(self.test_folder.filter(categories__contains=item.categories).count(), 0) | |
51 | self.assertEqual(self.account.trash.filter(categories__contains=item.categories).count(), 0) | |
52 | # But we can find it in the recoverable items folder | |
53 | self.assertEqual( | |
54 | self.account.recoverable_items_deletions.filter(categories__contains=item.categories).count(), 1 | |
55 | ) | |
56 | ||
57 | def test_move_to_trash(self): | |
58 | # First, empty trash bin | |
59 | self.account.trash.filter(categories__contains=self.categories).delete() | |
60 | item = self.get_test_item().save() | |
61 | item_id = (item.id, item.changekey) | |
62 | # Move to trash | |
63 | item.move_to_trash() | |
64 | for e in self.account.fetch(ids=[item_id]): | |
65 | # Not in the test folder anymore | |
66 | self.assertIsInstance(e, ErrorItemNotFound) | |
67 | # Really gone, not just changed ItemId | |
68 | self.assertEqual(self.test_folder.filter(categories__contains=item.categories).count(), 0) | |
69 | # Test that the item moved to trash | |
70 | item = self.account.trash.get(categories__contains=item.categories) | |
71 | moved_item = list(self.account.fetch(ids=[item]))[0] | |
72 | # The item was copied, so the ItemId has changed. Let's compare the subject instead | |
73 | self.assertEqual(item.subject, moved_item.subject) | |
74 | ||
75 | def test_copy(self): | |
76 | # First, empty trash bin | |
77 | self.account.trash.filter(categories__contains=self.categories).delete() | |
78 | item = self.get_test_item().save() | |
79 | # Copy to trash. We use trash because it can contain all item types. | |
80 | copy_item_id, copy_changekey = item.copy(to_folder=self.account.trash) | |
81 | # Test that the item still exists in the folder | |
82 | self.assertEqual(self.test_folder.filter(categories__contains=item.categories).count(), 1) | |
83 | # Test that the copied item exists in trash | |
84 | copied_item = self.account.trash.get(categories__contains=item.categories) | |
85 | self.assertNotEqual(item.id, copied_item.id) | |
86 | self.assertNotEqual(item.changekey, copied_item.changekey) | |
87 | self.assertEqual(copy_item_id, copied_item.id) | |
88 | self.assertEqual(copy_changekey, copied_item.changekey) | |
89 | ||
90 | def test_move(self): | |
91 | # First, empty trash bin | |
92 | self.account.trash.filter(categories__contains=self.categories).delete() | |
93 | item = self.get_test_item().save() | |
94 | item_id = (item.id, item.changekey) | |
95 | # Move to trash. We use trash because it can contain all item types. This changes the ItemId | |
96 | item.move(to_folder=self.account.trash) | |
97 | for e in self.account.fetch(ids=[item_id]): | |
98 | # original item ID no longer exists | |
99 | self.assertIsInstance(e, ErrorItemNotFound) | |
100 | # Test that the item moved to trash | |
101 | self.assertEqual(self.test_folder.filter(categories__contains=item.categories).count(), 0) | |
102 | moved_item = self.account.trash.get(categories__contains=item.categories) | |
103 | self.assertEqual(item.id, moved_item.id) | |
104 | self.assertEqual(item.changekey, moved_item.changekey) | |
105 | ||
106 | def test_refresh(self): | |
107 | # Test that we can refresh items, and that refresh fails if the item no longer exists on the server | |
108 | item = self.get_test_item().save() | |
109 | orig_subject = item.subject | |
110 | item.subject = 'XXX' | |
111 | item.refresh() | |
112 | self.assertEqual(item.subject, orig_subject) | |
113 | item.delete() | |
114 | with self.assertRaises(ValueError): | |
115 | # Item no longer has an ID | |
116 | item.refresh() |
0 | from email.mime.multipart import MIMEMultipart | |
1 | from email.mime.text import MIMEText | |
2 | import time | |
3 | ||
4 | from exchangelib.folders import Inbox | |
5 | from exchangelib.items import Message | |
6 | from exchangelib.queryset import DoesNotExist | |
7 | ||
8 | from ..common import get_random_string | |
9 | from .test_basics import CommonItemTest | |
10 | ||
11 | ||
12 | class MessagesTest(CommonItemTest): | |
13 | # Just test one of the Message-type folders | |
14 | TEST_FOLDER = 'inbox' | |
15 | FOLDER_CLASS = Inbox | |
16 | ITEM_CLASS = Message | |
17 | INCOMING_MESSAGE_TIMEOUT = 20 | |
18 | ||
19 | def get_incoming_message(self, subject): | |
20 | t1 = time.monotonic() | |
21 | while True: | |
22 | t2 = time.monotonic() | |
23 | if t2 - t1 > self.INCOMING_MESSAGE_TIMEOUT: | |
24 | raise self.skipTest('Too bad. Gave up in %s waiting for the incoming message to show up' % self.id()) | |
25 | try: | |
26 | return self.account.inbox.get(subject=subject) | |
27 | except DoesNotExist: | |
28 | time.sleep(5) | |
29 | ||
30 | def test_send(self): | |
31 | # Test that we can send (only) Message items | |
32 | item = self.get_test_item() | |
33 | item.folder = None | |
34 | item.send() | |
35 | self.assertIsNone(item.id) | |
36 | self.assertIsNone(item.changekey) | |
37 | self.assertEqual(self.test_folder.filter(categories__contains=item.categories).count(), 0) | |
38 | ||
39 | def test_send_and_save(self): | |
40 | # Test that we can send_and_save Message items | |
41 | item = self.get_test_item() | |
42 | item.send_and_save() | |
43 | self.assertIsNone(item.id) | |
44 | self.assertIsNone(item.changekey) | |
45 | time.sleep(5) # Requests are supposed to be transactional, but apparently not... | |
46 | # Also, the sent item may be followed by an automatic message with the same category | |
47 | self.assertGreaterEqual(self.test_folder.filter(categories__contains=item.categories).count(), 1) | |
48 | ||
49 | # Test update, although it makes little sense | |
50 | item = self.get_test_item() | |
51 | item.save() | |
52 | item.send_and_save() | |
53 | time.sleep(5) # Requests are supposed to be transactional, but apparently not... | |
54 | # Also, the sent item may be followed by an automatic message with the same category | |
55 | self.assertGreaterEqual(self.test_folder.filter(categories__contains=item.categories).count(), 1) | |
56 | ||
57 | def test_send_draft(self): | |
58 | item = self.get_test_item() | |
59 | item.folder = self.account.drafts | |
60 | item.is_draft = True | |
61 | item.save() # Save a draft | |
62 | item.send() # Send the draft | |
63 | self.assertIsNone(item.id) | |
64 | self.assertIsNone(item.changekey) | |
65 | self.assertEqual(item.folder, self.account.sent) | |
66 | self.assertEqual(self.test_folder.filter(categories__contains=item.categories).count(), 0) | |
67 | ||
68 | def test_send_and_copy_to_folder(self): | |
69 | item = self.get_test_item() | |
70 | item.send(save_copy=True, copy_to_folder=self.account.sent) # Send the draft and save to the sent folder | |
71 | self.assertIsNone(item.id) | |
72 | self.assertIsNone(item.changekey) | |
73 | self.assertEqual(item.folder, self.account.sent) | |
74 | time.sleep(5) # Requests are supposed to be transactional, but apparently not... | |
75 | self.assertEqual(self.account.sent.filter(categories__contains=item.categories).count(), 1) | |
76 | ||
77 | def test_bulk_send(self): | |
78 | with self.assertRaises(AttributeError): | |
79 | self.account.bulk_send(ids=[], save_copy=False, copy_to_folder=self.account.trash) | |
80 | item = self.get_test_item() | |
81 | item.save() | |
82 | for res in self.account.bulk_send(ids=[item]): | |
83 | self.assertEqual(res, True) | |
84 | time.sleep(10) # Requests are supposed to be transactional, but apparently not... | |
85 | # By default, sent items are placed in the sent folder | |
86 | self.assertEqual(self.account.sent.filter(categories__contains=item.categories).count(), 1) | |
87 | ||
88 | def test_reply(self): | |
89 | # Test that we can reply to a Message item. EWS only allows items that have been sent to receive a reply | |
90 | item = self.get_test_item() | |
91 | item.folder = None | |
92 | item.send() # get_test_item() sets the to_recipients to the test account | |
93 | sent_item = self.get_incoming_message(item.subject) | |
94 | new_subject = ('Re: %s' % sent_item.subject)[:255] | |
95 | sent_item.reply(subject=new_subject, body='Hello reply', to_recipients=[item.author]) | |
96 | reply = self.get_incoming_message(new_subject) | |
97 | self.account.bulk_delete([sent_item, reply]) | |
98 | ||
99 | def test_reply_all(self): | |
100 | # Test that we can reply-all a Message item. EWS only allows items that have been sent to receive a reply | |
101 | item = self.get_test_item(folder=None) | |
102 | item.folder = None | |
103 | item.send() | |
104 | sent_item = self.get_incoming_message(item.subject) | |
105 | new_subject = ('Re: %s' % sent_item.subject)[:255] | |
106 | sent_item.reply_all(subject=new_subject, body='Hello reply') | |
107 | reply = self.get_incoming_message(new_subject) | |
108 | self.account.bulk_delete([sent_item, reply]) | |
109 | ||
110 | def test_forward(self): | |
111 | # Test that we can forward a Message item. EWS only allows items that have been sent to receive a reply | |
112 | item = self.get_test_item(folder=None) | |
113 | item.folder = None | |
114 | item.send() | |
115 | sent_item = self.get_incoming_message(item.subject) | |
116 | new_subject = ('Re: %s' % sent_item.subject)[:255] | |
117 | sent_item.forward(subject=new_subject, body='Hello reply', to_recipients=[item.author]) | |
118 | reply = self.get_incoming_message(new_subject) | |
119 | forward = sent_item.create_forward(subject=new_subject, body='Hello reply', to_recipients=[item.author]) | |
120 | res = forward.save(self.account.drafts) | |
121 | self.account.bulk_delete([sent_item, reply, res]) | |
122 | ||
123 | def test_mime_content(self): | |
124 | # Tests the 'mime_content' field | |
125 | subject = get_random_string(16) | |
126 | msg = MIMEMultipart() | |
127 | msg['From'] = self.account.primary_smtp_address | |
128 | msg['To'] = self.account.primary_smtp_address | |
129 | msg['Subject'] = subject | |
130 | body = 'MIME test mail' | |
131 | msg.attach(MIMEText(body, 'plain', _charset='utf-8')) | |
132 | mime_content = msg.as_bytes() | |
133 | self.ITEM_CLASS( | |
134 | folder=self.test_folder, | |
135 | to_recipients=[self.account.primary_smtp_address], | |
136 | mime_content=mime_content, | |
137 | categories=self.categories, | |
138 | ).save() | |
139 | self.assertEqual(self.test_folder.get(subject=subject).body, body) |
0 | import time | |
1 | ||
2 | from exchangelib.folders import Inbox, FolderCollection | |
3 | from exchangelib.items import Message, SHALLOW, ASSOCIATED | |
4 | from exchangelib.queryset import QuerySet, DoesNotExist, MultipleObjectsReturned | |
5 | ||
6 | from .test_basics import BaseItemTest | |
7 | ||
8 | ||
9 | class ItemQuerySetTest(BaseItemTest): | |
10 | TEST_FOLDER = 'inbox' | |
11 | FOLDER_CLASS = Inbox | |
12 | ITEM_CLASS = Message | |
13 | ||
14 | def test_querysets(self): | |
15 | test_items = [] | |
16 | for i in range(4): | |
17 | item = self.get_test_item() | |
18 | item.subject = 'Item %s' % i | |
19 | item.save() | |
20 | test_items.append(item) | |
21 | qs = QuerySet( | |
22 | folder_collection=FolderCollection(account=self.account, folders=[self.test_folder]) | |
23 | ).filter(categories__contains=self.categories) | |
24 | test_cat = self.categories[0] | |
25 | self.assertEqual( | |
26 | set((i.subject, i.categories[0]) for i in qs), | |
27 | {('Item 0', test_cat), ('Item 1', test_cat), ('Item 2', test_cat), ('Item 3', test_cat)} | |
28 | ) | |
29 | self.assertEqual( | |
30 | [(i.subject, i.categories[0]) for i in qs.none()], | |
31 | [] | |
32 | ) | |
33 | self.assertEqual( | |
34 | [(i.subject, i.categories[0]) for i in qs.filter(subject__startswith='Item 2')], | |
35 | [('Item 2', test_cat)] | |
36 | ) | |
37 | self.assertEqual( | |
38 | set((i.subject, i.categories[0]) for i in qs.exclude(subject__startswith='Item 2')), | |
39 | {('Item 0', test_cat), ('Item 1', test_cat), ('Item 3', test_cat)} | |
40 | ) | |
41 | self.assertEqual( | |
42 | set((i.subject, i.categories) for i in qs.only('subject')), | |
43 | {('Item 0', None), ('Item 1', None), ('Item 2', None), ('Item 3', None)} | |
44 | ) | |
45 | self.assertEqual( | |
46 | [(i.subject, i.categories[0]) for i in qs.order_by('subject')], | |
47 | [('Item 0', test_cat), ('Item 1', test_cat), ('Item 2', test_cat), ('Item 3', test_cat)] | |
48 | ) | |
49 | self.assertEqual( # Test '-some_field' syntax for reverse sorting | |
50 | [(i.subject, i.categories[0]) for i in qs.order_by('-subject')], | |
51 | [('Item 3', test_cat), ('Item 2', test_cat), ('Item 1', test_cat), ('Item 0', test_cat)] | |
52 | ) | |
53 | self.assertEqual( # Test ordering on a field that we don't need to fetch | |
54 | [(i.subject, i.categories[0]) for i in qs.order_by('-subject').only('categories')], | |
55 | [(None, test_cat), (None, test_cat), (None, test_cat), (None, test_cat)] | |
56 | ) | |
57 | self.assertEqual( | |
58 | [(i.subject, i.categories[0]) for i in qs.order_by('subject').reverse()], | |
59 | [('Item 3', test_cat), ('Item 2', test_cat), ('Item 1', test_cat), ('Item 0', test_cat)] | |
60 | ) | |
61 | with self.assertRaises(ValueError): | |
62 | list(qs.values([])) | |
63 | self.assertEqual( | |
64 | [i for i in qs.order_by('subject').values('subject')], | |
65 | [{'subject': 'Item 0'}, {'subject': 'Item 1'}, {'subject': 'Item 2'}, {'subject': 'Item 3'}] | |
66 | ) | |
67 | ||
68 | # Test .values() in combinations of 'id' and 'changekey', which are handled specially | |
69 | self.assertEqual( | |
70 | list(qs.order_by('subject').values('id')), | |
71 | [{'id': i.id} for i in test_items] | |
72 | ) | |
73 | self.assertEqual( | |
74 | list(qs.order_by('subject').values('changekey')), | |
75 | [{'changekey': i.changekey} for i in test_items] | |
76 | ) | |
77 | self.assertEqual( | |
78 | list(qs.order_by('subject').values('id', 'changekey')), | |
79 | [{k: getattr(i, k) for k in ('id', 'changekey')} for i in test_items] | |
80 | ) | |
81 | ||
82 | self.assertEqual( | |
83 | set(i for i in qs.values_list('subject')), | |
84 | {('Item 0',), ('Item 1',), ('Item 2',), ('Item 3',)} | |
85 | ) | |
86 | ||
87 | # Test .values_list() in combinations of 'id' and 'changekey', which are handled specially | |
88 | self.assertEqual( | |
89 | list(qs.order_by('subject').values_list('id')), | |
90 | [(i.id,) for i in test_items] | |
91 | ) | |
92 | self.assertEqual( | |
93 | list(qs.order_by('subject').values_list('changekey')), | |
94 | [(i.changekey,) for i in test_items] | |
95 | ) | |
96 | self.assertEqual( | |
97 | list(qs.order_by('subject').values_list('id', 'changekey')), | |
98 | [(i.id, i.changekey) for i in test_items] | |
99 | ) | |
100 | ||
101 | self.assertEqual( | |
102 | set(i.subject for i in qs.only('subject')), | |
103 | {'Item 0', 'Item 1', 'Item 2', 'Item 3'} | |
104 | ) | |
105 | ||
106 | # Test .only() in combinations of 'id' and 'changekey', which are handled specially | |
107 | self.assertEqual( | |
108 | list((i.id,) for i in qs.order_by('subject').only('id')), | |
109 | [(i.id,) for i in test_items] | |
110 | ) | |
111 | self.assertEqual( | |
112 | list((i.changekey,) for i in qs.order_by('subject').only('changekey')), | |
113 | [(i.changekey,) for i in test_items] | |
114 | ) | |
115 | self.assertEqual( | |
116 | list((i.id, i.changekey) for i in qs.order_by('subject').only('id', 'changekey')), | |
117 | [(i.id, i.changekey) for i in test_items] | |
118 | ) | |
119 | ||
120 | with self.assertRaises(ValueError): | |
121 | list(qs.values_list('id', 'changekey', flat=True)) | |
122 | with self.assertRaises(AttributeError): | |
123 | list(qs.values_list('id', xxx=True)) | |
124 | self.assertEqual( | |
125 | list(qs.order_by('subject').values_list('id', flat=True)), | |
126 | [i.id for i in test_items] | |
127 | ) | |
128 | self.assertEqual( | |
129 | list(qs.order_by('subject').values_list('changekey', flat=True)), | |
130 | [i.changekey for i in test_items] | |
131 | ) | |
132 | self.assertEqual( | |
133 | set(i for i in qs.values_list('subject', flat=True)), | |
134 | {'Item 0', 'Item 1', 'Item 2', 'Item 3'} | |
135 | ) | |
136 | self.assertEqual( | |
137 | qs.values_list('subject', flat=True).get(subject='Item 2'), | |
138 | 'Item 2' | |
139 | ) | |
140 | self.assertEqual( | |
141 | set((i.subject, i.categories[0]) for i in qs.exclude(subject__startswith='Item 2')), | |
142 | {('Item 0', test_cat), ('Item 1', test_cat), ('Item 3', test_cat)} | |
143 | ) | |
144 | # Test that we can sort on a field that we don't want | |
145 | self.assertEqual( | |
146 | [i.categories[0] for i in qs.only('categories').order_by('subject')], | |
147 | [test_cat, test_cat, test_cat, test_cat] | |
148 | ) | |
149 | # Test iterator | |
150 | self.assertEqual( | |
151 | set((i.subject, i.categories[0]) for i in qs.iterator()), | |
152 | {('Item 0', test_cat), ('Item 1', test_cat), ('Item 2', test_cat), ('Item 3', test_cat)} | |
153 | ) | |
154 | # Test that iterator() preserves the result format | |
155 | self.assertEqual( | |
156 | set((i[0], i[1][0]) for i in qs.values_list('subject', 'categories').iterator()), | |
157 | {('Item 0', test_cat), ('Item 1', test_cat), ('Item 2', test_cat), ('Item 3', test_cat)} | |
158 | ) | |
159 | self.assertEqual(qs.get(subject='Item 3').subject, 'Item 3') | |
160 | with self.assertRaises(DoesNotExist): | |
161 | qs.get(subject='Item XXX') | |
162 | with self.assertRaises(MultipleObjectsReturned): | |
163 | qs.get(subject__startswith='Item') | |
164 | # len() and count() | |
165 | self.assertEqual(qs.count(), 4) | |
166 | # Indexing and slicing | |
167 | self.assertTrue(isinstance(qs[0], self.ITEM_CLASS)) | |
168 | self.assertEqual(len(list(qs[1:3])), 2) | |
169 | self.assertEqual(qs.count(), 4) | |
170 | with self.assertRaises(IndexError): | |
171 | print(qs[99999]) | |
172 | # Exists | |
173 | self.assertEqual(qs.exists(), True) | |
174 | self.assertEqual(qs.filter(subject='Test XXX').exists(), False) | |
175 | self.assertEqual( | |
176 | qs.filter(subject__startswith='Item').delete(), | |
177 | [True, True, True, True] | |
178 | ) | |
179 | ||
180 | def test_queryset_failure(self): | |
181 | qs = QuerySet( | |
182 | folder_collection=FolderCollection(account=self.account, folders=[self.test_folder]) | |
183 | ).filter(categories__contains=self.categories) | |
184 | with self.assertRaises(ValueError): | |
185 | qs.order_by('XXX') | |
186 | with self.assertRaises(ValueError): | |
187 | qs.values('XXX') | |
188 | with self.assertRaises(ValueError): | |
189 | qs.values_list('XXX') | |
190 | with self.assertRaises(ValueError): | |
191 | qs.only('XXX') | |
192 | with self.assertRaises(ValueError): | |
193 | qs.reverse() # We can't reverse when we haven't defined an order yet | |
194 | ||
195 | def test_cached_queryset_corner_cases(self): | |
196 | test_items = [] | |
197 | for i in range(4): | |
198 | item = self.get_test_item() | |
199 | item.subject = 'Item %s' % i | |
200 | item.save() | |
201 | test_items.append(item) | |
202 | qs = QuerySet( | |
203 | folder_collection=FolderCollection(account=self.account, folders=[self.test_folder]) | |
204 | ).filter(categories__contains=self.categories).order_by('subject') | |
205 | for _ in qs: | |
206 | # Build up the cache | |
207 | pass | |
208 | self.assertEqual(len(qs._cache), 4) | |
209 | with self.assertRaises(MultipleObjectsReturned): | |
210 | qs.get() # Get with a full cache | |
211 | self.assertEqual(qs[2].subject, 'Item 2') # Index with a full cache | |
212 | self.assertEqual(qs[-2].subject, 'Item 2') # Negative index with a full cache | |
213 | qs.delete() # Delete with a full cache | |
214 | self.assertEqual(qs.count(), 0) # QuerySet is empty after delete | |
215 | self.assertEqual(list(qs.none()), []) | |
216 | ||
217 | def test_queryset_get_by_id(self): | |
218 | item = self.get_test_item().save() | |
219 | with self.assertRaises(ValueError): | |
220 | list(self.test_folder.filter(id__in=[item.id])) | |
221 | with self.assertRaises(ValueError): | |
222 | list(self.test_folder.get(id=item.id, changekey=item.changekey, subject='XXX')) | |
223 | with self.assertRaises(ValueError): | |
224 | list(self.test_folder.get(id=None, changekey=item.changekey)) | |
225 | ||
226 | # Test a simple get() | |
227 | get_item = self.test_folder.get(id=item.id, changekey=item.changekey) | |
228 | self.assertEqual(item.id, get_item.id) | |
229 | self.assertEqual(item.changekey, get_item.changekey) | |
230 | self.assertEqual(item.subject, get_item.subject) | |
231 | self.assertEqual(item.body, get_item.body) | |
232 | ||
233 | # Test get() with ID only | |
234 | get_item = self.test_folder.get(id=item.id) | |
235 | self.assertEqual(item.id, get_item.id) | |
236 | self.assertEqual(item.changekey, get_item.changekey) | |
237 | self.assertEqual(item.subject, get_item.subject) | |
238 | self.assertEqual(item.body, get_item.body) | |
239 | get_item = self.test_folder.get(id=item.id, changekey=None) | |
240 | self.assertEqual(item.id, get_item.id) | |
241 | self.assertEqual(item.changekey, get_item.changekey) | |
242 | self.assertEqual(item.subject, get_item.subject) | |
243 | self.assertEqual(item.body, get_item.body) | |
244 | ||
245 | # Test a get() from queryset | |
246 | get_item = self.test_folder.all().get(id=item.id, changekey=item.changekey) | |
247 | self.assertEqual(item.id, get_item.id) | |
248 | self.assertEqual(item.changekey, get_item.changekey) | |
249 | self.assertEqual(item.subject, get_item.subject) | |
250 | self.assertEqual(item.body, get_item.body) | |
251 | ||
252 | # Test a get() with only() | |
253 | get_item = self.test_folder.all().only('subject').get(id=item.id, changekey=item.changekey) | |
254 | self.assertEqual(item.id, get_item.id) | |
255 | self.assertEqual(item.changekey, get_item.changekey) | |
256 | self.assertEqual(item.subject, get_item.subject) | |
257 | self.assertIsNone(get_item.body) | |
258 | ||
259 | def test_paging(self): | |
260 | # Test that paging services work correctly. Default EWS paging size is 1000 items. Our default is 100 items. | |
261 | items = [] | |
262 | for _ in range(11): | |
263 | i = self.get_test_item() | |
264 | del i.attachments[:] | |
265 | items.append(i) | |
266 | self.test_folder.bulk_create(items=items) | |
267 | ids = self.test_folder.filter(categories__contains=self.categories).values_list('id', 'changekey') | |
268 | ids.page_size = 10 | |
269 | self.bulk_delete(ids.iterator()) | |
270 | ||
271 | def test_slicing(self): | |
272 | # Test that slicing works correctly | |
273 | items = [] | |
274 | for i in range(4): | |
275 | item = self.get_test_item() | |
276 | item.subject = 'Subj %s' % i | |
277 | del item.attachments[:] | |
278 | items.append(item) | |
279 | ids = self.test_folder.bulk_create(items=items) | |
280 | qs = self.test_folder.filter(categories__contains=self.categories).only('subject').order_by('subject') | |
281 | ||
282 | # Test positive index | |
283 | self.assertEqual( | |
284 | qs._copy_self()[0].subject, | |
285 | 'Subj 0' | |
286 | ) | |
287 | # Test positive index | |
288 | self.assertEqual( | |
289 | qs._copy_self()[3].subject, | |
290 | 'Subj 3' | |
291 | ) | |
292 | # Test negative index | |
293 | self.assertEqual( | |
294 | qs._copy_self()[-2].subject, | |
295 | 'Subj 2' | |
296 | ) | |
297 | # Test positive slice | |
298 | self.assertEqual( | |
299 | [i.subject for i in qs._copy_self()[0:2]], | |
300 | ['Subj 0', 'Subj 1'] | |
301 | ) | |
302 | # Test positive slice | |
303 | self.assertEqual( | |
304 | [i.subject for i in qs._copy_self()[2:4]], | |
305 | ['Subj 2', 'Subj 3'] | |
306 | ) | |
307 | # Test positive open slice | |
308 | self.assertEqual( | |
309 | [i.subject for i in qs._copy_self()[:2]], | |
310 | ['Subj 0', 'Subj 1'] | |
311 | ) | |
312 | # Test positive open slice | |
313 | self.assertEqual( | |
314 | [i.subject for i in qs._copy_self()[2:]], | |
315 | ['Subj 2', 'Subj 3'] | |
316 | ) | |
317 | # Test negative slice | |
318 | self.assertEqual( | |
319 | [i.subject for i in qs._copy_self()[-3:-1]], | |
320 | ['Subj 1', 'Subj 2'] | |
321 | ) | |
322 | # Test negative slice | |
323 | self.assertEqual( | |
324 | [i.subject for i in qs._copy_self()[1:-1]], | |
325 | ['Subj 1', 'Subj 2'] | |
326 | ) | |
327 | # Test negative open slice | |
328 | self.assertEqual( | |
329 | [i.subject for i in qs._copy_self()[:-2]], | |
330 | ['Subj 0', 'Subj 1'] | |
331 | ) | |
332 | # Test negative open slice | |
333 | self.assertEqual( | |
334 | [i.subject for i in qs._copy_self()[-2:]], | |
335 | ['Subj 2', 'Subj 3'] | |
336 | ) | |
337 | # Test positive slice with step | |
338 | self.assertEqual( | |
339 | [i.subject for i in qs._copy_self()[0:4:2]], | |
340 | ['Subj 0', 'Subj 2'] | |
341 | ) | |
342 | # Test negative slice with step | |
343 | self.assertEqual( | |
344 | [i.subject for i in qs._copy_self()[4:0:-2]], | |
345 | ['Subj 3', 'Subj 1'] | |
346 | ) | |
347 | ||
348 | def test_delete_via_queryset(self): | |
349 | self.get_test_item().save() | |
350 | qs = self.test_folder.filter(categories__contains=self.categories) | |
351 | self.assertEqual(qs.count(), 1) | |
352 | qs.delete() | |
353 | self.assertEqual(qs.count(), 0) | |
354 | ||
355 | def test_send_via_queryset(self): | |
356 | self.get_test_item().save() | |
357 | qs = self.test_folder.filter(categories__contains=self.categories) | |
358 | to_folder = self.account.sent | |
359 | to_folder_qs = to_folder.filter(categories__contains=self.categories) | |
360 | self.assertEqual(qs.count(), 1) | |
361 | self.assertEqual(to_folder_qs.count(), 0) | |
362 | qs.send(copy_to_folder=to_folder) | |
363 | time.sleep(5) # Requests are supposed to be transactional, but apparently not... | |
364 | self.assertEqual(qs.count(), 0) | |
365 | self.assertEqual(to_folder_qs.count(), 1) | |
366 | ||
367 | def test_send_with_no_copy_via_queryset(self): | |
368 | self.get_test_item().save() | |
369 | qs = self.test_folder.filter(categories__contains=self.categories) | |
370 | to_folder = self.account.sent | |
371 | to_folder_qs = to_folder.filter(categories__contains=self.categories) | |
372 | self.assertEqual(qs.count(), 1) | |
373 | self.assertEqual(to_folder_qs.count(), 0) | |
374 | qs.send(save_copy=False) | |
375 | time.sleep(5) # Requests are supposed to be transactional, but apparently not... | |
376 | self.assertEqual(qs.count(), 0) | |
377 | self.assertEqual(to_folder_qs.count(), 0) | |
378 | ||
379 | def test_copy_via_queryset(self): | |
380 | self.get_test_item().save() | |
381 | qs = self.test_folder.filter(categories__contains=self.categories) | |
382 | to_folder = self.account.trash | |
383 | to_folder_qs = to_folder.filter(categories__contains=self.categories) | |
384 | self.assertEqual(qs.count(), 1) | |
385 | self.assertEqual(to_folder_qs.count(), 0) | |
386 | qs.copy(to_folder=to_folder) | |
387 | self.assertEqual(qs.count(), 1) | |
388 | self.assertEqual(to_folder_qs.count(), 1) | |
389 | ||
390 | def test_move_via_queryset(self): | |
391 | self.get_test_item().save() | |
392 | qs = self.test_folder.filter(categories__contains=self.categories) | |
393 | to_folder = self.account.trash | |
394 | to_folder_qs = to_folder.filter(categories__contains=self.categories) | |
395 | self.assertEqual(qs.count(), 1) | |
396 | self.assertEqual(to_folder_qs.count(), 0) | |
397 | qs.move(to_folder=to_folder) | |
398 | self.assertEqual(qs.count(), 0) | |
399 | self.assertEqual(to_folder_qs.count(), 1) | |
400 | ||
401 | def test_depth(self): | |
402 | self.assertGreaterEqual(self.test_folder.all().depth(ASSOCIATED).count(), 0) | |
403 | self.assertGreaterEqual(self.test_folder.all().depth(SHALLOW).count(), 0) |
0 | from decimal import Decimal | |
1 | ||
2 | from exchangelib.ewsdatetime import EWSDateTime, EWSTimeZone, UTC_NOW | |
3 | from exchangelib.folders import Tasks | |
4 | from exchangelib.items import Task | |
5 | ||
6 | from .test_basics import CommonItemTest | |
7 | ||
8 | ||
9 | class TasksTest(CommonItemTest): | |
10 | TEST_FOLDER = 'tasks' | |
11 | FOLDER_CLASS = Tasks | |
12 | ITEM_CLASS = Task | |
13 | ||
14 | def test_task_validation(self): | |
15 | tz = EWSTimeZone.timezone('Europe/Copenhagen') | |
16 | task = Task(due_date=tz.localize(EWSDateTime(2017, 1, 1)), start_date=tz.localize(EWSDateTime(2017, 2, 1))) | |
17 | task.clean() | |
18 | # We reset due date if it's before start date | |
19 | self.assertEqual(task.due_date, tz.localize(EWSDateTime(2017, 2, 1))) | |
20 | self.assertEqual(task.due_date, task.start_date) | |
21 | ||
22 | task = Task(complete_date=tz.localize(EWSDateTime(2099, 1, 1)), status=Task.NOT_STARTED) | |
23 | task.clean() | |
24 | # We reset status if complete_date is set | |
25 | self.assertEqual(task.status, Task.COMPLETED) | |
26 | # We also reset complete date to now() if it's in the future | |
27 | self.assertEqual(task.complete_date.date(), UTC_NOW().date()) | |
28 | ||
29 | task = Task(complete_date=tz.localize(EWSDateTime(2017, 1, 1)), start_date=tz.localize(EWSDateTime(2017, 2, 1))) | |
30 | task.clean() | |
31 | # We also reset complete date to start_date if it's before start_date | |
32 | self.assertEqual(task.complete_date, task.start_date) | |
33 | ||
34 | task = Task(percent_complete=Decimal('50.0'), status=Task.COMPLETED) | |
35 | task.clean() | |
36 | # We reset percent_complete to 100.0 if state is completed | |
37 | self.assertEqual(task.percent_complete, Decimal(100)) | |
38 | ||
39 | task = Task(percent_complete=Decimal('50.0'), status=Task.NOT_STARTED) | |
40 | task.clean() | |
41 | # We reset percent_complete to 0.0 if state is not_started | |
42 | self.assertEqual(task.percent_complete, Decimal(0)) | |
43 | ||
44 | def test_complete(self): | |
45 | item = self.get_test_item().save() | |
46 | item.refresh() | |
47 | self.assertNotEqual(item.status, Task.COMPLETED) | |
48 | self.assertNotEqual(item.percent_complete, Decimal(100)) | |
49 | item.complete() | |
50 | item.refresh() | |
51 | self.assertEqual(item.status, Task.COMPLETED) | |
52 | self.assertEqual(item.percent_complete, Decimal(100)) |
0 | import datetime | |
1 | from decimal import Decimal | |
2 | from email.mime.multipart import MIMEMultipart | |
3 | from email.mime.text import MIMEText | |
4 | from keyword import kwlist | |
5 | import time | |
6 | import unittest | |
7 | import unittest.util | |
8 | ||
9 | from dateutil.relativedelta import relativedelta | |
10 | from exchangelib.account import SAVE_ONLY, SEND_ONLY, SEND_AND_SAVE_COPY | |
11 | from exchangelib.attachments import ItemAttachment | |
12 | from exchangelib.errors import ErrorItemNotFound, ErrorInvalidOperation, ErrorInvalidChangeKey, \ | |
13 | ErrorUnsupportedPathForQuery, ErrorInvalidValueForProperty, ErrorPropertyUpdate, ErrorInvalidPropertySet, \ | |
14 | ErrorInvalidIdMalformed | |
15 | from exchangelib.ewsdatetime import EWSDateTime, EWSTimeZone, UTC, UTC_NOW | |
16 | from exchangelib.extended_properties import ExtendedProperty, ExternId | |
17 | from exchangelib.fields import TextField, BodyField, ExtendedPropertyField, FieldPath, CultureField, IdField, \ | |
18 | CharField, ChoiceField, AttachmentField, BooleanField | |
19 | from exchangelib.folders import Calendar, Inbox, Tasks, Contacts, Folder, FolderCollection | |
20 | from exchangelib.indexed_properties import EmailAddress, PhysicalAddress, SingleFieldIndexedElement, \ | |
21 | MultiFieldIndexedElement | |
22 | from exchangelib.items import Item, CalendarItem, Message, Contact, Task, DistributionList, Persona, BaseItem, \ | |
23 | SHALLOW, ASSOCIATED | |
24 | from exchangelib.properties import Mailbox, Member, Attendee | |
25 | from exchangelib.queryset import QuerySet, DoesNotExist, MultipleObjectsReturned | |
26 | from exchangelib.restriction import Restriction, Q | |
27 | from exchangelib.services import GetPersona | |
28 | from exchangelib.util import value_to_xml_text | |
29 | from exchangelib.version import Build, EXCHANGE_2007, EXCHANGE_2013 | |
30 | ||
31 | from .common import EWSTest, get_random_string, get_random_datetime_range, get_random_date, \ | |
32 | get_random_email, get_random_decimal, get_random_choice, get_random_int, mock_version | |
33 | ||
34 | ||
35 | class BaseItemTest(EWSTest): | |
36 | TEST_FOLDER = None | |
37 | FOLDER_CLASS = None | |
38 | ITEM_CLASS = None | |
39 | ||
40 | @classmethod | |
41 | def setUpClass(cls): | |
42 | if cls is BaseItemTest: | |
43 | raise unittest.SkipTest("Skip BaseItemTest, it's only for inheritance") | |
44 | super().setUpClass() | |
45 | ||
46 | def setUp(self): | |
47 | super().setUp() | |
48 | self.test_folder = getattr(self.account, self.TEST_FOLDER) | |
49 | self.assertEqual(type(self.test_folder), self.FOLDER_CLASS) | |
50 | self.assertEqual(self.test_folder.DISTINGUISHED_FOLDER_ID, self.TEST_FOLDER) | |
51 | self.test_folder.filter(categories__contains=self.categories).delete() | |
52 | ||
53 | def tearDown(self): | |
54 | self.test_folder.filter(categories__contains=self.categories).delete() | |
55 | # Delete all delivery receipts | |
56 | self.test_folder.filter(subject__startswith='Delivered: Subject: ').delete() | |
57 | super().tearDown() | |
58 | ||
59 | def get_random_insert_kwargs(self): | |
60 | insert_kwargs = {} | |
61 | for f in self.ITEM_CLASS.FIELDS: | |
62 | if not f.supports_version(self.account.version): | |
63 | # Cannot be used with this EWS version | |
64 | continue | |
65 | if self.ITEM_CLASS == CalendarItem and f in CalendarItem.timezone_fields(): | |
66 | # Timezone fields will (and must) be populated automatically from the timestamp | |
67 | continue | |
68 | if f.is_read_only: | |
69 | # These cannot be created | |
70 | continue | |
71 | if f.name == 'mime_content': | |
72 | # This needs special formatting. See separate test_mime_content() test | |
73 | continue | |
74 | if f.name == 'attachments': | |
75 | # Testing attachments is heavy. Leave this to specific tests | |
76 | insert_kwargs[f.name] = [] | |
77 | continue | |
78 | if f.name == 'resources': | |
79 | # The test server doesn't have any resources | |
80 | insert_kwargs[f.name] = [] | |
81 | continue | |
82 | if f.name == 'optional_attendees': | |
83 | # 'optional_attendees' and 'required_attendees' are mutually exclusive | |
84 | insert_kwargs[f.name] = None | |
85 | continue | |
86 | if f.name == 'start': | |
87 | start = get_random_date() | |
88 | insert_kwargs[f.name], insert_kwargs['end'] = \ | |
89 | get_random_datetime_range(start_date=start, end_date=start, tz=self.account.default_timezone) | |
90 | insert_kwargs['recurrence'] = self.random_val(self.ITEM_CLASS.get_field_by_fieldname('recurrence')) | |
91 | insert_kwargs['recurrence'].boundary.start = insert_kwargs[f.name].date() | |
92 | continue | |
93 | if f.name == 'end': | |
94 | continue | |
95 | if f.name == 'is_all_day': | |
96 | # For CalendarItem instances, the 'is_all_day' attribute affects the 'start' and 'end' values. Changing | |
97 | # from 'false' to 'true' removes the time part of these datetimes. | |
98 | insert_kwargs['is_all_day'] = False | |
99 | continue | |
100 | if f.name == 'recurrence': | |
101 | continue | |
102 | if f.name == 'due_date': | |
103 | # start_date must be before due_date | |
104 | insert_kwargs['start_date'], insert_kwargs[f.name] = \ | |
105 | get_random_datetime_range(tz=self.account.default_timezone) | |
106 | continue | |
107 | if f.name == 'start_date': | |
108 | continue | |
109 | if f.name == 'status': | |
110 | # Start with an incomplete task | |
111 | status = get_random_choice(set(f.supported_choices(version=self.account.version)) - {Task.COMPLETED}) | |
112 | insert_kwargs[f.name] = status | |
113 | if status == Task.NOT_STARTED: | |
114 | insert_kwargs['percent_complete'] = Decimal(0) | |
115 | else: | |
116 | insert_kwargs['percent_complete'] = get_random_decimal(1, 99) | |
117 | continue | |
118 | if f.name == 'percent_complete': | |
119 | continue | |
120 | insert_kwargs[f.name] = self.random_val(f) | |
121 | return insert_kwargs | |
122 | ||
123 | def get_random_update_kwargs(self, item, insert_kwargs): | |
124 | update_kwargs = {} | |
125 | now = UTC_NOW() | |
126 | for f in self.ITEM_CLASS.FIELDS: | |
127 | if not f.supports_version(self.account.version): | |
128 | # Cannot be used with this EWS version | |
129 | continue | |
130 | if self.ITEM_CLASS == CalendarItem and f in CalendarItem.timezone_fields(): | |
131 | # Timezone fields will (and must) be populated automatically from the timestamp | |
132 | continue | |
133 | if f.is_read_only: | |
134 | # These cannot be changed | |
135 | continue | |
136 | if not item.is_draft and f.is_read_only_after_send: | |
137 | # These cannot be changed when the item is no longer a draft | |
138 | continue | |
139 | if f.name == 'message_id' and f.is_read_only_after_send: | |
140 | # Cannot be updated, regardless of draft status | |
141 | continue | |
142 | if f.name == 'attachments': | |
143 | # Testing attachments is heavy. Leave this to specific tests | |
144 | update_kwargs[f.name] = [] | |
145 | continue | |
146 | if f.name == 'resources': | |
147 | # The test server doesn't have any resources | |
148 | update_kwargs[f.name] = [] | |
149 | continue | |
150 | if isinstance(f, AttachmentField): | |
151 | # Attachments are handled separately | |
152 | continue | |
153 | if f.name == 'start': | |
154 | start = get_random_date(start_date=insert_kwargs['end'].date()) | |
155 | update_kwargs[f.name], update_kwargs['end'] = \ | |
156 | get_random_datetime_range(start_date=start, end_date=start, tz=self.account.default_timezone) | |
157 | update_kwargs['recurrence'] = self.random_val(self.ITEM_CLASS.get_field_by_fieldname('recurrence')) | |
158 | update_kwargs['recurrence'].boundary.start = update_kwargs[f.name].date() | |
159 | continue | |
160 | if f.name == 'end': | |
161 | continue | |
162 | if f.name == 'recurrence': | |
163 | continue | |
164 | if f.name == 'due_date': | |
165 | # start_date must be before due_date, and before complete_date which must be in the past | |
166 | update_kwargs['start_date'], update_kwargs[f.name] = \ | |
167 | get_random_datetime_range(end_date=now.date(), tz=self.account.default_timezone) | |
168 | continue | |
169 | if f.name == 'start_date': | |
170 | continue | |
171 | if f.name == 'status': | |
172 | # Update task to a completed state. complete_date must be a date in the past, and < than start_date | |
173 | update_kwargs[f.name] = Task.COMPLETED | |
174 | update_kwargs['percent_complete'] = Decimal(100) | |
175 | continue | |
176 | if f.name == 'percent_complete': | |
177 | continue | |
178 | if f.name == 'reminder_is_set': | |
179 | if self.ITEM_CLASS == Task: | |
180 | # Task type doesn't allow updating 'reminder_is_set' to True | |
181 | update_kwargs[f.name] = False | |
182 | else: | |
183 | update_kwargs[f.name] = not insert_kwargs[f.name] | |
184 | continue | |
185 | if isinstance(f, BooleanField): | |
186 | update_kwargs[f.name] = not insert_kwargs[f.name] | |
187 | continue | |
188 | if f.value_cls in (Mailbox, Attendee): | |
189 | if insert_kwargs[f.name] is None: | |
190 | update_kwargs[f.name] = self.random_val(f) | |
191 | else: | |
192 | update_kwargs[f.name] = None | |
193 | continue | |
194 | update_kwargs[f.name] = self.random_val(f) | |
195 | if update_kwargs.get('is_all_day', False): | |
196 | # For is_all_day items, EWS will remove the time part of start and end values | |
197 | update_kwargs['start'] = update_kwargs['start'].replace(hour=0, minute=0, second=0, microsecond=0) | |
198 | update_kwargs['end'] = \ | |
199 | update_kwargs['end'].replace(hour=0, minute=0, second=0, microsecond=0) + datetime.timedelta(days=1) | |
200 | if self.ITEM_CLASS == CalendarItem: | |
201 | # EWS always sets due date to 'start' | |
202 | update_kwargs['reminder_due_by'] = update_kwargs['start'] | |
203 | return update_kwargs | |
204 | ||
205 | def get_test_item(self, folder=None, categories=None): | |
206 | item_kwargs = self.get_random_insert_kwargs() | |
207 | item_kwargs['categories'] = categories or self.categories | |
208 | return self.ITEM_CLASS(folder=folder or self.test_folder, **item_kwargs) | |
209 | ||
210 | ||
211 | class ItemQuerySetTest(BaseItemTest): | |
212 | TEST_FOLDER = 'inbox' | |
213 | FOLDER_CLASS = Inbox | |
214 | ITEM_CLASS = Message | |
215 | ||
216 | def test_querysets(self): | |
217 | test_items = [] | |
218 | for i in range(4): | |
219 | item = self.get_test_item() | |
220 | item.subject = 'Item %s' % i | |
221 | item.save() | |
222 | test_items.append(item) | |
223 | qs = QuerySet( | |
224 | folder_collection=FolderCollection(account=self.account, folders=[self.test_folder]) | |
225 | ).filter(categories__contains=self.categories) | |
226 | test_cat = self.categories[0] | |
227 | self.assertEqual( | |
228 | set((i.subject, i.categories[0]) for i in qs), | |
229 | {('Item 0', test_cat), ('Item 1', test_cat), ('Item 2', test_cat), ('Item 3', test_cat)} | |
230 | ) | |
231 | self.assertEqual( | |
232 | [(i.subject, i.categories[0]) for i in qs.none()], | |
233 | [] | |
234 | ) | |
235 | self.assertEqual( | |
236 | [(i.subject, i.categories[0]) for i in qs.filter(subject__startswith='Item 2')], | |
237 | [('Item 2', test_cat)] | |
238 | ) | |
239 | self.assertEqual( | |
240 | set((i.subject, i.categories[0]) for i in qs.exclude(subject__startswith='Item 2')), | |
241 | {('Item 0', test_cat), ('Item 1', test_cat), ('Item 3', test_cat)} | |
242 | ) | |
243 | self.assertEqual( | |
244 | set((i.subject, i.categories) for i in qs.only('subject')), | |
245 | {('Item 0', None), ('Item 1', None), ('Item 2', None), ('Item 3', None)} | |
246 | ) | |
247 | self.assertEqual( | |
248 | [(i.subject, i.categories[0]) for i in qs.order_by('subject')], | |
249 | [('Item 0', test_cat), ('Item 1', test_cat), ('Item 2', test_cat), ('Item 3', test_cat)] | |
250 | ) | |
251 | self.assertEqual( # Test '-some_field' syntax for reverse sorting | |
252 | [(i.subject, i.categories[0]) for i in qs.order_by('-subject')], | |
253 | [('Item 3', test_cat), ('Item 2', test_cat), ('Item 1', test_cat), ('Item 0', test_cat)] | |
254 | ) | |
255 | self.assertEqual( # Test ordering on a field that we don't need to fetch | |
256 | [(i.subject, i.categories[0]) for i in qs.order_by('-subject').only('categories')], | |
257 | [(None, test_cat), (None, test_cat), (None, test_cat), (None, test_cat)] | |
258 | ) | |
259 | self.assertEqual( | |
260 | [(i.subject, i.categories[0]) for i in qs.order_by('subject').reverse()], | |
261 | [('Item 3', test_cat), ('Item 2', test_cat), ('Item 1', test_cat), ('Item 0', test_cat)] | |
262 | ) | |
263 | with self.assertRaises(ValueError): | |
264 | list(qs.values([])) | |
265 | self.assertEqual( | |
266 | [i for i in qs.order_by('subject').values('subject')], | |
267 | [{'subject': 'Item 0'}, {'subject': 'Item 1'}, {'subject': 'Item 2'}, {'subject': 'Item 3'}] | |
268 | ) | |
269 | ||
270 | # Test .values() in combinations of 'id' and 'changekey', which are handled specially | |
271 | self.assertEqual( | |
272 | list(qs.order_by('subject').values('id')), | |
273 | [{'id': i.id} for i in test_items] | |
274 | ) | |
275 | self.assertEqual( | |
276 | list(qs.order_by('subject').values('changekey')), | |
277 | [{'changekey': i.changekey} for i in test_items] | |
278 | ) | |
279 | self.assertEqual( | |
280 | list(qs.order_by('subject').values('id', 'changekey')), | |
281 | [{k: getattr(i, k) for k in ('id', 'changekey')} for i in test_items] | |
282 | ) | |
283 | ||
284 | self.assertEqual( | |
285 | set(i for i in qs.values_list('subject')), | |
286 | {('Item 0',), ('Item 1',), ('Item 2',), ('Item 3',)} | |
287 | ) | |
288 | ||
289 | # Test .values_list() in combinations of 'id' and 'changekey', which are handled specially | |
290 | self.assertEqual( | |
291 | list(qs.order_by('subject').values_list('id')), | |
292 | [(i.id,) for i in test_items] | |
293 | ) | |
294 | self.assertEqual( | |
295 | list(qs.order_by('subject').values_list('changekey')), | |
296 | [(i.changekey,) for i in test_items] | |
297 | ) | |
298 | self.assertEqual( | |
299 | list(qs.order_by('subject').values_list('id', 'changekey')), | |
300 | [(i.id, i.changekey) for i in test_items] | |
301 | ) | |
302 | ||
303 | self.assertEqual( | |
304 | set(i.subject for i in qs.only('subject')), | |
305 | {'Item 0', 'Item 1', 'Item 2', 'Item 3'} | |
306 | ) | |
307 | ||
308 | # Test .only() in combinations of 'id' and 'changekey', which are handled specially | |
309 | self.assertEqual( | |
310 | list((i.id,) for i in qs.order_by('subject').only('id')), | |
311 | [(i.id,) for i in test_items] | |
312 | ) | |
313 | self.assertEqual( | |
314 | list((i.changekey,) for i in qs.order_by('subject').only('changekey')), | |
315 | [(i.changekey,) for i in test_items] | |
316 | ) | |
317 | self.assertEqual( | |
318 | list((i.id, i.changekey) for i in qs.order_by('subject').only('id', 'changekey')), | |
319 | [(i.id, i.changekey) for i in test_items] | |
320 | ) | |
321 | ||
322 | with self.assertRaises(ValueError): | |
323 | list(qs.values_list('id', 'changekey', flat=True)) | |
324 | with self.assertRaises(AttributeError): | |
325 | list(qs.values_list('id', xxx=True)) | |
326 | self.assertEqual( | |
327 | list(qs.order_by('subject').values_list('id', flat=True)), | |
328 | [i.id for i in test_items] | |
329 | ) | |
330 | self.assertEqual( | |
331 | list(qs.order_by('subject').values_list('changekey', flat=True)), | |
332 | [i.changekey for i in test_items] | |
333 | ) | |
334 | self.assertEqual( | |
335 | set(i for i in qs.values_list('subject', flat=True)), | |
336 | {'Item 0', 'Item 1', 'Item 2', 'Item 3'} | |
337 | ) | |
338 | self.assertEqual( | |
339 | qs.values_list('subject', flat=True).get(subject='Item 2'), | |
340 | 'Item 2' | |
341 | ) | |
342 | self.assertEqual( | |
343 | set((i.subject, i.categories[0]) for i in qs.exclude(subject__startswith='Item 2')), | |
344 | {('Item 0', test_cat), ('Item 1', test_cat), ('Item 3', test_cat)} | |
345 | ) | |
346 | # Test that we can sort on a field that we don't want | |
347 | self.assertEqual( | |
348 | [i.categories[0] for i in qs.only('categories').order_by('subject')], | |
349 | [test_cat, test_cat, test_cat, test_cat] | |
350 | ) | |
351 | # Test iterator | |
352 | self.assertEqual( | |
353 | set((i.subject, i.categories[0]) for i in qs.iterator()), | |
354 | {('Item 0', test_cat), ('Item 1', test_cat), ('Item 2', test_cat), ('Item 3', test_cat)} | |
355 | ) | |
356 | # Test that iterator() preserves the result format | |
357 | self.assertEqual( | |
358 | set((i[0], i[1][0]) for i in qs.values_list('subject', 'categories').iterator()), | |
359 | {('Item 0', test_cat), ('Item 1', test_cat), ('Item 2', test_cat), ('Item 3', test_cat)} | |
360 | ) | |
361 | self.assertEqual(qs.get(subject='Item 3').subject, 'Item 3') | |
362 | with self.assertRaises(DoesNotExist): | |
363 | qs.get(subject='Item XXX') | |
364 | with self.assertRaises(MultipleObjectsReturned): | |
365 | qs.get(subject__startswith='Item') | |
366 | # len() and count() | |
367 | self.assertEqual(len(qs), 4) | |
368 | self.assertEqual(qs.count(), 4) | |
369 | # Indexing and slicing | |
370 | self.assertTrue(isinstance(qs[0], self.ITEM_CLASS)) | |
371 | self.assertEqual(len(list(qs[1:3])), 2) | |
372 | self.assertEqual(len(qs), 4) | |
373 | with self.assertRaises(IndexError): | |
374 | print(qs[99999]) | |
375 | # Exists | |
376 | self.assertEqual(qs.exists(), True) | |
377 | self.assertEqual(qs.filter(subject='Test XXX').exists(), False) | |
378 | self.assertEqual( | |
379 | qs.filter(subject__startswith='Item').delete(), | |
380 | [True, True, True, True] | |
381 | ) | |
382 | ||
383 | def test_queryset_failure(self): | |
384 | qs = QuerySet( | |
385 | folder_collection=FolderCollection(account=self.account, folders=[self.test_folder]) | |
386 | ).filter(categories__contains=self.categories) | |
387 | with self.assertRaises(ValueError): | |
388 | qs.order_by('XXX') | |
389 | with self.assertRaises(ValueError): | |
390 | qs.values('XXX') | |
391 | with self.assertRaises(ValueError): | |
392 | qs.values_list('XXX') | |
393 | with self.assertRaises(ValueError): | |
394 | qs.only('XXX') | |
395 | with self.assertRaises(ValueError): | |
396 | qs.reverse() # We can't reverse when we haven't defined an order yet | |
397 | ||
398 | def test_cached_queryset_corner_cases(self): | |
399 | test_items = [] | |
400 | for i in range(4): | |
401 | item = self.get_test_item() | |
402 | item.subject = 'Item %s' % i | |
403 | item.save() | |
404 | test_items.append(item) | |
405 | qs = QuerySet( | |
406 | folder_collection=FolderCollection(account=self.account, folders=[self.test_folder]) | |
407 | ).filter(categories__contains=self.categories).order_by('subject') | |
408 | for _ in qs: | |
409 | # Build up the cache | |
410 | pass | |
411 | self.assertEqual(len(qs._cache), 4) | |
412 | with self.assertRaises(MultipleObjectsReturned): | |
413 | qs.get() # Get with a full cache | |
414 | self.assertEqual(qs[2].subject, 'Item 2') # Index with a full cache | |
415 | self.assertEqual(qs[-2].subject, 'Item 2') # Negative index with a full cache | |
416 | qs.delete() # Delete with a full cache | |
417 | self.assertEqual(qs.count(), 0) # QuerySet is empty after delete | |
418 | self.assertEqual(list(qs.none()), []) | |
419 | ||
420 | def test_queryset_get_by_id(self): | |
421 | item = self.get_test_item().save() | |
422 | with self.assertRaises(ValueError): | |
423 | list(self.test_folder.filter(id__in=[item.id])) | |
424 | with self.assertRaises(ValueError): | |
425 | list(self.test_folder.get(id=item.id, changekey=item.changekey, subject='XXX')) | |
426 | with self.assertRaises(ValueError): | |
427 | list(self.test_folder.get(id=None, changekey=item.changekey)) | |
428 | ||
429 | # Test a simple get() | |
430 | get_item = self.test_folder.get(id=item.id, changekey=item.changekey) | |
431 | self.assertEqual(item.id, get_item.id) | |
432 | self.assertEqual(item.changekey, get_item.changekey) | |
433 | self.assertEqual(item.subject, get_item.subject) | |
434 | self.assertEqual(item.body, get_item.body) | |
435 | ||
436 | # Test get() with ID only | |
437 | get_item = self.test_folder.get(id=item.id) | |
438 | self.assertEqual(item.id, get_item.id) | |
439 | self.assertEqual(item.changekey, get_item.changekey) | |
440 | self.assertEqual(item.subject, get_item.subject) | |
441 | self.assertEqual(item.body, get_item.body) | |
442 | get_item = self.test_folder.get(id=item.id, changekey=None) | |
443 | self.assertEqual(item.id, get_item.id) | |
444 | self.assertEqual(item.changekey, get_item.changekey) | |
445 | self.assertEqual(item.subject, get_item.subject) | |
446 | self.assertEqual(item.body, get_item.body) | |
447 | ||
448 | # Test a get() from queryset | |
449 | get_item = self.test_folder.all().get(id=item.id, changekey=item.changekey) | |
450 | self.assertEqual(item.id, get_item.id) | |
451 | self.assertEqual(item.changekey, get_item.changekey) | |
452 | self.assertEqual(item.subject, get_item.subject) | |
453 | self.assertEqual(item.body, get_item.body) | |
454 | ||
455 | # Test a get() with only() | |
456 | get_item = self.test_folder.all().only('subject').get(id=item.id, changekey=item.changekey) | |
457 | self.assertEqual(item.id, get_item.id) | |
458 | self.assertEqual(item.changekey, get_item.changekey) | |
459 | self.assertEqual(item.subject, get_item.subject) | |
460 | self.assertIsNone(get_item.body) | |
461 | ||
462 | def test_paging(self): | |
463 | # Test that paging services work correctly. Default EWS paging size is 1000 items. Our default is 100 items. | |
464 | items = [] | |
465 | for _ in range(11): | |
466 | i = self.get_test_item() | |
467 | del i.attachments[:] | |
468 | items.append(i) | |
469 | self.test_folder.bulk_create(items=items) | |
470 | ids = self.test_folder.filter(categories__contains=self.categories).values_list('id', 'changekey') | |
471 | ids.page_size = 10 | |
472 | self.bulk_delete(ids.iterator()) | |
473 | ||
474 | def test_slicing(self): | |
475 | # Test that slicing works correctly | |
476 | items = [] | |
477 | for i in range(4): | |
478 | item = self.get_test_item() | |
479 | item.subject = 'Subj %s' % i | |
480 | del item.attachments[:] | |
481 | items.append(item) | |
482 | ids = self.test_folder.bulk_create(items=items) | |
483 | qs = self.test_folder.filter(categories__contains=self.categories).only('subject').order_by('subject') | |
484 | ||
485 | # Test positive index | |
486 | self.assertEqual( | |
487 | qs._copy_self()[0].subject, | |
488 | 'Subj 0' | |
489 | ) | |
490 | # Test positive index | |
491 | self.assertEqual( | |
492 | qs._copy_self()[3].subject, | |
493 | 'Subj 3' | |
494 | ) | |
495 | # Test negative index | |
496 | self.assertEqual( | |
497 | qs._copy_self()[-2].subject, | |
498 | 'Subj 2' | |
499 | ) | |
500 | # Test positive slice | |
501 | self.assertEqual( | |
502 | [i.subject for i in qs._copy_self()[0:2]], | |
503 | ['Subj 0', 'Subj 1'] | |
504 | ) | |
505 | # Test positive slice | |
506 | self.assertEqual( | |
507 | [i.subject for i in qs._copy_self()[2:4]], | |
508 | ['Subj 2', 'Subj 3'] | |
509 | ) | |
510 | # Test positive open slice | |
511 | self.assertEqual( | |
512 | [i.subject for i in qs._copy_self()[:2]], | |
513 | ['Subj 0', 'Subj 1'] | |
514 | ) | |
515 | # Test positive open slice | |
516 | self.assertEqual( | |
517 | [i.subject for i in qs._copy_self()[2:]], | |
518 | ['Subj 2', 'Subj 3'] | |
519 | ) | |
520 | # Test negative slice | |
521 | self.assertEqual( | |
522 | [i.subject for i in qs._copy_self()[-3:-1]], | |
523 | ['Subj 1', 'Subj 2'] | |
524 | ) | |
525 | # Test negative slice | |
526 | self.assertEqual( | |
527 | [i.subject for i in qs._copy_self()[1:-1]], | |
528 | ['Subj 1', 'Subj 2'] | |
529 | ) | |
530 | # Test negative open slice | |
531 | self.assertEqual( | |
532 | [i.subject for i in qs._copy_self()[:-2]], | |
533 | ['Subj 0', 'Subj 1'] | |
534 | ) | |
535 | # Test negative open slice | |
536 | self.assertEqual( | |
537 | [i.subject for i in qs._copy_self()[-2:]], | |
538 | ['Subj 2', 'Subj 3'] | |
539 | ) | |
540 | # Test positive slice with step | |
541 | self.assertEqual( | |
542 | [i.subject for i in qs._copy_self()[0:4:2]], | |
543 | ['Subj 0', 'Subj 2'] | |
544 | ) | |
545 | # Test negative slice with step | |
546 | self.assertEqual( | |
547 | [i.subject for i in qs._copy_self()[4:0:-2]], | |
548 | ['Subj 3', 'Subj 1'] | |
549 | ) | |
550 | ||
551 | def test_delete_via_queryset(self): | |
552 | self.get_test_item().save() | |
553 | qs = self.test_folder.filter(categories__contains=self.categories) | |
554 | self.assertEqual(qs.count(), 1) | |
555 | qs.delete() | |
556 | self.assertEqual(qs.count(), 0) | |
557 | ||
558 | def test_send_via_queryset(self): | |
559 | self.get_test_item().save() | |
560 | qs = self.test_folder.filter(categories__contains=self.categories) | |
561 | to_folder = self.account.sent | |
562 | to_folder_qs = to_folder.filter(categories__contains=self.categories) | |
563 | self.assertEqual(qs.count(), 1) | |
564 | self.assertEqual(to_folder_qs.count(), 0) | |
565 | qs.send(copy_to_folder=to_folder) | |
566 | time.sleep(5) # Requests are supposed to be transactional, but apparently not... | |
567 | self.assertEqual(qs.count(), 0) | |
568 | self.assertEqual(to_folder_qs.count(), 1) | |
569 | ||
570 | def test_send_with_no_copy_via_queryset(self): | |
571 | self.get_test_item().save() | |
572 | qs = self.test_folder.filter(categories__contains=self.categories) | |
573 | to_folder = self.account.sent | |
574 | to_folder_qs = to_folder.filter(categories__contains=self.categories) | |
575 | self.assertEqual(qs.count(), 1) | |
576 | self.assertEqual(to_folder_qs.count(), 0) | |
577 | qs.send(save_copy=False) | |
578 | time.sleep(5) # Requests are supposed to be transactional, but apparently not... | |
579 | self.assertEqual(qs.count(), 0) | |
580 | self.assertEqual(to_folder_qs.count(), 0) | |
581 | ||
582 | def test_copy_via_queryset(self): | |
583 | self.get_test_item().save() | |
584 | qs = self.test_folder.filter(categories__contains=self.categories) | |
585 | to_folder = self.account.trash | |
586 | to_folder_qs = to_folder.filter(categories__contains=self.categories) | |
587 | self.assertEqual(qs.count(), 1) | |
588 | self.assertEqual(to_folder_qs.count(), 0) | |
589 | qs.copy(to_folder=to_folder) | |
590 | self.assertEqual(qs.count(), 1) | |
591 | self.assertEqual(to_folder_qs.count(), 1) | |
592 | ||
593 | def test_move_via_queryset(self): | |
594 | self.get_test_item().save() | |
595 | qs = self.test_folder.filter(categories__contains=self.categories) | |
596 | to_folder = self.account.trash | |
597 | to_folder_qs = to_folder.filter(categories__contains=self.categories) | |
598 | self.assertEqual(qs.count(), 1) | |
599 | self.assertEqual(to_folder_qs.count(), 0) | |
600 | qs.move(to_folder=to_folder) | |
601 | self.assertEqual(qs.count(), 0) | |
602 | self.assertEqual(to_folder_qs.count(), 1) | |
603 | ||
604 | def test_depth(self): | |
605 | self.assertGreaterEqual(self.test_folder.all().depth(ASSOCIATED).count(), 0) | |
606 | self.assertGreaterEqual(self.test_folder.all().depth(SHALLOW).count(), 0) | |
607 | ||
608 | ||
609 | class ItemHelperTest(BaseItemTest): | |
610 | TEST_FOLDER = 'inbox' | |
611 | FOLDER_CLASS = Inbox | |
612 | ITEM_CLASS = Message | |
613 | ||
614 | def test_save_with_update_fields(self): | |
615 | item = self.get_test_item() | |
616 | with self.assertRaises(ValueError): | |
617 | item.save(update_fields=['subject']) # update_fields does not work on item creation | |
618 | item.save() | |
619 | item.subject = 'XXX' | |
620 | item.body = 'YYY' | |
621 | item.save(update_fields=['subject']) | |
622 | item.refresh() | |
623 | self.assertEqual(item.subject, 'XXX') | |
624 | self.assertNotEqual(item.body, 'YYY') | |
625 | ||
626 | # Test invalid 'update_fields' input | |
627 | with self.assertRaises(ValueError) as e: | |
628 | item.save(update_fields=['xxx']) | |
629 | self.assertEqual( | |
630 | e.exception.args[0], | |
631 | "Field name(s) 'xxx' are not valid for a '%s' item" % self.ITEM_CLASS.__name__ | |
632 | ) | |
633 | with self.assertRaises(ValueError) as e: | |
634 | item.save(update_fields='subject') | |
635 | self.assertEqual( | |
636 | e.exception.args[0], | |
637 | "Field name(s) 's', 'u', 'b', 'j', 'e', 'c', 't' are not valid for a '%s' item" % self.ITEM_CLASS.__name__ | |
638 | ) | |
639 | ||
640 | def test_soft_delete(self): | |
641 | # First, empty trash bin | |
642 | self.account.trash.filter(categories__contains=self.categories).delete() | |
643 | self.account.recoverable_items_deletions.filter(categories__contains=self.categories).delete() | |
644 | item = self.get_test_item().save() | |
645 | item_id = (item.id, item.changekey) | |
646 | # Soft delete | |
647 | item.soft_delete() | |
648 | for e in self.account.fetch(ids=[item_id]): | |
649 | # It's gone from the test folder | |
650 | self.assertIsInstance(e, ErrorItemNotFound) | |
651 | # Really gone, not just changed ItemId | |
652 | self.assertEqual(len(self.test_folder.filter(categories__contains=item.categories)), 0) | |
653 | self.assertEqual(len(self.account.trash.filter(categories__contains=item.categories)), 0) | |
654 | # But we can find it in the recoverable items folder | |
655 | self.assertEqual(len(self.account.recoverable_items_deletions.filter(categories__contains=item.categories)), 1) | |
656 | ||
657 | def test_move_to_trash(self): | |
658 | # First, empty trash bin | |
659 | self.account.trash.filter(categories__contains=self.categories).delete() | |
660 | item = self.get_test_item().save() | |
661 | item_id = (item.id, item.changekey) | |
662 | # Move to trash | |
663 | item.move_to_trash() | |
664 | for e in self.account.fetch(ids=[item_id]): | |
665 | # Not in the test folder anymore | |
666 | self.assertIsInstance(e, ErrorItemNotFound) | |
667 | # Really gone, not just changed ItemId | |
668 | self.assertEqual(len(self.test_folder.filter(categories__contains=item.categories)), 0) | |
669 | # Test that the item moved to trash | |
670 | item = self.account.trash.get(categories__contains=item.categories) | |
671 | moved_item = list(self.account.fetch(ids=[item]))[0] | |
672 | # The item was copied, so the ItemId has changed. Let's compare the subject instead | |
673 | self.assertEqual(item.subject, moved_item.subject) | |
674 | ||
675 | def test_copy(self): | |
676 | # First, empty trash bin | |
677 | self.account.trash.filter(categories__contains=self.categories).delete() | |
678 | item = self.get_test_item().save() | |
679 | # Copy to trash. We use trash because it can contain all item types. | |
680 | copy_item_id, copy_changekey = item.copy(to_folder=self.account.trash) | |
681 | # Test that the item still exists in the folder | |
682 | self.assertEqual(len(self.test_folder.filter(categories__contains=item.categories)), 1) | |
683 | # Test that the copied item exists in trash | |
684 | copied_item = self.account.trash.get(categories__contains=item.categories) | |
685 | self.assertNotEqual(item.id, copied_item.id) | |
686 | self.assertNotEqual(item.changekey, copied_item.changekey) | |
687 | self.assertEqual(copy_item_id, copied_item.id) | |
688 | self.assertEqual(copy_changekey, copied_item.changekey) | |
689 | ||
690 | def test_move(self): | |
691 | # First, empty trash bin | |
692 | self.account.trash.filter(categories__contains=self.categories).delete() | |
693 | item = self.get_test_item().save() | |
694 | item_id = (item.id, item.changekey) | |
695 | # Move to trash. We use trash because it can contain all item types. This changes the ItemId | |
696 | item.move(to_folder=self.account.trash) | |
697 | for e in self.account.fetch(ids=[item_id]): | |
698 | # original item ID no longer exists | |
699 | self.assertIsInstance(e, ErrorItemNotFound) | |
700 | # Test that the item moved to trash | |
701 | self.assertEqual(len(self.test_folder.filter(categories__contains=item.categories)), 0) | |
702 | moved_item = self.account.trash.get(categories__contains=item.categories) | |
703 | self.assertEqual(item.id, moved_item.id) | |
704 | self.assertEqual(item.changekey, moved_item.changekey) | |
705 | ||
706 | def test_refresh(self): | |
707 | # Test that we can refresh items, and that refresh fails if the item no longer exists on the server | |
708 | item = self.get_test_item().save() | |
709 | orig_subject = item.subject | |
710 | item.subject = 'XXX' | |
711 | item.refresh() | |
712 | self.assertEqual(item.subject, orig_subject) | |
713 | item.delete() | |
714 | with self.assertRaises(ValueError): | |
715 | # Item no longer has an ID | |
716 | item.refresh() | |
717 | ||
718 | ||
719 | class BulkMethodTest(BaseItemTest): | |
720 | TEST_FOLDER = 'inbox' | |
721 | FOLDER_CLASS = Inbox | |
722 | ITEM_CLASS = Message | |
723 | ||
724 | def test_fetch(self): | |
725 | item = self.get_test_item() | |
726 | self.test_folder.bulk_create(items=[item, item]) | |
727 | ids = self.test_folder.filter(categories__contains=item.categories) | |
728 | items = list(self.account.fetch(ids=ids)) | |
729 | for item in items: | |
730 | self.assertIsInstance(item, self.ITEM_CLASS) | |
731 | self.assertEqual(len(items), 2) | |
732 | ||
733 | items = list(self.account.fetch(ids=ids, only_fields=['subject'])) | |
734 | self.assertEqual(len(items), 2) | |
735 | ||
736 | items = list(self.account.fetch(ids=ids, only_fields=[FieldPath.from_string('subject', self.test_folder)])) | |
737 | self.assertEqual(len(items), 2) | |
738 | ||
739 | def test_empty_args(self): | |
740 | # We allow empty sequences for these methods | |
741 | self.assertEqual(self.test_folder.bulk_create(items=[]), []) | |
742 | self.assertEqual(list(self.account.fetch(ids=[])), []) | |
743 | self.assertEqual(self.account.bulk_create(folder=self.test_folder, items=[]), []) | |
744 | self.assertEqual(self.account.bulk_update(items=[]), []) | |
745 | self.assertEqual(self.account.bulk_delete(ids=[]), []) | |
746 | self.assertEqual(self.account.bulk_send(ids=[]), []) | |
747 | self.assertEqual(self.account.bulk_copy(ids=[], to_folder=self.account.trash), []) | |
748 | self.assertEqual(self.account.bulk_move(ids=[], to_folder=self.account.trash), []) | |
749 | self.assertEqual(self.account.upload(data=[]), []) | |
750 | self.assertEqual(self.account.export(items=[]), []) | |
751 | ||
752 | def test_qs_args(self): | |
753 | # We allow querysets for these methods | |
754 | qs = self.test_folder.none() | |
755 | self.assertEqual(list(self.account.fetch(ids=qs)), []) | |
756 | with self.assertRaises(ValueError): | |
757 | # bulk_update() does not allow queryset input | |
758 | self.assertEqual(self.account.bulk_update(items=qs), []) | |
759 | self.assertEqual(self.account.bulk_delete(ids=qs), []) | |
760 | self.assertEqual(self.account.bulk_send(ids=qs), []) | |
761 | self.assertEqual(self.account.bulk_copy(ids=qs, to_folder=self.account.trash), []) | |
762 | self.assertEqual(self.account.bulk_move(ids=qs, to_folder=self.account.trash), []) | |
763 | with self.assertRaises(ValueError): | |
764 | # upload() does not allow queryset input | |
765 | self.assertEqual(self.account.upload(data=qs), []) | |
766 | self.assertEqual(self.account.export(items=qs), []) | |
767 | ||
768 | def test_no_kwargs(self): | |
769 | self.assertEqual(self.test_folder.bulk_create([]), []) | |
770 | self.assertEqual(list(self.account.fetch([])), []) | |
771 | self.assertEqual(self.account.bulk_create(self.test_folder, []), []) | |
772 | self.assertEqual(self.account.bulk_update([]), []) | |
773 | self.assertEqual(self.account.bulk_delete([]), []) | |
774 | self.assertEqual(self.account.bulk_send([]), []) | |
775 | self.assertEqual(self.account.bulk_copy([], to_folder=self.account.trash), []) | |
776 | self.assertEqual(self.account.bulk_move([], to_folder=self.account.trash), []) | |
777 | self.assertEqual(self.account.upload([]), []) | |
778 | self.assertEqual(self.account.export([]), []) | |
779 | ||
780 | def test_invalid_bulk_args(self): | |
781 | # Test bulk_create | |
782 | with self.assertRaises(ValueError): | |
783 | # Folder must belong to account | |
784 | self.account.bulk_create(folder=Folder(root=None), items=[]) | |
785 | with self.assertRaises(AttributeError): | |
786 | # Must have folder on save | |
787 | self.account.bulk_create(folder=None, items=[], message_disposition=SAVE_ONLY) | |
788 | # Test that we can send_and_save with a default folder | |
789 | self.account.bulk_create(folder=None, items=[], message_disposition=SEND_AND_SAVE_COPY) | |
790 | with self.assertRaises(AttributeError): | |
791 | # Must not have folder on send-only | |
792 | self.account.bulk_create(folder=self.test_folder, items=[], message_disposition=SEND_ONLY) | |
793 | ||
794 | # Test bulk_update | |
795 | with self.assertRaises(ValueError): | |
796 | # Cannot update in send-only mode | |
797 | self.account.bulk_update(items=[], message_disposition=SEND_ONLY) | |
798 | ||
799 | def test_bulk_failure(self): | |
800 | # Test that bulk_* can handle EWS errors and return the errors in order without losing non-failure results | |
801 | items1 = [self.get_test_item().save() for _ in range(3)] | |
802 | items1[1].changekey = 'XXX' | |
803 | for i, res in enumerate(self.account.bulk_delete(items1)): | |
804 | if i == 1: | |
805 | self.assertIsInstance(res, ErrorInvalidChangeKey) | |
806 | else: | |
807 | self.assertEqual(res, True) | |
808 | items2 = [self.get_test_item().save() for _ in range(3)] | |
809 | items2[1].id = 'AAAA==' | |
810 | for i, res in enumerate(self.account.bulk_delete(items2)): | |
811 | if i == 1: | |
812 | self.assertIsInstance(res, ErrorInvalidIdMalformed) | |
813 | else: | |
814 | self.assertEqual(res, True) | |
815 | items3 = [self.get_test_item().save() for _ in range(3)] | |
816 | items3[1].id = items1[0].id | |
817 | for i, res in enumerate(self.account.fetch(items3)): | |
818 | if i == 1: | |
819 | self.assertIsInstance(res, ErrorItemNotFound) | |
820 | else: | |
821 | self.assertIsInstance(res, Item) | |
822 | ||
823 | ||
824 | class CommonItemTest(BaseItemTest): | |
825 | @classmethod | |
826 | def setUpClass(cls): | |
827 | if cls is CommonItemTest: | |
828 | raise unittest.SkipTest("Skip CommonItemTest, it's only for inheritance") | |
829 | super().setUpClass() | |
830 | ||
831 | def test_field_names(self): | |
832 | # Test that fieldnames don't clash with Python keywords | |
833 | for f in self.ITEM_CLASS.FIELDS: | |
834 | self.assertNotIn(f.name, kwlist) | |
835 | ||
836 | def test_magic(self): | |
837 | item = self.get_test_item() | |
838 | self.assertIn('subject=', str(item)) | |
839 | self.assertIn(item.__class__.__name__, repr(item)) | |
840 | ||
841 | def test_queryset_nonsearchable_fields(self): | |
842 | for f in self.ITEM_CLASS.FIELDS: | |
843 | with self.subTest(f=f): | |
844 | if f.is_searchable or isinstance(f, IdField) or not f.supports_version(self.account.version): | |
845 | continue | |
846 | if f.name in ('percent_complete', 'allow_new_time_proposal'): | |
847 | # These fields don't raise an error when used in a filter, but also don't match anything in a filter | |
848 | continue | |
849 | try: | |
850 | filter_val = f.clean(self.random_val(f)) | |
851 | filter_kwargs = {'%s__in' % f.name: filter_val} if f.is_list else {f.name: filter_val} | |
852 | ||
853 | # We raise ValueError when searching on an is_searchable=False field | |
854 | with self.assertRaises(ValueError): | |
855 | list(self.test_folder.filter(**filter_kwargs)) | |
856 | ||
857 | # Make sure the is_searchable=False setting is correct by searching anyway and testing that this | |
858 | # fails server-side. This only works for values that we are actually able to convert to a search | |
859 | # string. | |
860 | try: | |
861 | value_to_xml_text(filter_val) | |
862 | except NotImplementedError: | |
863 | continue | |
864 | ||
865 | f.is_searchable = True | |
866 | if f.name in ('reminder_due_by',): | |
867 | # Filtering is accepted but doesn't work | |
868 | self.assertEqual( | |
869 | len(self.test_folder.filter(**filter_kwargs)), | |
870 | 0 | |
871 | ) | |
872 | else: | |
873 | with self.assertRaises((ErrorUnsupportedPathForQuery, ErrorInvalidValueForProperty)): | |
874 | list(self.test_folder.filter(**filter_kwargs)) | |
875 | finally: | |
876 | f.is_searchable = False | |
877 | ||
878 | def test_filter_on_all_fields(self): | |
879 | # Test that we can filter on all field names | |
880 | # TODO: Test filtering on subfields of IndexedField | |
881 | item = self.get_test_item().save() | |
882 | common_qs = self.test_folder.filter(categories__contains=self.categories) | |
883 | for f in self.ITEM_CLASS.FIELDS: | |
884 | if not f.supports_version(self.account.version): | |
885 | # Cannot be used with this EWS version | |
886 | continue | |
887 | if not f.is_searchable: | |
888 | # Cannot be used in a QuerySet | |
889 | continue | |
890 | val = getattr(item, f.name) | |
891 | if val is None: | |
892 | # We cannot filter on None values | |
893 | continue | |
894 | if self.ITEM_CLASS == Contact and f.name in ('body', 'display_name'): | |
895 | # filtering 'body' or 'display_name' on Contact items doesn't work at all. Error in EWS? | |
896 | continue | |
897 | if f.is_list: | |
898 | # Filter multi-value fields with =, __in and __contains | |
899 | if issubclass(f.value_cls, MultiFieldIndexedElement): | |
900 | # For these, we need to filter on the subfield | |
901 | filter_kwargs = [] | |
902 | for v in val: | |
903 | for subfield in f.value_cls.supported_fields(version=self.account.version): | |
904 | field_path = FieldPath(field=f, label=v.label, subfield=subfield) | |
905 | path, subval = field_path.path, field_path.get_value(item) | |
906 | if subval is None: | |
907 | continue | |
908 | filter_kwargs.extend([ | |
909 | {path: subval}, {'%s__in' % path: [subval]}, {'%s__contains' % path: [subval]} | |
910 | ]) | |
911 | elif issubclass(f.value_cls, SingleFieldIndexedElement): | |
912 | # For these, we may filter by item or subfield value | |
913 | filter_kwargs = [] | |
914 | for v in val: | |
915 | for subfield in f.value_cls.supported_fields(version=self.account.version): | |
916 | field_path = FieldPath(field=f, label=v.label, subfield=subfield) | |
917 | path, subval = field_path.path, field_path.get_value(item) | |
918 | if subval is None: | |
919 | continue | |
920 | filter_kwargs.extend([ | |
921 | {f.name: v}, {path: subval}, | |
922 | {'%s__in' % path: [subval]}, {'%s__contains' % path: [subval]} | |
923 | ]) | |
924 | else: | |
925 | filter_kwargs = [{'%s__in' % f.name: val}, {'%s__contains' % f.name: val}] | |
926 | else: | |
927 | # Filter all others with =, __in and __contains. We could have more filters here, but these should | |
928 | # always match. | |
929 | filter_kwargs = [{f.name: val}, {'%s__in' % f.name: [val]}] | |
930 | if isinstance(f, TextField) and not isinstance(f, ChoiceField): | |
931 | # Choice fields cannot be filtered using __contains. Sort of makes sense. | |
932 | random_start = get_random_int(min_val=0, max_val=len(val)//2) | |
933 | random_end = get_random_int(min_val=len(val)//2+1, max_val=len(val)) | |
934 | filter_kwargs.append({'%s__contains' % f.name: val[random_start:random_end]}) | |
935 | for kw in filter_kwargs: | |
936 | with self.subTest(f=f, kw=kw): | |
937 | matches = len(common_qs.filter(**kw)) | |
938 | if isinstance(f, TextField) and f.is_complex: | |
939 | # Complex text fields sometimes fail a search using generated data. In production, | |
940 | # they almost always work anyway. Give it one more try after 10 seconds; it seems EWS does | |
941 | # some sort of indexing that needs to catch up. | |
942 | if not matches: | |
943 | time.sleep(10) | |
944 | matches = len(common_qs.filter(**kw)) | |
945 | if not matches and isinstance(f, BodyField): | |
946 | # The body field is particularly nasty in this area. Give up | |
947 | continue | |
948 | self.assertEqual(matches, 1, (f.name, val, kw)) | |
949 | ||
950 | def test_text_field_settings(self): | |
951 | # Test that the max_length and is_complex field settings are correctly set for text fields | |
952 | item = self.get_test_item().save() | |
953 | for f in self.ITEM_CLASS.FIELDS: | |
954 | with self.subTest(f=f): | |
955 | if not f.supports_version(self.account.version): | |
956 | # Cannot be used with this EWS version | |
957 | continue | |
958 | if not isinstance(f, TextField): | |
959 | continue | |
960 | if isinstance(f, ChoiceField): | |
961 | # This one can't contain random values | |
962 | continue | |
963 | if isinstance(f, CultureField): | |
964 | # This one can't contain random values | |
965 | continue | |
966 | if f.is_read_only: | |
967 | continue | |
968 | if f.name == 'categories': | |
969 | # We're filtering on this one, so leave it alone | |
970 | continue | |
971 | old_max_length = getattr(f, 'max_length', None) | |
972 | old_is_complex = f.is_complex | |
973 | try: | |
974 | # Set a string long enough to not be handled by FindItems | |
975 | f.max_length = 4000 | |
976 | if f.is_list: | |
977 | setattr(item, f.name, [get_random_string(f.max_length) for _ in range(len(getattr(item, f.name)))]) | |
978 | else: | |
979 | setattr(item, f.name, get_random_string(f.max_length)) | |
980 | try: | |
981 | item.save(update_fields=[f.name]) | |
982 | except ErrorPropertyUpdate: | |
983 | # Some fields throw this error when updated to a huge value | |
984 | self.assertIn(f.name, ['given_name', 'middle_name', 'surname']) | |
985 | continue | |
986 | except ErrorInvalidPropertySet: | |
987 | # Some fields can not be updated after save | |
988 | self.assertTrue(f.is_read_only_after_send) | |
989 | continue | |
990 | # is_complex=True forces the query to use GetItems which will always get the full value | |
991 | f.is_complex = True | |
992 | new_full_item = self.test_folder.all().only(f.name).get(categories__contains=self.categories) | |
993 | new_full = getattr(new_full_item, f.name) | |
994 | if old_max_length: | |
995 | if f.is_list: | |
996 | for s in new_full: | |
997 | self.assertLessEqual(len(s), old_max_length, (f.name, len(s), old_max_length)) | |
998 | else: | |
999 | self.assertLessEqual(len(new_full), old_max_length, (f.name, len(new_full), old_max_length)) | |
1000 | ||
1001 | # is_complex=False forces the query to use FindItems which will only get the short value | |
1002 | f.is_complex = False | |
1003 | new_short_item = self.test_folder.all().only(f.name).get(categories__contains=self.categories) | |
1004 | new_short = getattr(new_short_item, f.name) | |
1005 | ||
1006 | if not old_is_complex: | |
1007 | self.assertEqual(new_short, new_full, (f.name, new_short, new_full)) | |
1008 | finally: | |
1009 | if old_max_length: | |
1010 | f.max_length = old_max_length | |
1011 | else: | |
1012 | delattr(f, 'max_length') | |
1013 | f.is_complex = old_is_complex | |
1014 | ||
1015 | def test_save_and_delete(self): | |
1016 | # Test that we can create, update and delete single items using methods directly on the item. | |
1017 | insert_kwargs = self.get_random_insert_kwargs() | |
1018 | insert_kwargs['categories'] = self.categories | |
1019 | item = self.ITEM_CLASS(folder=self.test_folder, **insert_kwargs) | |
1020 | self.assertIsNone(item.id) | |
1021 | self.assertIsNone(item.changekey) | |
1022 | ||
1023 | # Create | |
1024 | item.save() | |
1025 | self.assertIsNotNone(item.id) | |
1026 | self.assertIsNotNone(item.changekey) | |
1027 | for k, v in insert_kwargs.items(): | |
1028 | self.assertEqual(getattr(item, k), v, (k, getattr(item, k), v)) | |
1029 | # Test that whatever we have locally also matches whatever is in the DB | |
1030 | fresh_item = list(self.account.fetch(ids=[item]))[0] | |
1031 | for f in item.FIELDS: | |
1032 | with self.subTest(f=f): | |
1033 | old, new = getattr(item, f.name), getattr(fresh_item, f.name) | |
1034 | if f.is_read_only and old is None: | |
1035 | # Some fields are automatically set server-side | |
1036 | continue | |
1037 | if f.name == 'reminder_due_by': | |
1038 | # EWS sets a default value if it is not set on insert. Ignore | |
1039 | continue | |
1040 | if f.name == 'mime_content': | |
1041 | # This will change depending on other contents fields | |
1042 | continue | |
1043 | if f.is_list: | |
1044 | old, new = set(old or ()), set(new or ()) | |
1045 | self.assertEqual(old, new, (f.name, old, new)) | |
1046 | ||
1047 | # Update | |
1048 | update_kwargs = self.get_random_update_kwargs(item=item, insert_kwargs=insert_kwargs) | |
1049 | for k, v in update_kwargs.items(): | |
1050 | setattr(item, k, v) | |
1051 | item.save() | |
1052 | for k, v in update_kwargs.items(): | |
1053 | self.assertEqual(getattr(item, k), v, (k, getattr(item, k), v)) | |
1054 | # Test that whatever we have locally also matches whatever is in the DB | |
1055 | fresh_item = list(self.account.fetch(ids=[item]))[0] | |
1056 | for f in item.FIELDS: | |
1057 | with self.subTest(f=f): | |
1058 | old, new = getattr(item, f.name), getattr(fresh_item, f.name) | |
1059 | if f.is_read_only and old is None: | |
1060 | # Some fields are automatically updated server-side | |
1061 | continue | |
1062 | if f.name == 'mime_content': | |
1063 | # This will change depending on other contents fields | |
1064 | continue | |
1065 | if f.name == 'reminder_due_by': | |
1066 | if new is None: | |
1067 | # EWS does not always return a value if reminder_is_set is False. | |
1068 | continue | |
1069 | if old is not None: | |
1070 | # EWS sometimes randomly sets the new reminder due date to one month before or after we | |
1071 | # wanted it, and sometimes 30 days before or after. But only sometimes... | |
1072 | old_date = old.astimezone(self.account.default_timezone).date() | |
1073 | new_date = new.astimezone(self.account.default_timezone).date() | |
1074 | if relativedelta(month=1) + new_date == old_date: | |
1075 | item.reminder_due_by = new | |
1076 | continue | |
1077 | if relativedelta(month=1) + old_date == new_date: | |
1078 | item.reminder_due_by = new | |
1079 | continue | |
1080 | elif abs(old_date - new_date) == datetime.timedelta(days=30): | |
1081 | item.reminder_due_by = new | |
1082 | continue | |
1083 | if f.is_list: | |
1084 | old, new = set(old or ()), set(new or ()) | |
1085 | self.assertEqual(old, new, (f.name, old, new)) | |
1086 | ||
1087 | # Hard delete | |
1088 | item_id = (item.id, item.changekey) | |
1089 | item.delete() | |
1090 | for e in self.account.fetch(ids=[item_id]): | |
1091 | # It's gone from the account | |
1092 | self.assertIsInstance(e, ErrorItemNotFound) | |
1093 | # Really gone, not just changed ItemId | |
1094 | items = self.test_folder.filter(categories__contains=item.categories) | |
1095 | self.assertEqual(len(items), 0) | |
1096 | ||
1097 | def test_item(self): | |
1098 | # Test insert | |
1099 | insert_kwargs = self.get_random_insert_kwargs() | |
1100 | insert_kwargs['categories'] = self.categories | |
1101 | item = self.ITEM_CLASS(folder=self.test_folder, **insert_kwargs) | |
1102 | # Test with generator as argument | |
1103 | insert_ids = self.test_folder.bulk_create(items=(i for i in [item])) | |
1104 | self.assertEqual(len(insert_ids), 1) | |
1105 | self.assertIsInstance(insert_ids[0], BaseItem) | |
1106 | find_ids = self.test_folder.filter(categories__contains=item.categories).values_list('id', 'changekey') | |
1107 | self.assertEqual(len(find_ids), 1) | |
1108 | self.assertEqual(len(find_ids[0]), 2, find_ids[0]) | |
1109 | self.assertEqual(insert_ids, list(find_ids)) | |
1110 | # Test with generator as argument | |
1111 | item = list(self.account.fetch(ids=(i for i in find_ids)))[0] | |
1112 | for f in self.ITEM_CLASS.FIELDS: | |
1113 | with self.subTest(f=f): | |
1114 | if not f.supports_version(self.account.version): | |
1115 | # Cannot be used with this EWS version | |
1116 | continue | |
1117 | if self.ITEM_CLASS == CalendarItem and f in CalendarItem.timezone_fields(): | |
1118 | # Timezone fields will (and must) be populated automatically from the timestamp | |
1119 | continue | |
1120 | if f.is_read_only: | |
1121 | continue | |
1122 | if f.name == 'reminder_due_by': | |
1123 | # EWS sets a default value if it is not set on insert. Ignore | |
1124 | continue | |
1125 | if f.name == 'mime_content': | |
1126 | # This will change depending on other contents fields | |
1127 | continue | |
1128 | old, new = getattr(item, f.name), insert_kwargs[f.name] | |
1129 | if f.is_list: | |
1130 | old, new = set(old or ()), set(new or ()) | |
1131 | self.assertEqual(old, new, (f.name, old, new)) | |
1132 | ||
1133 | # Test update | |
1134 | update_kwargs = self.get_random_update_kwargs(item=item, insert_kwargs=insert_kwargs) | |
1135 | if self.ITEM_CLASS in (Contact, DistributionList): | |
1136 | # Contact and DistributionList don't support mime_type updates at all | |
1137 | update_kwargs.pop('mime_content', None) | |
1138 | update_fieldnames = [f for f in update_kwargs.keys() if f != 'attachments'] | |
1139 | for k, v in update_kwargs.items(): | |
1140 | setattr(item, k, v) | |
1141 | # Test with generator as argument | |
1142 | update_ids = self.account.bulk_update(items=(i for i in [(item, update_fieldnames)])) | |
1143 | self.assertEqual(len(update_ids), 1) | |
1144 | self.assertEqual(len(update_ids[0]), 2, update_ids) | |
1145 | self.assertEqual(insert_ids[0].id, update_ids[0][0]) # ID should be the same | |
1146 | self.assertNotEqual(insert_ids[0].changekey, update_ids[0][1]) # Changekey should change when item is updated | |
1147 | item = list(self.account.fetch(update_ids))[0] | |
1148 | for f in self.ITEM_CLASS.FIELDS: | |
1149 | with self.subTest(f=f): | |
1150 | if not f.supports_version(self.account.version): | |
1151 | # Cannot be used with this EWS version | |
1152 | continue | |
1153 | if self.ITEM_CLASS == CalendarItem and f in CalendarItem.timezone_fields(): | |
1154 | # Timezone fields will (and must) be populated automatically from the timestamp | |
1155 | continue | |
1156 | if f.is_read_only or f.is_read_only_after_send: | |
1157 | # These cannot be changed | |
1158 | continue | |
1159 | if f.name == 'mime_content': | |
1160 | # This will change depending on other contents fields | |
1161 | continue | |
1162 | old, new = getattr(item, f.name), update_kwargs[f.name] | |
1163 | if f.name == 'reminder_due_by': | |
1164 | if old is None: | |
1165 | # EWS does not always return a value if reminder_is_set is False. Set one now | |
1166 | item.reminder_due_by = new | |
1167 | continue | |
1168 | elif old is not None and new is not None: | |
1169 | # EWS sometimes randomly sets the new reminder due date to one month before or after we | |
1170 | # wanted it, and sometimes 30 days before or after. But only sometimes... | |
1171 | old_date = old.astimezone(self.account.default_timezone).date() | |
1172 | new_date = new.astimezone(self.account.default_timezone).date() | |
1173 | if relativedelta(month=1) + new_date == old_date: | |
1174 | item.reminder_due_by = new | |
1175 | continue | |
1176 | if relativedelta(month=1) + old_date == new_date: | |
1177 | item.reminder_due_by = new | |
1178 | continue | |
1179 | elif abs(old_date - new_date) == datetime.timedelta(days=30): | |
1180 | item.reminder_due_by = new | |
1181 | continue | |
1182 | if f.is_list: | |
1183 | old, new = set(old or ()), set(new or ()) | |
1184 | self.assertEqual(old, new, (f.name, old, new)) | |
1185 | ||
1186 | # Test wiping or removing fields | |
1187 | wipe_kwargs = {} | |
1188 | for f in self.ITEM_CLASS.FIELDS: | |
1189 | if not f.supports_version(self.account.version): | |
1190 | # Cannot be used with this EWS version | |
1191 | continue | |
1192 | if self.ITEM_CLASS == CalendarItem and f in CalendarItem.timezone_fields(): | |
1193 | # Timezone fields will (and must) be populated automatically from the timestamp | |
1194 | continue | |
1195 | if f.is_required or f.is_required_after_save: | |
1196 | # These cannot be deleted | |
1197 | continue | |
1198 | if f.is_read_only or f.is_read_only_after_send: | |
1199 | # These cannot be changed | |
1200 | continue | |
1201 | wipe_kwargs[f.name] = None | |
1202 | for k, v in wipe_kwargs.items(): | |
1203 | setattr(item, k, v) | |
1204 | wipe_ids = self.account.bulk_update([(item, update_fieldnames), ]) | |
1205 | self.assertEqual(len(wipe_ids), 1) | |
1206 | self.assertEqual(len(wipe_ids[0]), 2, wipe_ids) | |
1207 | self.assertEqual(insert_ids[0].id, wipe_ids[0][0]) # ID should be the same | |
1208 | self.assertNotEqual(insert_ids[0].changekey, | |
1209 | wipe_ids[0][1]) # Changekey should not be the same when item is updated | |
1210 | item = list(self.account.fetch(wipe_ids))[0] | |
1211 | for f in self.ITEM_CLASS.FIELDS: | |
1212 | with self.subTest(f=f): | |
1213 | if not f.supports_version(self.account.version): | |
1214 | # Cannot be used with this EWS version | |
1215 | continue | |
1216 | if self.ITEM_CLASS == CalendarItem and f in CalendarItem.timezone_fields(): | |
1217 | # Timezone fields will (and must) be populated automatically from the timestamp | |
1218 | continue | |
1219 | if f.is_required or f.is_required_after_save: | |
1220 | continue | |
1221 | if f.is_read_only or f.is_read_only_after_send: | |
1222 | continue | |
1223 | old, new = getattr(item, f.name), wipe_kwargs[f.name] | |
1224 | if f.is_list: | |
1225 | old, new = set(old or ()), set(new or ()) | |
1226 | self.assertEqual(old, new, (f.name, old, new)) | |
1227 | ||
1228 | try: | |
1229 | self.ITEM_CLASS.register('extern_id', ExternId) | |
1230 | # Test extern_id = None, which deletes the extended property entirely | |
1231 | extern_id = None | |
1232 | item.extern_id = extern_id | |
1233 | wipe2_ids = self.account.bulk_update([(item, ['extern_id']), ]) | |
1234 | self.assertEqual(len(wipe2_ids), 1) | |
1235 | self.assertEqual(len(wipe2_ids[0]), 2, wipe2_ids) | |
1236 | self.assertEqual(insert_ids[0].id, wipe2_ids[0][0]) # ID must be the same | |
1237 | self.assertNotEqual(insert_ids[0].changekey, wipe2_ids[0][1]) # Changekey must change when item is updated | |
1238 | item = list(self.account.fetch(wipe2_ids))[0] | |
1239 | self.assertEqual(item.extern_id, extern_id) | |
1240 | finally: | |
1241 | self.ITEM_CLASS.deregister('extern_id') | |
1242 | ||
1243 | ||
1244 | class GenericItemTest(CommonItemTest): | |
1245 | # Tests that don't need to be run for every single folder type | |
1246 | TEST_FOLDER = 'inbox' | |
1247 | FOLDER_CLASS = Inbox | |
1248 | ITEM_CLASS = Message | |
1249 | ||
1250 | def test_validation(self): | |
1251 | item = self.get_test_item() | |
1252 | item.clean() | |
1253 | for f in self.ITEM_CLASS.FIELDS: | |
1254 | with self.subTest(f=f): | |
1255 | # Test field max_length | |
1256 | if isinstance(f, CharField) and f.max_length: | |
1257 | with self.assertRaises(ValueError): | |
1258 | setattr(item, f.name, 'a' * (f.max_length + 1)) | |
1259 | item.clean() | |
1260 | setattr(item, f.name, 'a') | |
1261 | ||
1262 | def test_invalid_direct_args(self): | |
1263 | with self.assertRaises(ValueError): | |
1264 | item = self.get_test_item() | |
1265 | item.account = None | |
1266 | item.save() # Must have account on save | |
1267 | with self.assertRaises(ValueError): | |
1268 | item = self.get_test_item() | |
1269 | item.id = 'XXX' # Fake a saved item | |
1270 | item.account = None | |
1271 | item.save() # Must have account on update | |
1272 | with self.assertRaises(ValueError): | |
1273 | item = self.get_test_item() | |
1274 | item.save(update_fields=['foo', 'bar']) # update_fields is only valid on update | |
1275 | ||
1276 | with self.assertRaises(ValueError): | |
1277 | item = self.get_test_item() | |
1278 | item.account = None | |
1279 | item.refresh() # Must have account on refresh | |
1280 | with self.assertRaises(ValueError): | |
1281 | item = self.get_test_item() | |
1282 | item.refresh() # Refresh an item that has not been saved | |
1283 | with self.assertRaises(ErrorItemNotFound): | |
1284 | item = self.get_test_item() | |
1285 | item.save() | |
1286 | item_id, changekey = item.id, item.changekey | |
1287 | item.delete() | |
1288 | item.id, item.changekey = item_id, changekey | |
1289 | item.refresh() # Refresh an item that doesn't exist | |
1290 | ||
1291 | with self.assertRaises(ValueError): | |
1292 | item = self.get_test_item() | |
1293 | item.account = None | |
1294 | item.copy(to_folder=self.test_folder) # Must have an account on copy | |
1295 | with self.assertRaises(ValueError): | |
1296 | item = self.get_test_item() | |
1297 | item.copy(to_folder=self.test_folder) # Must be an existing item | |
1298 | with self.assertRaises(ErrorItemNotFound): | |
1299 | item = self.get_test_item() | |
1300 | item.save() | |
1301 | item_id, changekey = item.id, item.changekey | |
1302 | item.delete() | |
1303 | item.id, item.changekey = item_id, changekey | |
1304 | item.copy(to_folder=self.test_folder) # Item disappeared | |
1305 | ||
1306 | with self.assertRaises(ValueError): | |
1307 | item = self.get_test_item() | |
1308 | item.account = None | |
1309 | item.move(to_folder=self.test_folder) # Must have an account on move | |
1310 | with self.assertRaises(ValueError): | |
1311 | item = self.get_test_item() | |
1312 | item.move(to_folder=self.test_folder) # Must be an existing item | |
1313 | with self.assertRaises(ErrorItemNotFound): | |
1314 | item = self.get_test_item() | |
1315 | item.save() | |
1316 | item_id, changekey = item.id, item.changekey | |
1317 | item.delete() | |
1318 | item.id, item.changekey = item_id, changekey | |
1319 | item.move(to_folder=self.test_folder) # Item disappeared | |
1320 | ||
1321 | with self.assertRaises(ValueError): | |
1322 | item = self.get_test_item() | |
1323 | item.account = None | |
1324 | item.delete() # Must have an account | |
1325 | with self.assertRaises(ValueError): | |
1326 | item = self.get_test_item() | |
1327 | item.delete() # Must be an existing item | |
1328 | with self.assertRaises(ErrorItemNotFound): | |
1329 | item = self.get_test_item() | |
1330 | item.save() | |
1331 | item_id, changekey = item.id, item.changekey | |
1332 | item.delete() | |
1333 | item.id, item.changekey = item_id, changekey | |
1334 | item.delete() # Item disappeared | |
1335 | ||
1336 | def test_invalid_kwargs_on_send(self): | |
1337 | # Only Message class has the send() method | |
1338 | with self.assertRaises(ValueError): | |
1339 | item = self.get_test_item() | |
1340 | item.account = None | |
1341 | item.send() # Must have account on send | |
1342 | with self.assertRaises(ErrorItemNotFound): | |
1343 | item = self.get_test_item() | |
1344 | item.save() | |
1345 | item_id, changekey = item.id, item.changekey | |
1346 | item.delete() | |
1347 | item.id, item.changekey = item_id, changekey | |
1348 | item.send() # Item disappeared | |
1349 | with self.assertRaises(AttributeError): | |
1350 | item = self.get_test_item() | |
1351 | item.send(copy_to_folder=self.account.trash, save_copy=False) # Inconsistent args | |
1352 | ||
1353 | def test_unsupported_fields(self): | |
1354 | # Create a field that is not supported by any current versions. Test that we fail when using this field | |
1355 | class UnsupportedProp(ExtendedProperty): | |
1356 | property_set_id = 'deadcafe-beef-beef-beef-deadcafebeef' | |
1357 | property_name = 'Unsupported Property' | |
1358 | property_type = 'String' | |
1359 | ||
1360 | attr_name = 'unsupported_property' | |
1361 | self.ITEM_CLASS.register(attr_name=attr_name, attr_cls=UnsupportedProp) | |
1362 | try: | |
1363 | for f in self.ITEM_CLASS.FIELDS: | |
1364 | if f.name == attr_name: | |
1365 | f.supported_from = Build(99, 99, 99, 99) | |
1366 | ||
1367 | with self.assertRaises(ValueError): | |
1368 | self.test_folder.get(**{attr_name: 'XXX'}) | |
1369 | with self.assertRaises(ValueError): | |
1370 | list(self.test_folder.filter(**{attr_name: 'XXX'})) | |
1371 | with self.assertRaises(ValueError): | |
1372 | list(self.test_folder.all().only(attr_name)) | |
1373 | with self.assertRaises(ValueError): | |
1374 | list(self.test_folder.all().values(attr_name)) | |
1375 | with self.assertRaises(ValueError): | |
1376 | list(self.test_folder.all().values_list(attr_name)) | |
1377 | finally: | |
1378 | self.ITEM_CLASS.deregister(attr_name=attr_name) | |
1379 | ||
1380 | def test_order_by(self): | |
1381 | # Test order_by() on normal field | |
1382 | test_items = [] | |
1383 | for i in range(4): | |
1384 | item = self.get_test_item() | |
1385 | item.subject = 'Subj %s' % i | |
1386 | test_items.append(item) | |
1387 | self.test_folder.bulk_create(items=test_items) | |
1388 | qs = QuerySet( | |
1389 | folder_collection=FolderCollection(account=self.account, folders=[self.test_folder]) | |
1390 | ).filter(categories__contains=self.categories) | |
1391 | self.assertEqual( | |
1392 | [i for i in qs.order_by('subject').values_list('subject', flat=True)], | |
1393 | ['Subj 0', 'Subj 1', 'Subj 2', 'Subj 3'] | |
1394 | ) | |
1395 | self.assertEqual( | |
1396 | [i for i in qs.order_by('-subject').values_list('subject', flat=True)], | |
1397 | ['Subj 3', 'Subj 2', 'Subj 1', 'Subj 0'] | |
1398 | ) | |
1399 | self.bulk_delete(qs) | |
1400 | ||
1401 | try: | |
1402 | self.ITEM_CLASS.register('extern_id', ExternId) | |
1403 | # Test order_by() on ExtendedProperty | |
1404 | test_items = [] | |
1405 | for i in range(4): | |
1406 | item = self.get_test_item() | |
1407 | item.extern_id = 'ID %s' % i | |
1408 | test_items.append(item) | |
1409 | self.test_folder.bulk_create(items=test_items) | |
1410 | qs = QuerySet( | |
1411 | folder_collection=FolderCollection(account=self.account, folders=[self.test_folder]) | |
1412 | ).filter(categories__contains=self.categories) | |
1413 | self.assertEqual( | |
1414 | [i for i in qs.order_by('extern_id').values_list('extern_id', flat=True)], | |
1415 | ['ID 0', 'ID 1', 'ID 2', 'ID 3'] | |
1416 | ) | |
1417 | self.assertEqual( | |
1418 | [i for i in qs.order_by('-extern_id').values_list('extern_id', flat=True)], | |
1419 | ['ID 3', 'ID 2', 'ID 1', 'ID 0'] | |
1420 | ) | |
1421 | finally: | |
1422 | self.ITEM_CLASS.deregister('extern_id') | |
1423 | self.bulk_delete(qs) | |
1424 | ||
1425 | # Test sorting on multiple fields | |
1426 | try: | |
1427 | self.ITEM_CLASS.register('extern_id', ExternId) | |
1428 | test_items = [] | |
1429 | for i in range(2): | |
1430 | for j in range(2): | |
1431 | item = self.get_test_item() | |
1432 | item.subject = 'Subj %s' % i | |
1433 | item.extern_id = 'ID %s' % j | |
1434 | test_items.append(item) | |
1435 | self.test_folder.bulk_create(items=test_items) | |
1436 | qs = QuerySet( | |
1437 | folder_collection=FolderCollection(account=self.account, folders=[self.test_folder]) | |
1438 | ).filter(categories__contains=self.categories) | |
1439 | self.assertEqual( | |
1440 | [i for i in qs.order_by('subject', 'extern_id').values('subject', 'extern_id')], | |
1441 | [{'subject': 'Subj 0', 'extern_id': 'ID 0'}, | |
1442 | {'subject': 'Subj 0', 'extern_id': 'ID 1'}, | |
1443 | {'subject': 'Subj 1', 'extern_id': 'ID 0'}, | |
1444 | {'subject': 'Subj 1', 'extern_id': 'ID 1'}] | |
1445 | ) | |
1446 | self.assertEqual( | |
1447 | [i for i in qs.order_by('-subject', 'extern_id').values('subject', 'extern_id')], | |
1448 | [{'subject': 'Subj 1', 'extern_id': 'ID 0'}, | |
1449 | {'subject': 'Subj 1', 'extern_id': 'ID 1'}, | |
1450 | {'subject': 'Subj 0', 'extern_id': 'ID 0'}, | |
1451 | {'subject': 'Subj 0', 'extern_id': 'ID 1'}] | |
1452 | ) | |
1453 | self.assertEqual( | |
1454 | [i for i in qs.order_by('subject', '-extern_id').values('subject', 'extern_id')], | |
1455 | [{'subject': 'Subj 0', 'extern_id': 'ID 1'}, | |
1456 | {'subject': 'Subj 0', 'extern_id': 'ID 0'}, | |
1457 | {'subject': 'Subj 1', 'extern_id': 'ID 1'}, | |
1458 | {'subject': 'Subj 1', 'extern_id': 'ID 0'}] | |
1459 | ) | |
1460 | self.assertEqual( | |
1461 | [i for i in qs.order_by('-subject', '-extern_id').values('subject', 'extern_id')], | |
1462 | [{'subject': 'Subj 1', 'extern_id': 'ID 1'}, | |
1463 | {'subject': 'Subj 1', 'extern_id': 'ID 0'}, | |
1464 | {'subject': 'Subj 0', 'extern_id': 'ID 1'}, | |
1465 | {'subject': 'Subj 0', 'extern_id': 'ID 0'}] | |
1466 | ) | |
1467 | finally: | |
1468 | self.ITEM_CLASS.deregister('extern_id') | |
1469 | ||
1470 | def test_finditems(self): | |
1471 | now = UTC_NOW() | |
1472 | ||
1473 | # Test argument types | |
1474 | item = self.get_test_item() | |
1475 | ids = self.test_folder.bulk_create(items=[item]) | |
1476 | # No arguments. There may be leftover items in the folder, so just make sure there's at least one. | |
1477 | self.assertGreaterEqual( | |
1478 | len(self.test_folder.filter()), | |
1479 | 1 | |
1480 | ) | |
1481 | # Q object | |
1482 | self.assertEqual( | |
1483 | len(self.test_folder.filter(Q(subject=item.subject))), | |
1484 | 1 | |
1485 | ) | |
1486 | # Multiple Q objects | |
1487 | self.assertEqual( | |
1488 | len(self.test_folder.filter(Q(subject=item.subject), ~Q(subject=item.subject[:-3] + 'XXX'))), | |
1489 | 1 | |
1490 | ) | |
1491 | # Multiple Q object and kwargs | |
1492 | self.assertEqual( | |
1493 | len(self.test_folder.filter(Q(subject=item.subject), categories__contains=item.categories)), | |
1494 | 1 | |
1495 | ) | |
1496 | self.bulk_delete(ids) | |
1497 | ||
1498 | # Test categories which are handled specially - only '__contains' and '__in' lookups are supported | |
1499 | item = self.get_test_item(categories=['TestA', 'TestB']) | |
1500 | ids = self.test_folder.bulk_create(items=[item]) | |
1501 | common_qs = self.test_folder.filter(subject=item.subject) # Guard against other simultaneous runs | |
1502 | self.assertEqual( | |
1503 | len(common_qs.filter(categories__contains='ci6xahH1')), # Plain string | |
1504 | 0 | |
1505 | ) | |
1506 | self.assertEqual( | |
1507 | len(common_qs.filter(categories__contains=['ci6xahH1'])), # Same, but as list | |
1508 | 0 | |
1509 | ) | |
1510 | self.assertEqual( | |
1511 | len(common_qs.filter(categories__contains=['TestA', 'TestC'])), # One wrong category | |
1512 | 0 | |
1513 | ) | |
1514 | self.assertEqual( | |
1515 | len(common_qs.filter(categories__contains=['TESTA'])), # Test case insensitivity | |
1516 | 1 | |
1517 | ) | |
1518 | self.assertEqual( | |
1519 | len(common_qs.filter(categories__contains=['testa'])), # Test case insensitivity | |
1520 | 1 | |
1521 | ) | |
1522 | self.assertEqual( | |
1523 | len(common_qs.filter(categories__contains=['TestA'])), # Partial | |
1524 | 1 | |
1525 | ) | |
1526 | self.assertEqual( | |
1527 | len(common_qs.filter(categories__contains=item.categories)), # Exact match | |
1528 | 1 | |
1529 | ) | |
1530 | with self.assertRaises(ValueError): | |
1531 | len(common_qs.filter(categories__in='ci6xahH1')) # Plain string is not supported | |
1532 | self.assertEqual( | |
1533 | len(common_qs.filter(categories__in=['ci6xahH1'])), # Same, but as list | |
1534 | 0 | |
1535 | ) | |
1536 | self.assertEqual( | |
1537 | len(common_qs.filter(categories__in=['TestA', 'TestC'])), # One wrong category | |
1538 | 1 | |
1539 | ) | |
1540 | self.assertEqual( | |
1541 | len(common_qs.filter(categories__in=['TestA'])), # Partial | |
1542 | 1 | |
1543 | ) | |
1544 | self.assertEqual( | |
1545 | len(common_qs.filter(categories__in=item.categories)), # Exact match | |
1546 | 1 | |
1547 | ) | |
1548 | self.bulk_delete(ids) | |
1549 | ||
1550 | common_qs = self.test_folder.filter(categories__contains=self.categories) | |
1551 | one_hour = datetime.timedelta(hours=1) | |
1552 | two_hours = datetime.timedelta(hours=2) | |
1553 | # Test 'exists' | |
1554 | ids = self.test_folder.bulk_create(items=[self.get_test_item()]) | |
1555 | self.assertEqual( | |
1556 | len(common_qs.filter(datetime_created__exists=True)), | |
1557 | 1 | |
1558 | ) | |
1559 | self.assertEqual( | |
1560 | len(common_qs.filter(datetime_created__exists=False)), | |
1561 | 0 | |
1562 | ) | |
1563 | self.bulk_delete(ids) | |
1564 | ||
1565 | # Test 'range' | |
1566 | ids = self.test_folder.bulk_create(items=[self.get_test_item()]) | |
1567 | self.assertEqual( | |
1568 | len(common_qs.filter(datetime_created__range=(now + one_hour, now + two_hours))), | |
1569 | 0 | |
1570 | ) | |
1571 | self.assertEqual( | |
1572 | len(common_qs.filter(datetime_created__range=(now - one_hour, now + one_hour))), | |
1573 | 1 | |
1574 | ) | |
1575 | self.bulk_delete(ids) | |
1576 | ||
1577 | # Test '>' | |
1578 | ids = self.test_folder.bulk_create(items=[self.get_test_item()]) | |
1579 | self.assertEqual( | |
1580 | len(common_qs.filter(datetime_created__gt=now + one_hour)), | |
1581 | 0 | |
1582 | ) | |
1583 | self.assertEqual( | |
1584 | len(common_qs.filter(datetime_created__gt=now - one_hour)), | |
1585 | 1 | |
1586 | ) | |
1587 | self.bulk_delete(ids) | |
1588 | ||
1589 | # Test '>=' | |
1590 | ids = self.test_folder.bulk_create(items=[self.get_test_item()]) | |
1591 | self.assertEqual( | |
1592 | len(common_qs.filter(datetime_created__gte=now + one_hour)), | |
1593 | 0 | |
1594 | ) | |
1595 | self.assertEqual( | |
1596 | len(common_qs.filter(datetime_created__gte=now - one_hour)), | |
1597 | 1 | |
1598 | ) | |
1599 | self.bulk_delete(ids) | |
1600 | ||
1601 | # Test '<' | |
1602 | ids = self.test_folder.bulk_create(items=[self.get_test_item()]) | |
1603 | self.assertEqual( | |
1604 | len(common_qs.filter(datetime_created__lt=now - one_hour)), | |
1605 | 0 | |
1606 | ) | |
1607 | self.assertEqual( | |
1608 | len(common_qs.filter(datetime_created__lt=now + one_hour)), | |
1609 | 1 | |
1610 | ) | |
1611 | self.bulk_delete(ids) | |
1612 | ||
1613 | # Test '<=' | |
1614 | ids = self.test_folder.bulk_create(items=[self.get_test_item()]) | |
1615 | self.assertEqual( | |
1616 | len(common_qs.filter(datetime_created__lte=now - one_hour)), | |
1617 | 0 | |
1618 | ) | |
1619 | self.assertEqual( | |
1620 | len(common_qs.filter(datetime_created__lte=now + one_hour)), | |
1621 | 1 | |
1622 | ) | |
1623 | self.bulk_delete(ids) | |
1624 | ||
1625 | # Test '=' | |
1626 | item = self.get_test_item() | |
1627 | ids = self.test_folder.bulk_create(items=[item]) | |
1628 | self.assertEqual( | |
1629 | len(common_qs.filter(subject=item.subject[:-3] + 'XXX')), | |
1630 | 0 | |
1631 | ) | |
1632 | self.assertEqual( | |
1633 | len(common_qs.filter(subject=item.subject)), | |
1634 | 1 | |
1635 | ) | |
1636 | self.bulk_delete(ids) | |
1637 | ||
1638 | # Test '!=' | |
1639 | item = self.get_test_item() | |
1640 | ids = self.test_folder.bulk_create(items=[item]) | |
1641 | self.assertEqual( | |
1642 | len(common_qs.filter(subject__not=item.subject)), | |
1643 | 0 | |
1644 | ) | |
1645 | self.assertEqual( | |
1646 | len(common_qs.filter(subject__not=item.subject[:-3] + 'XXX')), | |
1647 | 1 | |
1648 | ) | |
1649 | self.bulk_delete(ids) | |
1650 | ||
1651 | # Test 'exact' | |
1652 | item = self.get_test_item() | |
1653 | item.subject = 'aA' + item.subject[2:] | |
1654 | ids = self.test_folder.bulk_create(items=[item]) | |
1655 | self.assertEqual( | |
1656 | len(common_qs.filter(subject__exact=item.subject[:-3] + 'XXX')), | |
1657 | 0 | |
1658 | ) | |
1659 | self.assertEqual( | |
1660 | len(common_qs.filter(subject__exact=item.subject.lower())), | |
1661 | 0 | |
1662 | ) | |
1663 | self.assertEqual( | |
1664 | len(common_qs.filter(subject__exact=item.subject.upper())), | |
1665 | 0 | |
1666 | ) | |
1667 | self.assertEqual( | |
1668 | len(common_qs.filter(subject__exact=item.subject)), | |
1669 | 1 | |
1670 | ) | |
1671 | self.bulk_delete(ids) | |
1672 | ||
1673 | # Test 'iexact' | |
1674 | item = self.get_test_item() | |
1675 | item.subject = 'aA' + item.subject[2:] | |
1676 | ids = self.test_folder.bulk_create(items=[item]) | |
1677 | self.assertEqual( | |
1678 | len(common_qs.filter(subject__iexact=item.subject[:-3] + 'XXX')), | |
1679 | 0 | |
1680 | ) | |
1681 | self.assertIn( | |
1682 | len(common_qs.filter(subject__iexact=item.subject.lower())), | |
1683 | (0, 1) # iexact search is broken on some EWS versions | |
1684 | ) | |
1685 | self.assertIn( | |
1686 | len(common_qs.filter(subject__iexact=item.subject.upper())), | |
1687 | (0, 1) # iexact search is broken on some EWS versions | |
1688 | ) | |
1689 | self.assertEqual( | |
1690 | len(common_qs.filter(subject__iexact=item.subject)), | |
1691 | 1 | |
1692 | ) | |
1693 | self.bulk_delete(ids) | |
1694 | ||
1695 | # Test 'contains' | |
1696 | item = self.get_test_item() | |
1697 | item.subject = item.subject[2:8] + 'aA' + item.subject[8:] | |
1698 | ids = self.test_folder.bulk_create(items=[item]) | |
1699 | self.assertEqual( | |
1700 | len(common_qs.filter(subject__contains=item.subject[2:14] + 'XXX')), | |
1701 | 0 | |
1702 | ) | |
1703 | self.assertEqual( | |
1704 | len(common_qs.filter(subject__contains=item.subject[2:14].lower())), | |
1705 | 0 | |
1706 | ) | |
1707 | self.assertEqual( | |
1708 | len(common_qs.filter(subject__contains=item.subject[2:14].upper())), | |
1709 | 0 | |
1710 | ) | |
1711 | self.assertEqual( | |
1712 | len(common_qs.filter(subject__contains=item.subject[2:14])), | |
1713 | 1 | |
1714 | ) | |
1715 | self.bulk_delete(ids) | |
1716 | ||
1717 | # Test 'icontains' | |
1718 | item = self.get_test_item() | |
1719 | item.subject = item.subject[2:8] + 'aA' + item.subject[8:] | |
1720 | ids = self.test_folder.bulk_create(items=[item]) | |
1721 | self.assertEqual( | |
1722 | len(common_qs.filter(subject__icontains=item.subject[2:14] + 'XXX')), | |
1723 | 0 | |
1724 | ) | |
1725 | self.assertIn( | |
1726 | len(common_qs.filter(subject__icontains=item.subject[2:14].lower())), | |
1727 | (0, 1) # icontains search is broken on some EWS versions | |
1728 | ) | |
1729 | self.assertIn( | |
1730 | len(common_qs.filter(subject__icontains=item.subject[2:14].upper())), | |
1731 | (0, 1) # icontains search is broken on some EWS versions | |
1732 | ) | |
1733 | self.assertEqual( | |
1734 | len(common_qs.filter(subject__icontains=item.subject[2:14])), | |
1735 | 1 | |
1736 | ) | |
1737 | self.bulk_delete(ids) | |
1738 | ||
1739 | # Test 'startswith' | |
1740 | item = self.get_test_item() | |
1741 | item.subject = 'aA' + item.subject[2:] | |
1742 | ids = self.test_folder.bulk_create(items=[item]) | |
1743 | self.assertEqual( | |
1744 | len(common_qs.filter(subject__startswith='XXX' + item.subject[:12])), | |
1745 | 0 | |
1746 | ) | |
1747 | self.assertEqual( | |
1748 | len(common_qs.filter(subject__startswith=item.subject[:12].lower())), | |
1749 | 0 | |
1750 | ) | |
1751 | self.assertEqual( | |
1752 | len(common_qs.filter(subject__startswith=item.subject[:12].upper())), | |
1753 | 0 | |
1754 | ) | |
1755 | self.assertEqual( | |
1756 | len(common_qs.filter(subject__startswith=item.subject[:12])), | |
1757 | 1 | |
1758 | ) | |
1759 | self.bulk_delete(ids) | |
1760 | ||
1761 | # Test 'istartswith' | |
1762 | item = self.get_test_item() | |
1763 | item.subject = 'aA' + item.subject[2:] | |
1764 | ids = self.test_folder.bulk_create(items=[item]) | |
1765 | self.assertEqual( | |
1766 | len(common_qs.filter(subject__istartswith='XXX' + item.subject[:12])), | |
1767 | 0 | |
1768 | ) | |
1769 | self.assertIn( | |
1770 | len(common_qs.filter(subject__istartswith=item.subject[:12].lower())), | |
1771 | (0, 1) # istartswith search is broken on some EWS versions | |
1772 | ) | |
1773 | self.assertIn( | |
1774 | len(common_qs.filter(subject__istartswith=item.subject[:12].upper())), | |
1775 | (0, 1) # istartswith search is broken on some EWS versions | |
1776 | ) | |
1777 | self.assertEqual( | |
1778 | len(common_qs.filter(subject__istartswith=item.subject[:12])), | |
1779 | 1 | |
1780 | ) | |
1781 | self.bulk_delete(ids) | |
1782 | ||
1783 | def test_filter_with_querystring(self): | |
1784 | # QueryString is only supported from Exchange 2010 | |
1785 | with self.assertRaises(NotImplementedError): | |
1786 | Q('Subject:XXX').to_xml(self.test_folder, version=mock_version(build=EXCHANGE_2007), | |
1787 | applies_to=Restriction.ITEMS) | |
1788 | ||
1789 | # We don't allow QueryString in combination with other restrictions | |
1790 | with self.assertRaises(ValueError): | |
1791 | self.test_folder.filter('Subject:XXX', foo='bar') | |
1792 | with self.assertRaises(ValueError): | |
1793 | self.test_folder.filter('Subject:XXX').filter(foo='bar') | |
1794 | with self.assertRaises(ValueError): | |
1795 | self.test_folder.filter(foo='bar').filter('Subject:XXX') | |
1796 | ||
1797 | item = self.get_test_item() | |
1798 | item.subject = get_random_string(length=8, spaces=False, special=False) | |
1799 | item.save() | |
1800 | # For some reason, the querystring search doesn't work instantly. We may have to wait for up to 60 seconds. | |
1801 | # I'm too impatient for that, so also allow empty results. This makes the test almost worthless but I blame EWS. | |
1802 | self.assertIn( | |
1803 | len(self.test_folder.filter('Subject:%s' % item.subject)), | |
1804 | (0, 1) | |
1805 | ) | |
1806 | ||
1807 | def test_complex_fields(self): | |
1808 | # Test that complex fields can be fetched using only(). This is a test for #141. | |
1809 | item = self.get_test_item().save() | |
1810 | for f in self.ITEM_CLASS.FIELDS: | |
1811 | with self.subTest(f=f): | |
1812 | if not f.supports_version(self.account.version): | |
1813 | # Cannot be used with this EWS version | |
1814 | continue | |
1815 | if f.name in ('optional_attendees', 'required_attendees', 'resources'): | |
1816 | continue | |
1817 | if f.is_read_only: | |
1818 | continue | |
1819 | if f.name == 'reminder_due_by': | |
1820 | # EWS sets a default value if it is not set on insert. Ignore | |
1821 | continue | |
1822 | if f.name == 'mime_content': | |
1823 | # This will change depending on other contents fields | |
1824 | continue | |
1825 | old = getattr(item, f.name) | |
1826 | # Test field as single element in only() | |
1827 | fresh_item = self.test_folder.all().only(f.name).get(categories__contains=item.categories) | |
1828 | new = getattr(fresh_item, f.name) | |
1829 | if f.is_list: | |
1830 | old, new = set(old or ()), set(new or ()) | |
1831 | self.assertEqual(old, new, (f.name, old, new)) | |
1832 | # Test field as one of the elements in only() | |
1833 | fresh_item = self.test_folder.all().only('subject', f.name).get(categories__contains=item.categories) | |
1834 | new = getattr(fresh_item, f.name) | |
1835 | if f.is_list: | |
1836 | old, new = set(old or ()), set(new or ()) | |
1837 | self.assertEqual(old, new, (f.name, old, new)) | |
1838 | ||
1839 | def test_text_body(self): | |
1840 | if self.account.version.build < EXCHANGE_2013: | |
1841 | raise self.skipTest('Exchange version too old') | |
1842 | item = self.get_test_item() | |
1843 | item.body = 'X' * 500 # Make body longer than the normal 256 char text field limit | |
1844 | item.save() | |
1845 | fresh_item = self.test_folder.filter(categories__contains=item.categories).only('text_body')[0] | |
1846 | self.assertEqual(fresh_item.text_body, item.body) | |
1847 | ||
1848 | def test_only_fields(self): | |
1849 | item = self.get_test_item().save() | |
1850 | item = self.test_folder.get(categories__contains=item.categories) | |
1851 | self.assertIsInstance(item, self.ITEM_CLASS) | |
1852 | for f in self.ITEM_CLASS.FIELDS: | |
1853 | with self.subTest(f=f): | |
1854 | self.assertTrue(hasattr(item, f.name)) | |
1855 | if not f.supports_version(self.account.version): | |
1856 | # Cannot be used with this EWS version | |
1857 | continue | |
1858 | if f.name in ('optional_attendees', 'required_attendees', 'resources'): | |
1859 | continue | |
1860 | if f.name == 'reminder_due_by' and not item.reminder_is_set: | |
1861 | # We delete the due date if reminder is not set | |
1862 | continue | |
1863 | elif f.is_read_only: | |
1864 | continue | |
1865 | self.assertIsNotNone(getattr(item, f.name), (f, getattr(item, f.name))) | |
1866 | only_fields = ('subject', 'body', 'categories') | |
1867 | item = self.test_folder.all().only(*only_fields).get(categories__contains=item.categories) | |
1868 | self.assertIsInstance(item, self.ITEM_CLASS) | |
1869 | for f in self.ITEM_CLASS.FIELDS: | |
1870 | with self.subTest(f=f): | |
1871 | self.assertTrue(hasattr(item, f.name)) | |
1872 | if not f.supports_version(self.account.version): | |
1873 | # Cannot be used with this EWS version | |
1874 | continue | |
1875 | if f.name in only_fields: | |
1876 | self.assertIsNotNone(getattr(item, f.name), (f.name, getattr(item, f.name))) | |
1877 | elif f.is_required: | |
1878 | v = getattr(item, f.name) | |
1879 | if f.name == 'attachments': | |
1880 | self.assertEqual(v, [], (f.name, v)) | |
1881 | elif f.default is None: | |
1882 | self.assertIsNone(v, (f.name, v)) | |
1883 | else: | |
1884 | self.assertEqual(v, f.default, (f.name, v)) | |
1885 | ||
1886 | def test_export_and_upload(self): | |
1887 | # 15 new items which we will attempt to export and re-upload | |
1888 | items = [self.get_test_item().save() for _ in range(15)] | |
1889 | ids = [(i.id, i.changekey) for i in items] | |
1890 | # re-fetch items because there will be some extra fields added by the server | |
1891 | items = list(self.account.fetch(items)) | |
1892 | ||
1893 | # Try exporting and making sure we get the right response | |
1894 | export_results = self.account.export(items) | |
1895 | self.assertEqual(len(items), len(export_results)) | |
1896 | for result in export_results: | |
1897 | self.assertIsInstance(result, str) | |
1898 | ||
1899 | # Try reuploading our results | |
1900 | upload_results = self.account.upload([(self.test_folder, data) for data in export_results]) | |
1901 | self.assertEqual(len(items), len(upload_results), (items, upload_results)) | |
1902 | for result in upload_results: | |
1903 | # Must be a completely new ItemId | |
1904 | self.assertIsInstance(result, tuple) | |
1905 | self.assertNotIn(result, ids) | |
1906 | ||
1907 | # Check the items uploaded are the same as the original items | |
1908 | def to_dict(item): | |
1909 | dict_item = {} | |
1910 | # fieldnames is everything except the ID so we'll use it to compare | |
1911 | for f in item.FIELDS: | |
1912 | # datetime_created and last_modified_time aren't copied, but instead are added to the new item after | |
1913 | # uploading. This means mime_content and size can also change. Items also get new IDs on upload. And | |
1914 | # meeting_count values are dependent on contents of current calendar. Form query strings contain the | |
1915 | # item ID and will also change. | |
1916 | if f.name in {'id', 'changekey', 'first_occurrence', 'last_occurrence', 'datetime_created', | |
1917 | 'last_modified_time', 'mime_content', 'size', 'conversation_id', | |
1918 | 'adjacent_meeting_count', 'conflicting_meeting_count', | |
1919 | 'web_client_read_form_query_string', 'web_client_edit_form_query_string'}: | |
1920 | continue | |
1921 | dict_item[f.name] = getattr(item, f.name) | |
1922 | if f.name == 'attachments': | |
1923 | # Attachments get new IDs on upload. Wipe them here so we can compare the other fields | |
1924 | for a in dict_item[f.name]: | |
1925 | a.attachment_id = None | |
1926 | return dict_item | |
1927 | ||
1928 | uploaded_items = sorted([to_dict(item) for item in self.account.fetch(upload_results)], | |
1929 | key=lambda i: i['subject']) | |
1930 | original_items = sorted([to_dict(item) for item in items], key=lambda i: i['subject']) | |
1931 | self.assertListEqual(original_items, uploaded_items) | |
1932 | ||
1933 | def test_export_with_error(self): | |
1934 | # 15 new items which we will attempt to export and re-upload | |
1935 | items = [self.get_test_item().save() for _ in range(15)] | |
1936 | # Use id tuples for export here because deleting an item clears it's | |
1937 | # id. | |
1938 | ids = [(item.id, item.changekey) for item in items] | |
1939 | # Delete one of the items, this will cause an error | |
1940 | items[3].delete() | |
1941 | ||
1942 | export_results = self.account.export(ids) | |
1943 | self.assertEqual(len(items), len(export_results)) | |
1944 | for idx, result in enumerate(export_results): | |
1945 | if idx == 3: | |
1946 | # If it is the one returning the error | |
1947 | self.assertIsInstance(result, ErrorItemNotFound) | |
1948 | else: | |
1949 | self.assertIsInstance(result, str) | |
1950 | ||
1951 | # Clean up after yourself | |
1952 | del ids[3] # Sending the deleted one through will cause an error | |
1953 | ||
1954 | def test_item_attachments(self): | |
1955 | item = self.get_test_item(folder=self.test_folder) | |
1956 | item.attachments = [] | |
1957 | ||
1958 | attached_item1 = self.get_test_item(folder=self.test_folder) | |
1959 | attached_item1.attachments = [] | |
1960 | attached_item1.save() | |
1961 | attachment1 = ItemAttachment(name='attachment1', item=attached_item1) | |
1962 | item.attach(attachment1) | |
1963 | ||
1964 | self.assertEqual(len(item.attachments), 1) | |
1965 | item.save() | |
1966 | fresh_item = list(self.account.fetch(ids=[item]))[0] | |
1967 | self.assertEqual(len(fresh_item.attachments), 1) | |
1968 | fresh_attachments = sorted(fresh_item.attachments, key=lambda a: a.name) | |
1969 | self.assertEqual(fresh_attachments[0].name, 'attachment1') | |
1970 | self.assertIsInstance(fresh_attachments[0].item, self.ITEM_CLASS) | |
1971 | ||
1972 | for f in self.ITEM_CLASS.FIELDS: | |
1973 | with self.subTest(f=f): | |
1974 | # Normalize some values we don't control | |
1975 | if f.is_read_only: | |
1976 | continue | |
1977 | if self.ITEM_CLASS == CalendarItem and f in CalendarItem.timezone_fields(): | |
1978 | # Timezone fields will (and must) be populated automatically from the timestamp | |
1979 | continue | |
1980 | if isinstance(f, ExtendedPropertyField): | |
1981 | # Attachments don't have these values. It may be possible to request it if we can find the FieldURI | |
1982 | continue | |
1983 | if f.name == 'is_read': | |
1984 | # This is always true for item attachments? | |
1985 | continue | |
1986 | if f.name == 'reminder_due_by': | |
1987 | # EWS sets a default value if it is not set on insert. Ignore | |
1988 | continue | |
1989 | if f.name == 'mime_content': | |
1990 | # This will change depending on other contents fields | |
1991 | continue | |
1992 | old_val = getattr(attached_item1, f.name) | |
1993 | new_val = getattr(fresh_attachments[0].item, f.name) | |
1994 | if f.is_list: | |
1995 | old_val, new_val = set(old_val or ()), set(new_val or ()) | |
1996 | self.assertEqual(old_val, new_val, (f.name, old_val, new_val)) | |
1997 | ||
1998 | # Test attach on saved object | |
1999 | attached_item2 = self.get_test_item(folder=self.test_folder) | |
2000 | attached_item2.attachments = [] | |
2001 | attached_item2.save() | |
2002 | attachment2 = ItemAttachment(name='attachment2', item=attached_item2) | |
2003 | item.attach(attachment2) | |
2004 | ||
2005 | self.assertEqual(len(item.attachments), 2) | |
2006 | fresh_item = list(self.account.fetch(ids=[item]))[0] | |
2007 | self.assertEqual(len(fresh_item.attachments), 2) | |
2008 | fresh_attachments = sorted(fresh_item.attachments, key=lambda a: a.name) | |
2009 | self.assertEqual(fresh_attachments[0].name, 'attachment1') | |
2010 | self.assertIsInstance(fresh_attachments[0].item, self.ITEM_CLASS) | |
2011 | ||
2012 | for f in self.ITEM_CLASS.FIELDS: | |
2013 | with self.subTest(f=f): | |
2014 | # Normalize some values we don't control | |
2015 | if f.is_read_only: | |
2016 | continue | |
2017 | if self.ITEM_CLASS == CalendarItem and f in CalendarItem.timezone_fields(): | |
2018 | # Timezone fields will (and must) be populated automatically from the timestamp | |
2019 | continue | |
2020 | if isinstance(f, ExtendedPropertyField): | |
2021 | # Attachments don't have these values. It may be possible to request it if we can find the FieldURI | |
2022 | continue | |
2023 | if f.name == 'reminder_due_by': | |
2024 | # EWS sets a default value if it is not set on insert. Ignore | |
2025 | continue | |
2026 | if f.name == 'is_read': | |
2027 | # This is always true for item attachments? | |
2028 | continue | |
2029 | if f.name == 'mime_content': | |
2030 | # This will change depending on other contents fields | |
2031 | continue | |
2032 | old_val = getattr(attached_item1, f.name) | |
2033 | new_val = getattr(fresh_attachments[0].item, f.name) | |
2034 | if f.is_list: | |
2035 | old_val, new_val = set(old_val or ()), set(new_val or ()) | |
2036 | self.assertEqual(old_val, new_val, (f.name, old_val, new_val)) | |
2037 | ||
2038 | self.assertEqual(fresh_attachments[1].name, 'attachment2') | |
2039 | self.assertIsInstance(fresh_attachments[1].item, self.ITEM_CLASS) | |
2040 | ||
2041 | for f in self.ITEM_CLASS.FIELDS: | |
2042 | # Normalize some values we don't control | |
2043 | if f.is_read_only: | |
2044 | continue | |
2045 | if self.ITEM_CLASS == CalendarItem and f in CalendarItem.timezone_fields(): | |
2046 | # Timezone fields will (and must) be populated automatically from the timestamp | |
2047 | continue | |
2048 | if isinstance(f, ExtendedPropertyField): | |
2049 | # Attachments don't have these values. It may be possible to request it if we can find the FieldURI | |
2050 | continue | |
2051 | if f.name == 'reminder_due_by': | |
2052 | # EWS sets a default value if it is not set on insert. Ignore | |
2053 | continue | |
2054 | if f.name == 'is_read': | |
2055 | # This is always true for item attachments? | |
2056 | continue | |
2057 | if f.name == 'mime_content': | |
2058 | # This will change depending on other contents fields | |
2059 | continue | |
2060 | old_val = getattr(attached_item2, f.name) | |
2061 | new_val = getattr(fresh_attachments[1].item, f.name) | |
2062 | if f.is_list: | |
2063 | old_val, new_val = set(old_val or ()), set(new_val or ()) | |
2064 | self.assertEqual(old_val, new_val, (f.name, old_val, new_val)) | |
2065 | ||
2066 | # Test detach | |
2067 | item.detach(attachment2) | |
2068 | self.assertTrue(attachment2.attachment_id is None) | |
2069 | self.assertTrue(attachment2.parent_item is None) | |
2070 | fresh_item = list(self.account.fetch(ids=[item]))[0] | |
2071 | self.assertEqual(len(fresh_item.attachments), 1) | |
2072 | fresh_attachments = sorted(fresh_item.attachments, key=lambda a: a.name) | |
2073 | ||
2074 | for f in self.ITEM_CLASS.FIELDS: | |
2075 | with self.subTest(f=f): | |
2076 | # Normalize some values we don't control | |
2077 | if f.is_read_only: | |
2078 | continue | |
2079 | if self.ITEM_CLASS == CalendarItem and f in CalendarItem.timezone_fields(): | |
2080 | # Timezone fields will (and must) be populated automatically from the timestamp | |
2081 | continue | |
2082 | if isinstance(f, ExtendedPropertyField): | |
2083 | # Attachments don't have these values. It may be possible to request it if we can find the FieldURI | |
2084 | continue | |
2085 | if f.name == 'reminder_due_by': | |
2086 | # EWS sets a default value if it is not set on insert. Ignore | |
2087 | continue | |
2088 | if f.name == 'is_read': | |
2089 | # This is always true for item attachments? | |
2090 | continue | |
2091 | if f.name == 'mime_content': | |
2092 | # This will change depending on other contents fields | |
2093 | continue | |
2094 | old_val = getattr(attached_item1, f.name) | |
2095 | new_val = getattr(fresh_attachments[0].item, f.name) | |
2096 | if f.is_list: | |
2097 | old_val, new_val = set(old_val or ()), set(new_val or ()) | |
2098 | self.assertEqual(old_val, new_val, (f.name, old_val, new_val)) | |
2099 | ||
2100 | # Test attach with non-saved item | |
2101 | attached_item3 = self.get_test_item(folder=self.test_folder) | |
2102 | attached_item3.attachments = [] | |
2103 | attachment3 = ItemAttachment(name='attachment2', item=attached_item3) | |
2104 | item.attach(attachment3) | |
2105 | item.detach(attachment3) | |
2106 | ||
2107 | ||
2108 | class CalendarTest(CommonItemTest): | |
2109 | TEST_FOLDER = 'calendar' | |
2110 | FOLDER_CLASS = Calendar | |
2111 | ITEM_CLASS = CalendarItem | |
2112 | ||
2113 | def test_updating_timestamps(self): | |
2114 | # Test that we can update an item without changing anything, and maintain the hidden timezone fields as local | |
2115 | # timezones, and that returned timestamps are in UTC. | |
2116 | item = self.get_test_item() | |
2117 | item.reminder_is_set = True | |
2118 | item.is_all_day = False | |
2119 | item.save() | |
2120 | for i in self.account.calendar.filter(categories__contains=self.categories).only('start', 'end', 'categories'): | |
2121 | self.assertEqual(i.start, item.start) | |
2122 | self.assertEqual(i.start.tzinfo, UTC) | |
2123 | self.assertEqual(i.end, item.end) | |
2124 | self.assertEqual(i.end.tzinfo, UTC) | |
2125 | self.assertEqual(i._start_timezone, self.account.default_timezone) | |
2126 | self.assertEqual(i._end_timezone, self.account.default_timezone) | |
2127 | i.save(update_fields=['start', 'end']) | |
2128 | self.assertEqual(i.start, item.start) | |
2129 | self.assertEqual(i.start.tzinfo, UTC) | |
2130 | self.assertEqual(i.end, item.end) | |
2131 | self.assertEqual(i.end.tzinfo, UTC) | |
2132 | self.assertEqual(i._start_timezone, self.account.default_timezone) | |
2133 | self.assertEqual(i._end_timezone, self.account.default_timezone) | |
2134 | for i in self.account.calendar.filter(categories__contains=self.categories).only('start', 'end', 'categories'): | |
2135 | self.assertEqual(i.start, item.start) | |
2136 | self.assertEqual(i.start.tzinfo, UTC) | |
2137 | self.assertEqual(i.end, item.end) | |
2138 | self.assertEqual(i.end.tzinfo, UTC) | |
2139 | self.assertEqual(i._start_timezone, self.account.default_timezone) | |
2140 | self.assertEqual(i._end_timezone, self.account.default_timezone) | |
2141 | i.delete() | |
2142 | ||
2143 | def test_update_to_non_utc_datetime(self): | |
2144 | # Test updating with non-UTC datetime values. This is a separate code path in UpdateItem code | |
2145 | item = self.get_test_item() | |
2146 | item.reminder_is_set = True | |
2147 | item.is_all_day = False | |
2148 | item.save() | |
2149 | # Update start, end and recurrence with timezoned datetimes. For some reason, EWS throws | |
2150 | # 'ErrorOccurrenceTimeSpanTooBig' is we go back in time. | |
2151 | start = get_random_date(start_date=item.start.date() + datetime.timedelta(days=1)) | |
2152 | dt_start, dt_end = [ | |
2153 | dt.astimezone(self.account.default_timezone) for dt in | |
2154 | get_random_datetime_range(start_date=start, end_date=start, tz=self.account.default_timezone) | |
2155 | ] | |
2156 | item.start, item.end = dt_start, dt_end | |
2157 | item.recurrence.boundary.start = dt_start.date() | |
2158 | item.save() | |
2159 | item.refresh() | |
2160 | self.assertEqual(item.start, dt_start) | |
2161 | self.assertEqual(item.end, dt_end) | |
2162 | ||
2163 | def test_all_day_datetimes(self): | |
2164 | # Test that start and end datetimes for all-day items are returned in the datetime of the account. | |
2165 | start = get_random_date() | |
2166 | start_dt, end_dt = get_random_datetime_range( | |
2167 | start_date=start, | |
2168 | end_date=start + datetime.timedelta(days=365), | |
2169 | tz=self.account.default_timezone | |
2170 | ) | |
2171 | item = self.ITEM_CLASS(folder=self.test_folder, start=start_dt, end=end_dt, is_all_day=True, | |
2172 | categories=self.categories) | |
2173 | item.save() | |
2174 | ||
2175 | item = self.test_folder.all().only('start', 'end').get(id=item.id, changekey=item.changekey) | |
2176 | self.assertEqual(item.start.astimezone(self.account.default_timezone).time(), datetime.time(0, 0)) | |
2177 | self.assertEqual(item.end.astimezone(self.account.default_timezone).time(), datetime.time(0, 0)) | |
2178 | ||
2179 | def test_view(self): | |
2180 | item1 = self.ITEM_CLASS( | |
2181 | account=self.account, | |
2182 | folder=self.test_folder, | |
2183 | subject=get_random_string(16), | |
2184 | start=self.account.default_timezone.localize(EWSDateTime(2016, 1, 1, 8)), | |
2185 | end=self.account.default_timezone.localize(EWSDateTime(2016, 1, 1, 10)), | |
2186 | categories=self.categories, | |
2187 | ) | |
2188 | item2 = self.ITEM_CLASS( | |
2189 | account=self.account, | |
2190 | folder=self.test_folder, | |
2191 | subject=get_random_string(16), | |
2192 | start=self.account.default_timezone.localize(EWSDateTime(2016, 2, 1, 8)), | |
2193 | end=self.account.default_timezone.localize(EWSDateTime(2016, 2, 1, 10)), | |
2194 | categories=self.categories, | |
2195 | ) | |
2196 | self.test_folder.bulk_create(items=[item1, item2]) | |
2197 | ||
2198 | # Test missing args | |
2199 | with self.assertRaises(TypeError): | |
2200 | self.test_folder.view() | |
2201 | # Test bad args | |
2202 | with self.assertRaises(ValueError): | |
2203 | list(self.test_folder.view(start=item1.end, end=item1.start)) | |
2204 | with self.assertRaises(TypeError): | |
2205 | list(self.test_folder.view(start='xxx', end=item1.end)) | |
2206 | with self.assertRaises(ValueError): | |
2207 | list(self.test_folder.view(start=item1.start, end=item1.end, max_items=0)) | |
2208 | ||
2209 | def match_cat(i): | |
2210 | return set(i.categories) == set(self.categories) | |
2211 | ||
2212 | # Test dates | |
2213 | self.assertEqual( | |
2214 | len([i for i in self.test_folder.view(start=item1.start, end=item1.end) if match_cat(i)]), | |
2215 | 1 | |
2216 | ) | |
2217 | self.assertEqual( | |
2218 | len([i for i in self.test_folder.view(start=item1.start, end=item2.end) if match_cat(i)]), | |
2219 | 2 | |
2220 | ) | |
2221 | # Edge cases. Get view from end of item1 to start of item2. Should logically return 0 items, but Exchange wants | |
2222 | # it differently and returns item1 even though there is no overlap. | |
2223 | self.assertEqual( | |
2224 | len([i for i in self.test_folder.view(start=item1.end, end=item2.start) if match_cat(i)]), | |
2225 | 1 | |
2226 | ) | |
2227 | self.assertEqual( | |
2228 | len([i for i in self.test_folder.view(start=item1.start, end=item2.start) if match_cat(i)]), | |
2229 | 1 | |
2230 | ) | |
2231 | ||
2232 | # Test max_items | |
2233 | self.assertEqual( | |
2234 | len([i for i in self.test_folder.view(start=item1.start, end=item2.end, max_items=9999) if match_cat(i)]), | |
2235 | 2 | |
2236 | ) | |
2237 | self.assertEqual( | |
2238 | len(self.test_folder.view(start=item1.start, end=item2.end, max_items=1)), | |
2239 | 1 | |
2240 | ) | |
2241 | ||
2242 | # Test chaining | |
2243 | qs = self.test_folder.view(start=item1.start, end=item2.end) | |
2244 | self.assertTrue(qs.count() >= 2) | |
2245 | with self.assertRaises(ErrorInvalidOperation): | |
2246 | qs.filter(subject=item1.subject).count() # EWS does not allow restrictions | |
2247 | self.assertListEqual( | |
2248 | [i for i in qs.order_by('subject').values('subject') if i['subject'] in (item1.subject, item2.subject)], | |
2249 | [{'subject': s} for s in sorted([item1.subject, item2.subject])] | |
2250 | ) | |
2251 | ||
2252 | ||
2253 | class MessagesTest(CommonItemTest): | |
2254 | # Just test one of the Message-type folders | |
2255 | TEST_FOLDER = 'inbox' | |
2256 | FOLDER_CLASS = Inbox | |
2257 | ITEM_CLASS = Message | |
2258 | INCOMING_MESSAGE_TIMEOUT = 20 | |
2259 | ||
2260 | def get_incoming_message(self, subject): | |
2261 | t1 = time.monotonic() | |
2262 | while True: | |
2263 | t2 = time.monotonic() | |
2264 | if t2 - t1 > self.INCOMING_MESSAGE_TIMEOUT: | |
2265 | raise self.skipTest('Too bad. Gave up in %s waiting for the incoming message to show up' % self.id()) | |
2266 | try: | |
2267 | return self.account.inbox.get(subject=subject) | |
2268 | except DoesNotExist: | |
2269 | time.sleep(5) | |
2270 | ||
2271 | def test_send(self): | |
2272 | # Test that we can send (only) Message items | |
2273 | item = self.get_test_item() | |
2274 | item.folder = None | |
2275 | item.send() | |
2276 | self.assertIsNone(item.id) | |
2277 | self.assertIsNone(item.changekey) | |
2278 | self.assertEqual(len(self.test_folder.filter(categories__contains=item.categories)), 0) | |
2279 | ||
2280 | def test_send_and_save(self): | |
2281 | # Test that we can send_and_save Message items | |
2282 | item = self.get_test_item() | |
2283 | item.send_and_save() | |
2284 | self.assertIsNone(item.id) | |
2285 | self.assertIsNone(item.changekey) | |
2286 | time.sleep(5) # Requests are supposed to be transactional, but apparently not... | |
2287 | # Also, the sent item may be followed by an automatic message with the same category | |
2288 | self.assertGreaterEqual(len(self.test_folder.filter(categories__contains=item.categories)), 1) | |
2289 | ||
2290 | # Test update, although it makes little sense | |
2291 | item = self.get_test_item() | |
2292 | item.save() | |
2293 | item.send_and_save() | |
2294 | time.sleep(5) # Requests are supposed to be transactional, but apparently not... | |
2295 | # Also, the sent item may be followed by an automatic message with the same category | |
2296 | self.assertGreaterEqual(len(self.test_folder.filter(categories__contains=item.categories)), 1) | |
2297 | ||
2298 | def test_send_draft(self): | |
2299 | item = self.get_test_item() | |
2300 | item.folder = self.account.drafts | |
2301 | item.is_draft = True | |
2302 | item.save() # Save a draft | |
2303 | item.send() # Send the draft | |
2304 | self.assertIsNone(item.id) | |
2305 | self.assertIsNone(item.changekey) | |
2306 | self.assertIsNone(item.folder) | |
2307 | self.assertEqual(len(self.test_folder.filter(categories__contains=item.categories)), 0) | |
2308 | ||
2309 | def test_send_and_copy_to_folder(self): | |
2310 | item = self.get_test_item() | |
2311 | item.send(save_copy=True, copy_to_folder=self.account.sent) # Send the draft and save to the sent folder | |
2312 | self.assertIsNone(item.id) | |
2313 | self.assertIsNone(item.changekey) | |
2314 | self.assertEqual(item.folder, self.account.sent) | |
2315 | time.sleep(5) # Requests are supposed to be transactional, but apparently not... | |
2316 | self.assertEqual(len(self.account.sent.filter(categories__contains=item.categories)), 1) | |
2317 | ||
2318 | def test_bulk_send(self): | |
2319 | with self.assertRaises(AttributeError): | |
2320 | self.account.bulk_send(ids=[], save_copy=False, copy_to_folder=self.account.trash) | |
2321 | item = self.get_test_item() | |
2322 | item.save() | |
2323 | for res in self.account.bulk_send(ids=[item]): | |
2324 | self.assertEqual(res, True) | |
2325 | time.sleep(10) # Requests are supposed to be transactional, but apparently not... | |
2326 | # By default, sent items are placed in the sent folder | |
2327 | ids = self.account.sent.filter(categories__contains=item.categories).values_list('id', 'changekey') | |
2328 | self.assertEqual(len(ids), 1) | |
2329 | ||
2330 | def test_reply(self): | |
2331 | # Test that we can reply to a Message item. EWS only allows items that have been sent to receive a reply | |
2332 | item = self.get_test_item() | |
2333 | item.folder = None | |
2334 | item.send() # get_test_item() sets the to_recipients to the test account | |
2335 | sent_item = self.get_incoming_message(item.subject) | |
2336 | new_subject = ('Re: %s' % sent_item.subject)[:255] | |
2337 | sent_item.reply(subject=new_subject, body='Hello reply', to_recipients=[item.author]) | |
2338 | reply = self.get_incoming_message(new_subject) | |
2339 | self.account.bulk_delete([sent_item, reply]) | |
2340 | ||
2341 | def test_reply_all(self): | |
2342 | # Test that we can reply-all a Message item. EWS only allows items that have been sent to receive a reply | |
2343 | item = self.get_test_item(folder=None) | |
2344 | item.folder = None | |
2345 | item.send() | |
2346 | sent_item = self.get_incoming_message(item.subject) | |
2347 | new_subject = ('Re: %s' % sent_item.subject)[:255] | |
2348 | sent_item.reply_all(subject=new_subject, body='Hello reply') | |
2349 | reply = self.get_incoming_message(new_subject) | |
2350 | self.account.bulk_delete([sent_item, reply]) | |
2351 | ||
2352 | def test_forward(self): | |
2353 | # Test that we can forward a Message item. EWS only allows items that have been sent to receive a reply | |
2354 | item = self.get_test_item(folder=None) | |
2355 | item.folder = None | |
2356 | item.send() | |
2357 | sent_item = self.get_incoming_message(item.subject) | |
2358 | new_subject = ('Re: %s' % sent_item.subject)[:255] | |
2359 | sent_item.forward(subject=new_subject, body='Hello reply', to_recipients=[item.author]) | |
2360 | reply = self.get_incoming_message(new_subject) | |
2361 | reply2 = sent_item.create_forward(subject=new_subject, body='Hello reply', to_recipients=[item.author]) | |
2362 | reply2 = reply2.save(self.account.drafts) | |
2363 | self.assertIsInstance(reply2, Message) | |
2364 | ||
2365 | self.account.bulk_delete([sent_item, reply, reply2]) | |
2366 | ||
2367 | def test_mime_content(self): | |
2368 | # Tests the 'mime_content' field | |
2369 | subject = get_random_string(16) | |
2370 | msg = MIMEMultipart() | |
2371 | msg['From'] = self.account.primary_smtp_address | |
2372 | msg['To'] = self.account.primary_smtp_address | |
2373 | msg['Subject'] = subject | |
2374 | body = 'MIME test mail' | |
2375 | msg.attach(MIMEText(body, 'plain', _charset='utf-8')) | |
2376 | mime_content = msg.as_bytes() | |
2377 | item = self.ITEM_CLASS( | |
2378 | folder=self.test_folder, | |
2379 | to_recipients=[self.account.primary_smtp_address], | |
2380 | mime_content=mime_content, | |
2381 | categories=self.categories, | |
2382 | ).save() | |
2383 | self.assertEqual(self.test_folder.get(subject=subject).body, body) | |
2384 | ||
2385 | ||
2386 | class TasksTest(CommonItemTest): | |
2387 | TEST_FOLDER = 'tasks' | |
2388 | FOLDER_CLASS = Tasks | |
2389 | ITEM_CLASS = Task | |
2390 | ||
2391 | def test_task_validation(self): | |
2392 | tz = EWSTimeZone.timezone('Europe/Copenhagen') | |
2393 | task = Task(due_date=tz.localize(EWSDateTime(2017, 1, 1)), start_date=tz.localize(EWSDateTime(2017, 2, 1))) | |
2394 | task.clean() | |
2395 | # We reset due date if it's before start date | |
2396 | self.assertEqual(task.due_date, tz.localize(EWSDateTime(2017, 2, 1))) | |
2397 | self.assertEqual(task.due_date, task.start_date) | |
2398 | ||
2399 | task = Task(complete_date=tz.localize(EWSDateTime(2099, 1, 1)), status=Task.NOT_STARTED) | |
2400 | task.clean() | |
2401 | # We reset status if complete_date is set | |
2402 | self.assertEqual(task.status, Task.COMPLETED) | |
2403 | # We also reset complete date to now() if it's in the future | |
2404 | self.assertEqual(task.complete_date.date(), UTC_NOW().date()) | |
2405 | ||
2406 | task = Task(complete_date=tz.localize(EWSDateTime(2017, 1, 1)), start_date=tz.localize(EWSDateTime(2017, 2, 1))) | |
2407 | task.clean() | |
2408 | # We also reset complete date to start_date if it's before start_date | |
2409 | self.assertEqual(task.complete_date, task.start_date) | |
2410 | ||
2411 | task = Task(percent_complete=Decimal('50.0'), status=Task.COMPLETED) | |
2412 | task.clean() | |
2413 | # We reset percent_complete to 100.0 if state is completed | |
2414 | self.assertEqual(task.percent_complete, Decimal(100)) | |
2415 | ||
2416 | task = Task(percent_complete=Decimal('50.0'), status=Task.NOT_STARTED) | |
2417 | task.clean() | |
2418 | # We reset percent_complete to 0.0 if state is not_started | |
2419 | self.assertEqual(task.percent_complete, Decimal(0)) | |
2420 | ||
2421 | def test_complete(self): | |
2422 | item = self.get_test_item().save() | |
2423 | item.refresh() | |
2424 | self.assertNotEqual(item.status, Task.COMPLETED) | |
2425 | self.assertNotEqual(item.percent_complete, Decimal(100)) | |
2426 | item.complete() | |
2427 | item.refresh() | |
2428 | self.assertEqual(item.status, Task.COMPLETED) | |
2429 | self.assertEqual(item.percent_complete, Decimal(100)) | |
2430 | ||
2431 | ||
2432 | class ContactsTest(CommonItemTest): | |
2433 | TEST_FOLDER = 'contacts' | |
2434 | FOLDER_CLASS = Contacts | |
2435 | ITEM_CLASS = Contact | |
2436 | ||
2437 | def test_order_by_on_indexed_field(self): | |
2438 | # Test order_by() on IndexedField (simple and multi-subfield). Only Contact items have these | |
2439 | test_items = [] | |
2440 | label = self.random_val(EmailAddress.get_field_by_fieldname('label')) | |
2441 | for i in range(4): | |
2442 | item = self.get_test_item() | |
2443 | item.email_addresses = [EmailAddress(email='%s@foo.com' % i, label=label)] | |
2444 | test_items.append(item) | |
2445 | self.test_folder.bulk_create(items=test_items) | |
2446 | qs = QuerySet( | |
2447 | folder_collection=FolderCollection(account=self.account, folders=[self.test_folder]) | |
2448 | ).filter(categories__contains=self.categories) | |
2449 | self.assertEqual( | |
2450 | [i[0].email for i in qs.order_by('email_addresses__%s' % label) | |
2451 | .values_list('email_addresses', flat=True)], | |
2452 | ['0@foo.com', '1@foo.com', '2@foo.com', '3@foo.com'] | |
2453 | ) | |
2454 | self.assertEqual( | |
2455 | [i[0].email for i in qs.order_by('-email_addresses__%s' % label) | |
2456 | .values_list('email_addresses', flat=True)], | |
2457 | ['3@foo.com', '2@foo.com', '1@foo.com', '0@foo.com'] | |
2458 | ) | |
2459 | self.bulk_delete(qs) | |
2460 | ||
2461 | test_items = [] | |
2462 | label = self.random_val(PhysicalAddress.get_field_by_fieldname('label')) | |
2463 | for i in range(4): | |
2464 | item = self.get_test_item() | |
2465 | item.physical_addresses = [PhysicalAddress(street='Elm St %s' % i, label=label)] | |
2466 | test_items.append(item) | |
2467 | self.test_folder.bulk_create(items=test_items) | |
2468 | qs = QuerySet( | |
2469 | folder_collection=FolderCollection(account=self.account, folders=[self.test_folder]) | |
2470 | ).filter(categories__contains=self.categories) | |
2471 | self.assertEqual( | |
2472 | [i[0].street for i in qs.order_by('physical_addresses__%s__street' % label) | |
2473 | .values_list('physical_addresses', flat=True)], | |
2474 | ['Elm St 0', 'Elm St 1', 'Elm St 2', 'Elm St 3'] | |
2475 | ) | |
2476 | self.assertEqual( | |
2477 | [i[0].street for i in qs.order_by('-physical_addresses__%s__street' % label) | |
2478 | .values_list('physical_addresses', flat=True)], | |
2479 | ['Elm St 3', 'Elm St 2', 'Elm St 1', 'Elm St 0'] | |
2480 | ) | |
2481 | self.bulk_delete(qs) | |
2482 | ||
2483 | def test_order_by_failure(self): | |
2484 | # Test error handling on indexed properties with labels and subfields | |
2485 | qs = QuerySet( | |
2486 | folder_collection=FolderCollection(account=self.account, folders=[self.test_folder]) | |
2487 | ).filter(categories__contains=self.categories) | |
2488 | with self.assertRaises(ValueError): | |
2489 | qs.order_by('email_addresses') # Must have label | |
2490 | with self.assertRaises(ValueError): | |
2491 | qs.order_by('email_addresses__FOO') # Must have a valid label | |
2492 | with self.assertRaises(ValueError): | |
2493 | qs.order_by('email_addresses__EmailAddress1__FOO') # Must not have a subfield | |
2494 | with self.assertRaises(ValueError): | |
2495 | qs.order_by('physical_addresses__Business') # Must have a subfield | |
2496 | with self.assertRaises(ValueError): | |
2497 | qs.order_by('physical_addresses__Business__FOO') # Must have a valid subfield | |
2498 | ||
2499 | def test_distribution_lists(self): | |
2500 | dl = DistributionList(folder=self.test_folder, display_name=get_random_string(255), categories=self.categories) | |
2501 | dl.save() | |
2502 | new_dl = self.test_folder.get(categories__contains=dl.categories) | |
2503 | self.assertEqual(new_dl.display_name, dl.display_name) | |
2504 | self.assertEqual(new_dl.members, None) | |
2505 | dl.refresh() | |
2506 | ||
2507 | dl.members = set( | |
2508 | # We set mailbox_type to OneOff because otherwise the email address must be an actual account | |
2509 | Member(mailbox=Mailbox(email_address=get_random_email(), mailbox_type='OneOff')) for _ in range(4) | |
2510 | ) | |
2511 | dl.save() | |
2512 | new_dl = self.test_folder.get(categories__contains=dl.categories) | |
2513 | self.assertEqual({m.mailbox.email_address for m in new_dl.members}, dl.members) | |
2514 | ||
2515 | dl.delete() | |
2516 | ||
2517 | def test_find_people(self): | |
2518 | # The test server may not have any contacts. Just test that the FindPeople service and helpers work | |
2519 | self.assertGreaterEqual(len(list(self.test_folder.people())), 0) | |
2520 | self.assertGreaterEqual( | |
2521 | len(list( | |
2522 | self.test_folder.people().only('display_name').filter(display_name='john').order_by('display_name') | |
2523 | )), | |
2524 | 0 | |
2525 | ) | |
2526 | ||
2527 | def test_get_persona(self): | |
2528 | # The test server may not have any personas. Just test that the service response with something we can parse | |
2529 | persona = Persona(id='AAA=', changekey='xxx') | |
2530 | try: | |
2531 | GetPersona(protocol=self.account.protocol).call(persona=persona) | |
2532 | except ErrorInvalidIdMalformed: | |
2533 | pass |
50 | 50 | self.assertEqual(base_p, p) |
51 | 51 | self.assertEqual(id(base_p), id(p)) |
52 | 52 | self.assertEqual(hash(base_p), hash(p)) |
53 | self.assertEqual(id(base_p.thread_pool), id(p.thread_pool)) | |
54 | 53 | self.assertEqual(id(base_p._session_pool), id(p._session_pool)) |
55 | 54 | |
56 | 55 | def test_close(self): |
71 | 70 | self.assertEqual(len({p.raddr[0] for p in proc.connections() if p.raddr[0] in ip_addresses}), 0) |
72 | 71 | |
73 | 72 | def test_poolsize(self): |
74 | self.assertEqual(self.account.protocol.SESSION_POOLSIZE, 4) | |
73 | self.assertEqual(self.account.protocol.SESSION_POOLSIZE, 1) | |
75 | 74 | |
76 | 75 | def test_decrease_poolsize(self): |
76 | # Temporarily change the session pool size so we can test decreasing the pool size | |
77 | tmp = Protocol.SESSION_POOLSIZE | |
78 | Protocol.SESSION_POOLSIZE = 4 | |
77 | 79 | protocol = Protocol(config=Configuration( |
78 | 80 | service_endpoint='https://example.com/Foo.asmx', credentials=Credentials('A', 'B'), |
79 | 81 | auth_type=NTLM, version=Version(Build(15, 1)), retry_policy=FailFast() |
80 | 82 | )) |
81 | self.assertEqual(protocol._session_pool.qsize(), Protocol.SESSION_POOLSIZE) | |
83 | Protocol.SESSION_POOLSIZE = tmp | |
84 | self.assertEqual(protocol._session_pool.qsize(), 4) | |
82 | 85 | protocol.decrease_poolsize() |
83 | 86 | self.assertEqual(protocol._session_pool.qsize(), 3) |
84 | 87 | protocol.decrease_poolsize() |
109 | 112 | accounts = [(self.account, 'Organizer', False)] |
110 | 113 | |
111 | 114 | with self.assertRaises(ValueError): |
112 | self.account.protocol.get_free_busy_info(accounts=[('XXX', 'XXX', 'XXX')], start=0, end=0) | |
115 | self.account.protocol.get_free_busy_info(accounts=[(123, 'XXX', 'XXX')], start=0, end=0) | |
113 | 116 | with self.assertRaises(ValueError): |
114 | 117 | self.account.protocol.get_free_busy_info(accounts=[(self.account, 'XXX', 'XXX')], start=0, end=0) |
115 | 118 | with self.assertRaises(ValueError): |
127 | 130 | self.assertIsInstance(view_info.working_hours_timezone, TimeZone) |
128 | 131 | ms_id = view_info.working_hours_timezone.to_server_timezone(server_timezones, start.year) |
129 | 132 | self.assertIn(ms_id, {t[0] for t in CLDR_TO_MS_TIMEZONE_MAP.values()}) |
133 | ||
134 | # Test account as simple email | |
135 | for view_info in self.account.protocol.get_free_busy_info( | |
136 | accounts=[(self.account.primary_smtp_address, 'Organizer', False)], start=start, end=end | |
137 | ): | |
138 | self.assertIsInstance(view_info, FreeBusyView) | |
130 | 139 | |
131 | 140 | def test_get_roomlists(self): |
132 | 141 | # The test server is not guaranteed to have any room lists which makes this test less useful |
431 | 440 | self.assertEqual(len(return_ids), len(items)) |
432 | 441 | ids = self.account.calendar.filter(start__lt=end, end__gt=start, categories__contains=self.categories) \ |
433 | 442 | .values_list('id', 'changekey') |
434 | self.assertEqual(len(ids), len(items)) | |
443 | self.assertEqual(ids.count(), len(items)) | |
435 | 444 | |
436 | 445 | def test_disable_ssl_verification(self): |
437 | 446 | # Test that we can make requests when SSL verification is turned off. I don't know how to mock TLS responses |
3 | 3 | import requests_mock |
4 | 4 | |
5 | 5 | from exchangelib import DELEGATE, IMPERSONATION |
6 | from exchangelib.account import Identity | |
6 | 7 | from exchangelib.errors import UnauthorizedError |
7 | 8 | from exchangelib.transport import wrap, get_auth_method_from_response, BASIC, NOAUTH, NTLM, DIGEST |
8 | 9 | from exchangelib.util import PrettyXmlHandler, create_element |
84 | 85 | def test_wrap(self): |
85 | 86 | # Test payload wrapper with both delegation, impersonation and timezones |
86 | 87 | MockTZ = namedtuple('EWSTimeZone', ['ms_id']) |
87 | MockAccount = namedtuple('Account', ['access_type', 'primary_smtp_address', 'default_timezone']) | |
88 | MockAccount = namedtuple( | |
89 | 'Account', ['access_type', 'identity', 'default_timezone'] | |
90 | ) | |
88 | 91 | content = create_element('AAA') |
89 | 92 | api_version = 'BBB' |
90 | account = MockAccount(DELEGATE, 'foo@example.com', MockTZ('XXX')) | |
91 | wrapped = wrap(content=content, api_version=api_version, account=account) | |
93 | account = MockAccount(access_type=DELEGATE, identity=None, default_timezone=MockTZ('XXX')) | |
94 | wrapped = wrap(content=content, api_version=api_version, timezone=account.default_timezone) | |
92 | 95 | self.assertEqual( |
93 | 96 | PrettyXmlHandler.prettify_xml(wrapped), |
94 | 97 | b'''<?xml version='1.0' encoding='utf-8'?> |
107 | 110 | </s:Body> |
108 | 111 | </s:Envelope> |
109 | 112 | ''') |
110 | account = MockAccount(IMPERSONATION, 'foo@example.com', MockTZ('XXX')) | |
111 | wrapped = wrap(content=content, api_version=api_version, account=account) | |
112 | self.assertEqual( | |
113 | PrettyXmlHandler.prettify_xml(wrapped), | |
114 | b'''<?xml version='1.0' encoding='utf-8'?> | |
113 | for attr, tag in ( | |
114 | ('primary_smtp_address', 'PrimarySmtpAddress'), | |
115 | ('upn', 'PrincipalName'), | |
116 | ('sid', 'SID'), | |
117 | ('smtp_address', 'SmtpAddress'), | |
118 | ): | |
119 | val = '%s@example.com' % attr | |
120 | account = MockAccount(access_type=DELEGATE, identity=Identity(**{attr: val}), default_timezone=MockTZ('XXX')) | |
121 | wrapped = wrap( | |
122 | content=content, | |
123 | api_version=api_version, | |
124 | account_to_impersonate=account.identity, | |
125 | timezone=account.default_timezone, | |
126 | ) | |
127 | self.assertEqual( | |
128 | PrettyXmlHandler.prettify_xml(wrapped), | |
129 | '''<?xml version='1.0' encoding='utf-8'?> | |
115 | 130 | <s:Envelope |
116 | 131 | xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" |
117 | 132 | xmlns:m="http://schemas.microsoft.com/exchange/services/2006/messages" |
120 | 135 | <t:RequestServerVersion Version="BBB"/> |
121 | 136 | <t:ExchangeImpersonation> |
122 | 137 | <t:ConnectingSID> |
123 | <t:PrimarySmtpAddress>foo@example.com</t:PrimarySmtpAddress> | |
138 | <t:{tag}>{val}</t:{tag}> | |
124 | 139 | </t:ConnectingSID> |
125 | 140 | </t:ExchangeImpersonation> |
126 | 141 | <t:TimeZoneContext> |
131 | 146 | <AAA/> |
132 | 147 | </s:Body> |
133 | 148 | </s:Envelope> |
134 | ''') | |
149 | '''.format(tag=tag, val=val).encode()) |