Codebase list python-exchangelib / 1c928dd
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
73 changed file(s) with 4445 addition(s) and 3526 deletion(s). Raw diff Collapse all Expand all
1414 - openssl aes-256-cbc -K $encrypted_ae8487d57299_key -iv $encrypted_ae8487d57299_iv -in settings.yml.enc -out settings.yml -d
1515
1616 install:
17 - python -m ensurepip --upgrade
1718 # Install master branches of Cython and Cython-built packages if we are testing on nightly since the C API of
1819 # 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 .
2324 # 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
2526
2627 script:
2728 - coverage run --source=exchangelib setup.py test
22
33 HEAD
44 ----
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
537
638
739 3.1.1
0 Copyright (c) 2009-2018 Erik Cederstrand <erik@cederstrand.dk>
0 Copyright (c) 2009 Erik Cederstrand <erik@cederstrand.dk>
11
22 Redistribution and use in source and binary forms, with or without modification, are
33 permitted provided that the following conditions are met:
9191 ## Setup and connecting
9292
9393 ```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
9895
9996 # Specify your credentials. Username is usually in WINDOMAIN\username format, where WINDOMAIN is
10097 # the name of the Windows Domain your username is connected to, but some servers also
126123 still_marys_account = Account(primary_smtp_address='alias_for_mary@example.com',
127124 credentials=credentials, autodiscover=True, access_type=DELEGATE)
128125
129 # Full autodiscover data is availale on the Account object:
126 # Full autodiscover data is available on the Account object:
130127 my_account.ad_response
131128
132129 # Set up a target account and do an autodiscover lookup to find the target EWS endpoint.
137134 # different 'access_type':
138135 account = Account(primary_smtp_address='john@example.com', credentials=credentials,
139136 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'
140148
141149 # If the server doesn't support autodiscover, or you want to avoid the overhead of autodiscover,
142150 # use a Configuration object to set the server location instead:
151 credentials = Credentials(...)
143152 config = Configuration(server='mail.example.com', credentials=credentials)
144153 account = Account(primary_smtp_address='john@example.com', config=config,
145154 autodiscover=False, access_type=DELEGATE)
151160 config = Configuration(
152161 server='example.com', credentials=credentials, version=version, auth_type=NTLM
153162 )
154
163 ```
164
165 ### Fault tolerance
166 ```python
167 from exchangelib import Account, FaultTolerance, Configuration, Credentials
168 from exchangelib.autodiscover import Autodiscovery
155169 # By default, we fail on all exceptions from the server. If you want to enable fault
156170 # tolerance, add a retry policy to your configuration. We will then retry on certain
157171 # transient errors. By default, we back off exponentially and retry for up to an hour.
158172 # This is configurable:
173 credentials = Credentials(...)
159174 config = Configuration(retry_policy=FaultTolerance(max_wait=3600), credentials=credentials)
160175 account = Account(primary_smtp_address='john@example.com', config=config)
161176
162177 # Autodiscovery will also use this policy, but only for the final autodiscover endpoint.
163178 # 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
169179 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
171185 # 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
175200 # OAuth is supported via the OAUTH2 auth type and the OAuth2Credentials class.
176201 # Use OAuth2AuthorizationCodeCredentials for the authorization code flow (useful
177202 # for applications that access multiple accounts).
203 from exchangelib import Configuration, OAuth2Credentials, OAuth2AuthorizationCodeCredentials, \
204 Identity, OAUTH2
205 from oauthlib.oauth2 import OAuth2Token
178206 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
179213 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 )
181217 config = Configuration(credentials=credentials, auth_type=OAUTH2)
182218
183219 # Applications using the authorization code flow that let exchangelib refresh
197233 class MyCredentials(OAuth2AuthorizationCodeCredentials):
198234 def refresh(self):
199235 self.access_token = ...
200
236 ```
237
238 ### Caching autodiscover results
239 ```python
240 from exchangelib import Configuration, Credentials, Account, DELEGATE
201241 # If you're connecting to the same account very often, you can cache the autodiscover result for
202242 # later so you can skip the autodiscover lookup:
243 account = Account(...)
203244 ews_url = account.protocol.service_endpoint
204245 ews_auth_type = account.protocol.auth_type
205246 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
206250
207251 # 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)
209254 account = Account(
210255 primary_smtp_address=primary_smtp_address,
211256 config=config, autodiscover=False,
223268 clear_cache()
224269 ```
225270
226 ## Proxies and custom TLS validation
271 ### Proxies and custom TLS validation
227272
228273 If you need proxy support or custom TLS validation, you can supply a
229274 custom 'requests' transport adapter class, as described in
318363 some_folder.children # A generator of child folders
319364 some_folder.absolute # Returns the absolute path, as a string
320365 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
322367 some_folder.glob('foo*') # Return child folders matching the pattern
323368 some_folder.glob('*/foo') # Return subfolders named 'foo' in any child folder
324369 some_folder.glob('**/foo') # Return subfolders named 'foo' at any depth
642687
643688 # The syntax for filter() is modeled after Django QuerySet filters. The following filter lookup
644689 # 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.
648701 qs.filter(subject='foo') # Returns items where subject is exactly 'foo'. Case-sensitive
649702 qs.filter(start__range=(start, end)) # Returns items within range
650703 qs.filter(subject__in=('foo', 'bar')) # Return items where subject is either 'foo' or 'bar'
10491102 a = Account(...)
10501103 start = a.default_timezone.localize(EWSDateTime(2017, 9, 1, 11))
10511104 end = start + timedelta(hours=2)
1052 item = CalendarItem(
1105 master_recurrence = CalendarItem(
10531106 folder=a.calendar,
10541107 start=start,
10551108 end=end,
10921145 occurrence.save()
10931146 else:
10941147 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'])
10951162 ```
10961163
10971164 ## Message timestamp fields
0 from .account import Account
0 from .account import Account, Identity
11 from .attachments import FileAttachment, ItemAttachment
22 from .autodiscover import discover
33 from .configuration import Configuration
99 from .items import AcceptItem, TentativelyAcceptItem, DeclineItem, CalendarItem, CancelCalendarItem, Contact, \
1010 DistributionList, Message, PostItem, Task
1111 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
1313 from .settings import OofSettings
1414 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
1616 from .version import Build, Version
1717
18 __version__ = '3.1.1'
18 __version__ = '3.2.0'
1919
2020 __all__ = [
2121 '__version__',
22 'Account',
22 'Account', 'Identity',
2323 'FileAttachment', 'ItemAttachment',
2424 'discover',
2525 'Configuration',
3030 'AcceptItem', 'TentativelyAcceptItem', 'DeclineItem', 'CalendarItem', 'CancelCalendarItem', 'Contact',
3131 'DistributionList', 'Message', 'PostItem', 'Task',
3232 'ItemId', 'Mailbox', 'DLMailbox', 'Attendee', 'Room', 'RoomList', 'Body', 'HTMLBody', 'UID',
33 'FailFast', 'FaultTolerance',
33 'FailFast', 'FaultTolerance', 'BaseProtocol', 'NoVerifyHTTPAdapter', 'TLSClientAuth',
3434 'OofSettings',
3535 'Q',
36 'BASIC', 'DIGEST', 'NTLM', 'GSSAPI', 'SSPI', 'OAUTH2',
36 'BASIC', 'DIGEST', 'NTLM', 'GSSAPI', 'SSPI', 'OAUTH2', 'CBA',
3737 'Build', 'Version',
3838 ]
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())
3943
4044
4145 def close_connections():
1414 Directory, Drafts, Favorites, IMContactList, Inbox, Journal, JunkEmail, LocalFailures, MsgFolderRoot, MyContacts, \
1515 Notes, Outbox, PeopleConnect, PublicFoldersRoot, QuickContacts, RecipientCache, RecoverableItemsDeletions, \
1616 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
2420 from .protocol import Protocol
2521 from .queryset import QuerySet
2622 from .services import ExportItems, UploadItems, GetItem, CreateItem, UpdateItem, DeleteItem, MoveItem, SendItem, \
2723 CopyItem, GetUserOofSettings, SetUserOofSettings, GetMailTips, ArchiveItem, GetDelegate
28 from .settings import OofSettings
2924 from .util import get_domain, peek
3025
3126 log = getLogger(__name__)
3227
3328
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
3458 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.
3660 """
3761 def __init__(self, primary_smtp_address, fullname=None, access_type=None, autodiscover=False, credentials=None,
3862 config=None, locale=None, default_timezone=None):
84108 self.ad_response, self.protocol = discover(
85109 email=primary_smtp_address, credentials=credentials, auth_type=auth_type, retry_policy=retry_policy
86110 )
87 self.primary_smtp_address = self.ad_response.autodiscover_smtp_address
111 primary_smtp_address = self.ad_response.autodiscover_smtp_address
88112 else:
89113 if not config:
90114 raise AttributeError('non-autodiscover requires a config')
91 self.primary_smtp_address = primary_smtp_address
115 primary_smtp_address = primary_smtp_address
92116 self.ad_response = None
93117 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
94122 # We may need to override the default server version on a per-account basis because Microsoft may report one
95123 # server version up-front but delegate account requests to an older backend server.
96124 self.version = self.protocol.version
97125 log.debug('Added account: %s', self)
126
127 @property
128 def primary_smtp_address(self):
129 return self.identity.primary_smtp_address
98130
99131 @threaded_cached_property
100132 def admin_audit_logs(self):
284316
285317 @oof_settings.setter
286318 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,
290321 mailbox=Mailbox(email_address=self.primary_smtp_address),
291 oof_settings=value,
292322 )
293323
294324 def _consume_item_service(self, service_cls, items, chunk_size, kwargs):
333363 (account.calendar, "ABCXYZ...")])
334364 -> [("idA", "changekey"), ("idB", "changekey"), ("idC", "changekey")]
335365 """
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 )
342369
343370 def bulk_create(self, folder, items, message_disposition=SAVE_ONLY, send_meeting_invitations=SEND_TO_NONE,
344371 chunk_size=None):
355382 BulkCreateResult objects are normal Item objects except they only contain the 'id' and 'changekey'
356383 of the created item, and the 'id' of any attachments that were also created.
357384 """
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")
377385 if isinstance(items, QuerySet):
378386 # bulk_create() on a queryset does not make sense because it returns items that have already been created
379387 raise ValueError('Cannot bulk create items from a QuerySet')
412420
413421 :return: a list of either (id, changekey) tuples or exception instances, in the same order as the input
414422 """
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')
431423 # bulk_update() on a queryset does not make sense because there would be no opportunity to alter the items. In
432424 # fact, it could be dangerous if the queryset contains an '.only()'. This would wipe out certain fields
433425 # entirely.
466458
467459 :return: a list of either True or exception instances, in the same order as the input
468460 """
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)
483461 log.debug(
484462 'Deleting items for %s (delete_type: %s, send_meeting_invitations: %s, affected_task_occurences: %s)',
485463 self,
509487 raise AttributeError("'save_copy' must be True when 'copy_to_folder' is set")
510488 if save_copy and not copy_to_folder:
511489 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)
514490 return list(
515491 self._consume_item_service(service_cls=SendItem, items=ids, chunk_size=chunk_size, kwargs=dict(
516492 saved_item_folder=copy_to_folder,
525501 :param chunk_size: The number of items to send to the server in a single request
526502 :return: Status for each send operation, in the same order as the input
527503 """
528 if not isinstance(to_folder, BaseFolder):
529 raise ValueError("'to_folder' %r must be a Folder instance" % to_folder)
530504 return list(
531505 i if isinstance(i, Exception) else Item.id_from_xml(i)
532506 for i in self._consume_item_service(service_cls=CopyItem, items=ids, chunk_size=chunk_size, kwargs=dict(
543517 :return: The new IDs of the moved items, in the same order as the input. If 'to_folder' is a public folder or a
544518 folder in a different mailbox, an empty list is returned.
545519 """
546 if not isinstance(to_folder, BaseFolder):
547 raise ValueError("'to_folder' %r must be a Folder instance" % to_folder)
548520 return list(
549521 i if isinstance(i, Exception) else Item.id_from_xml(i)
550522 for i in self._consume_item_service(service_cls=MoveItem, items=ids, chunk_size=chunk_size, kwargs=dict(
561533 :param chunk_size: The number of items to send to the server in a single request
562534 :return: A list containing True or an exception instance in stable order of the requested items
563535 """
564 if not isinstance(to_folder, (BaseFolder, FolderId, DistinguishedFolderId)):
565 raise ValueError("'to_folder' %r must be a Folder or FolderId instance" % to_folder)
566536 return list(self._consume_item_service(service_cls=ArchiveItem, items=ids, chunk_size=chunk_size, kwargs=dict(
567537 to_folder=to_folder,
568538 ))
589559 else:
590560 for field in only_fields:
591561 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}
593565 # Always use IdOnly here, because AllProperties doesn't actually get *all* properties
594566 for i in self._consume_item_service(service_cls=GetItem, items=ids, chunk_size=chunk_size, kwargs=dict(
595567 additional_fields=additional_fields,
606578 """See self.oof_settings about caching considerations
607579 """
608580 # 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(
610582 sending_as=SendingAs(email_address=self.primary_smtp_address),
611583 recipients=[Mailbox(email_address=self.primary_smtp_address)],
612584 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 )
619586
620587 @property
621588 def delegates(self):
33
44 from .fields import BooleanField, TextField, IntegerField, URIField, DateTimeField, EWSElementField, Base64Field, \
55 ItemField, IdField
6 from .properties import RootItemId, EWSElement
6 from .properties import RootItemId, EWSElement, Fields
77 from .services import GetAttachment, CreateAttachment, DeleteAttachment
88
99 log = logging.getLogger(__name__)
1919 ID_ATTR = 'Id'
2020 ROOT_ID_ATTR = 'RootItemId'
2121 ROOT_CHANGEKEY_ATTR = 'RootItemChangeKey'
22 FIELDS = [
22 FIELDS = Fields(
2323 IdField('id', field_uri=ID_ATTR, is_required=True),
2424 IdField('root_id', field_uri=ROOT_ID_ATTR),
2525 IdField('root_changekey', field_uri=ROOT_CHANGEKEY_ATTR),
26 ]
26 )
2727
2828 __slots__ = tuple(f.name for f in FIELDS)
2929
3131 class Attachment(EWSElement):
3232 """Base class for FileAttachment and ItemAttachment
3333 """
34 FIELDS = [
34 FIELDS = Fields(
3535 EWSElementField('attachment_id', value_cls=AttachmentId),
3636 TextField('name', field_uri='Name'),
3737 TextField('content_type', field_uri='ContentType'),
4040 IntegerField('size', field_uri='Size', is_read_only=True), # Attachment size in bytes
4141 DateTimeField('last_modified_time', field_uri='LastModifiedTime'),
4242 BooleanField('is_inline', field_uri='IsInline'),
43 ]
43 )
4444
4545 __slots__ = tuple(f.name for f in FIELDS) + ('parent_item',)
4646
123123 MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/fileattachment
124124 """
125125 ELEMENT_NAME = 'FileAttachment'
126 FIELDS = Attachment.FIELDS + [
126 FIELDS = Attachment.FIELDS + Fields(
127127 BooleanField('is_contact_photo', field_uri='IsContactPhoto'),
128128 Base64Field('_content', field_uri='Content'),
129 ]
129 )
130130
131131 __slots__ = ('is_contact_photo', '_content', '_fp')
132132
199199 """
200200 ELEMENT_NAME = 'ItemAttachment'
201201 # noinspection PyTypeChecker
202 FIELDS = Attachment.FIELDS + [
202 FIELDS = Attachment.FIELDS + Fields(
203203 ItemField('_item', field_uri='Item'),
204 ]
204 )
205205
206206 __slots__ = ('_item',)
207207
55 import dns.resolver
66
77 from ..configuration import Configuration
8 from ..credentials import OAuth2Credentials
89 from ..errors import AutoDiscoverFailed, AutoDiscoverCircularRedirect, TransportError, RedirectError, UnauthorizedError
910 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
1112 from ..util import post_ratelimited, get_domain, get_redirect_url, _back_off_if_needed, _may_retry_on_error, \
1213 is_valid_hostname, DummyResponse, CONNECTION_ERRORS, TLS_ERRORS
1314 from ..version import Version
7475 self._emails_visited = [] # Collects Autodiscover email redirects
7576
7677 def discover(self):
77 self._emails_visited.append(self.email)
78 self._emails_visited.append(self.email.lower())
7879
7980 # Check the autodiscover cache to see if we already know the autodiscover service endpoint for this email
8081 # domain. Use a lock to guard against multiple threads competing to cache information.
135136 # Get the server version. Not all protocol entries have a server version so we cheat a bit and also look at the
136137 # other ones that point to the same endpoint.
137138 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():
139142 version = Version(build=protocol.server_version)
140143 break
141144 else:
227230 with AutodiscoverProtocol.raw_session() as s:
228231 try:
229232 r = getattr(s, method)(**kwargs)
233 r.close() # Release memory
230234 break
231235 except TLS_ERRORS as e:
232236 # Don't retry on TLS errors. They will most likely be persistent.
267271 try:
268272 session = protocol.get_session()
269273 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)
271275 protocol.release_session(session)
272276 except UnauthorizedError as e:
273277 # It's entirely possible for the endpoint to ask for login. We should continue if login fails because this
285289 log.debug('Attempting to get a valid response from %s', url)
286290 try:
287291 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)
288297 ad_protocol = AutodiscoverProtocol(
289298 config=Configuration(
290299 service_endpoint=url,
00 from ..errors import ErrorNonExistentMailbox, AutoDiscoverFailed
11 from ..fields import TextField, EmailAddressField, ChoiceField, Choice, EWSElementField, OnOffField, BooleanField, \
22 IntegerField, BuildField, ProtocolListField
3 from ..properties import EWSElement
3 from ..properties import EWSElement, Fields
44 from ..transport import DEFAULT_ENCODING
55 from ..util import create_element, add_xml_child, to_xml, is_xml, xml_to_str, AUTODISCOVER_REQUEST_NS, \
66 AUTODISCOVER_BASE_NS, AUTODISCOVER_RESPONSE_NS as RNS, ParseError
1313 class User(AutodiscoverBase):
1414 """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/user-pox"""
1515 ELEMENT_NAME = 'User'
16 FIELDS = [
16 FIELDS = Fields(
1717 TextField('display_name', field_uri='DisplayName', namespace=RNS),
1818 TextField('legacy_dn', field_uri='LegacyDN', namespace=RNS),
1919 TextField('deployment_id', field_uri='DeploymentId', namespace=RNS), # GUID format
2020 EmailAddressField('autodiscover_smtp_address', field_uri='AutoDiscoverSMTPAddress', namespace=RNS),
21 ]
21 )
2222 __slots__ = tuple(f.name for f in FIELDS)
2323
2424
2525 class IntExtUrlBase(AutodiscoverBase):
26 FIELDS = [
26 FIELDS = Fields(
2727 TextField('external_url', field_uri='ExternalUrl', namespace=RNS),
2828 TextField('internal_url', field_uri='InternalUrl', namespace=RNS),
29 ]
29 )
3030 __slots__ = tuple(f.name for f in FIELDS)
3131
3232
4444 class NetworkRequirements(AutodiscoverBase):
4545 """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/networkrequirements-pox"""
4646 ELEMENT_NAME = 'NetworkRequirements'
47 FIELDS = [
47 FIELDS = Fields(
4848 TextField('ipv4_start', field_uri='IPv4Start', namespace=RNS),
4949 TextField('ipv4_end', field_uri='IPv4End', namespace=RNS),
5050 TextField('ipv6_start', field_uri='IPv6Start', namespace=RNS),
5151 TextField('ipv6_end', field_uri='IPv6End', namespace=RNS),
52 ]
52 )
5353 __slots__ = tuple(f.name for f in FIELDS)
5454
5555
5959 Used for the 'Internal' and 'External' elements that may contain a stripped-down version of the Protocol element.
6060 """
6161 ELEMENT_NAME = 'Protocol'
62 FIELDS = [
62 FIELDS = Fields(
6363 ChoiceField('type', field_uri='Type', choices={
6464 Choice('WEB'), Choice('EXCH'), Choice('EXPR'), Choice('EXHTTP')
6565 }, namespace=RNS),
6666 TextField('as_url', field_uri='ASUrl', namespace=RNS),
67 ]
67 )
6868 __slots__ = tuple(f.name for f in FIELDS)
6969
7070
7171 class IntExtBase(AutodiscoverBase):
72 FIELDS = [
72 FIELDS = Fields(
7373 # TODO: 'OWAUrl' also has an AuthenticationMethod enum-style XML attribute
7474 TextField('owa_url', field_uri='OWAUrl', namespace=RNS),
7575 EWSElementField('protocol', value_cls=SimpleProtocol),
76 ]
76 )
77
7778 __slots__ = tuple(f.name for f in FIELDS)
7879
7980
9394 """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/protocol-pox"""
9495 ELEMENT_NAME = 'Protocol'
9596 TYPES = ('WEB', 'EXCH', 'EXPR', 'EXHTTP')
96 FIELDS = [
97 FIELDS = Fields(
9798 # Attribute 'Type' is ignored here. Has a name conflict with the child element and does not seem useful.
9899 TextField('version', field_uri='Version', is_attribute=True, namespace=RNS),
99100 ChoiceField('type', field_uri='Type', namespace=RNS, choices={Choice(p) for p in TYPES}),
144145 EWSElementField('network_requirements', value_cls=NetworkRequirements),
145146 EWSElementField('address_book', value_cls=AddressBook),
146147 EWSElementField('mail_store', value_cls=MailStore),
147 ]
148 )
149
148150 __slots__ = tuple(f.name for f in FIELDS)
149151
150152 @property
151153 def auth_type(self):
152154 # 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
154156 if not self.auth_required:
155157 return NOAUTH
156158 return {
159161 'kerb': GSSAPI,
160162 'kerbntlm': NTLM, # Means client can chose between NTLM and GSSAPI
161163 'ntlm': NTLM,
162 # 'certificate' is not supported by us
164 'certificate': CBA,
163165 'negotiate': SSPI, # Unsure about this one
164166 'nego2': GSSAPI,
165167 'anonymous': NOAUTH, # Seen in some docs even though it's not mentioned in MSDN
170172 """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/error-pox"""
171173 ELEMENT_NAME = 'Error'
172174 NAMESPACE = AUTODISCOVER_BASE_NS
173 FIELDS = [
175 FIELDS = Fields(
174176 TextField('id', field_uri='Id', namespace=AUTODISCOVER_BASE_NS, is_attribute=True),
175177 TextField('time', field_uri='Time', namespace=AUTODISCOVER_BASE_NS, is_attribute=True),
176178 TextField('code', field_uri='ErrorCode', namespace=AUTODISCOVER_BASE_NS),
177179 TextField('message', field_uri='Message', namespace=AUTODISCOVER_BASE_NS),
178180 TextField('debug_data', field_uri='DebugData', namespace=AUTODISCOVER_BASE_NS),
179 ]
181 )
182
180183 __slots__ = tuple(f.name for f in FIELDS)
181184
182185
187190 REDIRECT_ADDR = 'redirectAddr'
188191 SETTINGS = 'settings'
189192 ACTIONS = (REDIRECT_URL, REDIRECT_ADDR, SETTINGS)
190 FIELDS = [
193 FIELDS = Fields(
191194 ChoiceField('type', field_uri='AccountType', namespace=RNS, choices={Choice('email')}),
192195 ChoiceField('action', field_uri='Action', namespace=RNS, choices={Choice(p) for p in ACTIONS}),
193196 BooleanField('microsoft_online', field_uri='MicrosoftOnline', namespace=RNS),
198201 ProtocolListField('protocols'),
199202 # 'SmtpAddress' is inside the 'PublicFolderInformation' element
200203 TextField('public_folder_smtp_address', field_uri='SmtpAddress', namespace=RNS),
201 ]
204 )
205
202206 __slots__ = tuple(f.name for f in FIELDS)
203207
204208 @classmethod
219223 class Response(AutodiscoverBase):
220224 """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/response-pox"""
221225 ELEMENT_NAME = 'Response'
222 FIELDS = [
226 FIELDS = Fields(
223227 EWSElementField('user', value_cls=User),
224228 EWSElementField('account', value_cls=Account),
225 ]
229 )
230
226231 __slots__ = tuple(f.name for f in FIELDS)
227232
228233 @property
274279 """
275280 ELEMENT_NAME = 'Response'
276281 NAMESPACE = AUTODISCOVER_BASE_NS
277 FIELDS = [
282 FIELDS = Fields(
278283 EWSElementField('error', value_cls=Error),
279 ]
284 )
285
280286 __slots__ = tuple(f.name for f in FIELDS)
281287
282288
283289 class Autodiscover(EWSElement):
284290 ELEMENT_NAME = 'Autodiscover'
285291 NAMESPACE = AUTODISCOVER_BASE_NS
286 FIELDS = [
292 FIELDS = Fields(
287293 EWSElementField('response', value_cls=Response),
288294 EWSElementField('error_response', value_cls=ErrorResponse),
289 ]
295 )
296
290297 __slots__ = tuple(f.name for f in FIELDS)
291298
292299 @staticmethod
1111
1212
1313 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.
1615
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'.
1818
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'), ...)
2120
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:
2323
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', ...)
2526
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:
2729
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)), ...)
2932
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:
3134
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), ...)
3536 """
3637 def __init__(self, credentials=None, server=None, service_endpoint=None, auth_type=None, version=None,
3738 retry_policy=None):
6465
6566 @threaded_cached_property
6667 def server(self):
68 if not self.service_endpoint:
69 return None
6770 return split_url(self.service_endpoint)[1]
6871
6972 def __repr__(self):
121121 :param client_id: ID of an authorized OAuth application
122122 :param client_secret: Secret associated with the OAuth application
123123 :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):
127129 super().__init__()
128130 self.client_id = client_id
129131 self.client_secret = client_secret
130132 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
131136
132137 def refresh(self, session):
133138 # Creating a new session gets a new access token, so there's no
147152 """
148153 # Ensure we don't update the object in the middle of a new session
149154 # being created, which could cause a race
155 if not isinstance(access_token, dict):
156 raise ValueError("'access_token' must be an OAuth2Token")
150157 with self.lock:
151158 self.access_token = access_token
152159
153160 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))
161177
162178 def __repr__(self):
163179 return self.__class__.__name__ + repr((self.client_id, '********'))
199215 def __init__(self, client_id=None, client_secret=None, authorization_code=None, access_token=None):
200216 super().__init__(client_id, client_secret, tenant_id=None)
201217 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")
202220 self.access_token = access_token
203221
204222 def __repr__(self):
5353
5454 @classmethod
5555 def from_date(cls, d):
56 if d.__class__ != datetime.date:
56 if type(d) != datetime.date:
5757 raise ValueError("%r must be a date instance" % d)
5858 return cls(d.year, d.month, d.day)
5959
109109
110110 @classmethod
111111 def from_datetime(cls, d):
112 if d.__class__ != datetime.datetime:
112 if type(d) != datetime.datetime:
113113 raise ValueError("%r must be a datetime instance" % d)
114114 if d.tzinfo is None:
115115 tz = None
196196 # Returns whether an 'ExtendedProperty' element matches the definition for this class. Extended property fields
197197 # do not have a name, so we must match on the cls.property_* attributes to match a field in the request with a
198198 # field in the response.
199 # TODO: Rewrite to take advantage of exchangelib.properties.ExtendedFieldURI
199200 extended_field_uri = elem.find('{%s}ExtendedFieldURI' % TNS)
200201 cls_props = cls.properties_map()
201202 elem_props = {k: extended_field_uri.get(k) for k in cls_props.keys()}
311312 property_type = 'String'
312313
313314 __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'
66 import logging
77
88 from .errors import ErrorInvalidServerVersion
9 from .ewsdatetime import EWSDateTime, EWSDate, EWSTimeZone, NaiveDateTimeNotAllowed, UnknownTimeZone
9 from .ewsdatetime import EWSDateTime, EWSDate, EWSTimeZone, NaiveDateTimeNotAllowed, UnknownTimeZone, UTC
1010 from .util import create_element, get_xml_attrs, set_xml_value, value_to_xml_text, is_iterable, safe_b64decode, TNS
1111 from .version import Build, Version, EXCHANGE_2013
1212
360360 return set_xml_value(field_elem, value, version=version)
361361
362362 def field_uri_xml(self):
363 from .properties import FieldURI
363364 if not self.field_uri:
364365 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)
366367
367368 def request_tag(self):
368369 if not self.field_uri_postfix:
574575 return self.default
575576
576577
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
577602 class TimeField(FieldURIField):
578603 value_cls = datetime.time
579604
622647 return self.default
623648
624649
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
625671 class TimeZoneField(FieldURIField):
626672 value_cls = EWSTimeZone
627673
642688
643689 def to_xml(self, value, version):
644690 return create_element(
645 't:%s' % self.field_uri_postfix,
691 self.request_tag(),
646692 attrs=OrderedDict([
647693 ('Id', value.ms_id),
648694 ('Name', value.ms_name),
10621108
10631109 @staticmethod
10641110 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)
10721113
10731114 def __hash__(self):
10741115 return hash(self.name)
11041145 return set_xml_value(field_elem, value, version=version)
11051146
11061147 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)
11141150
11151151 def request_tag(self):
11161152 return 't:%s' % self.field_uri
11481184
11491185 def field_uri_xml(self):
11501186 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)
11511201
11521202
11531203 class PhoneNumberField(IndexedField):
11971247 return value
11981248
11991249 def field_uri_xml(self):
1200 elem = create_element('t:ExtendedFieldURI')
1250 from .properties import ExtendedFieldURI
12011251 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)
12141260
12151261 def from_xml(self, elem, account):
12161262 extended_properties = elem.findall(self.value_cls.response_tag())
12991345
13001346 def from_xml(self, elem, account):
13011347 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)
44 from ..errors import ErrorAccessDenied, ErrorFolderNotFound, ErrorCannotEmptyFolder, ErrorCannotDeleteObject, \
55 ErrorDeleteDistinguishedFolder
66 from ..fields import IntegerField, CharField, FieldPath, EffectiveRightsField, PermissionSetField, EWSElementField, \
7 Field
7 Field, IdElementField
88 from ..items import CalendarItem, RegisterMixIn, Persona, ITEM_CLASSES, ITEM_TRAVERSAL_CHOICES, SHAPE_CHOICES, \
99 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
1111 from ..queryset import QuerySet, SearchableMixIn, DoesNotExist
1212 from ..restriction import Restriction
1313 from ..services import CreateFolder, UpdateFolder, DeleteFolder, EmptyFolder, FindPeople
14 from ..util import TNS
14 from ..util import TNS, require_id
1515 from ..version import Version, EXCHANGE_2007_SP1, EXCHANGE_2010
1616 from .collections import FolderCollection
1717 from .queryset import SingleFolderQuerySet, SHALLOW as SHALLOW_FOLDERS, DEEP as DEEP_FOLDERS
3939 LOCALIZED_NAMES = dict() # A map of (str)locale: (tuple)localized_folder_names
4040 ITEM_MODEL_MAP = {cls.response_tag(): cls for cls in ITEM_CLASSES}
4141 ID_ELEMENT_CLS = FolderId
42 LOCAL_FIELDS = [
42 FIELDS = Fields(
43 IdElementField('_id', field_uri='folder:FolderId', value_cls=ID_ELEMENT_CLS),
4344 EWSElementField('parent_folder_id', field_uri='folder:ParentFolderId', value_cls=ParentFolderId,
4445 is_read_only=True),
4546 CharField('folder_class', field_uri='folder:FolderClass', is_required_after_save=True),
4748 IntegerField('total_count', field_uri='folder:TotalCount', is_read_only=True),
4849 IntegerField('child_folder_count', field_uri='folder:ChildFolderCount', is_read_only=True),
4950 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',)
5454
5555 # Used to register extended properties
5656 INSERT_AFTER_FIELD = 'child_folder_count'
126126 elif head == '**':
127127 # Match anything here or in any subfolder at arbitrary depth
128128 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()):
130133 yield c
131134 else:
132135 # Regular pattern
133136 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()):
135139 continue
136140 if tail is None:
137141 yield c
361365 # New folder
362366 if update_fields:
363367 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)
370370 self.root.add_folder(self) # Add this folder to the cache
371371 return self
372372
383383 # These are required and cannot be deleted
384384 continue
385385 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
392388 if self.id != folder_id:
393389 raise ValueError('ID mismatch')
394390 # Don't check changekey value. It may not change on no-op updates
399395 def delete(self, delete_type=HARD_DELETE):
400396 if delete_type not in DELETE_TYPE_CHOICES:
401397 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)
407399 self.root.remove_folder(self) # Remove the updated folder from the cache
408 self.id, self.changekey = None, None
400 self._id = None
409401
410402 def empty(self, delete_type=HARD_DELETE, delete_sub_folders=False):
411403 if delete_type not in DELETE_TYPE_CHOICES:
412404 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
415407 )
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]
420408 if delete_sub_folders:
421409 # We don't know exactly what was deleted, so invalidate the entire folder cache to be safe
422410 self.root.clear_cache()
465453
466454 @classmethod
467455 def _kwargs_from_elem(cls, elem, account):
468 folder_id, changekey = cls.id_from_xml(elem)
469 kwargs = dict(id=folder_id, changekey=changekey)
470456 # Check for 'DisplayName' element before collecting kwargs because because that clears the elements
471457 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}
475459 if has_name_elem and not kwargs['name']:
476460 # When we request the 'DisplayName' property, some folders may still be returned with an empty value.
477461 # Assign a default name to these folders.
492476 return FolderId(id=self.id, changekey=self.changekey).to_xml(version=version)
493477 return super().to_xml(version=version)
494478
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
495483 @classmethod
496484 def resolve(cls, account, folder):
497485 # Resolve a single folder
507495 raise ValueError("Expected folder %r to be a %s instance" % (f, cls))
508496 return f
509497
498 @require_id
510499 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__)
515500 fresh_folder = self.resolve(account=self.account, folder=self)
516501 if self.id != fresh_folder.id:
517502 raise ValueError('ID mismatch')
559544
560545 class Folder(BaseFolder):
561546 """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/folder"""
562 LOCAL_FIELDS = [
547 LOCAL_FIELDS = Fields(
563548 PermissionSetField('permission_set', field_uri='folder:PermissionSet', supported_from=EXCHANGE_2007_SP1),
564549 EffectiveRightsField('effective_rights', field_uri='folder:EffectiveRights', is_read_only=True,
565550 supported_from=EXCHANGE_2007_SP1),
566 ]
551 )
567552 FIELDS = BaseFolder.FIELDS + LOCAL_FIELDS
568553
569554 __slots__ = tuple(f.name for f in LOCAL_FIELDS) + ('_root',)
77 from ..queryset import QuerySet, SearchableMixIn
88 from ..restriction import Restriction
99 from ..services import FindFolder, GetFolder, FindItem
10 from ..util import require_account
1011 from .queryset import FOLDER_TRAVERSAL_CHOICES
1112
1213 log = logging.getLogger(__name__)
255256 ):
256257 yield f
257258
259 @require_account
258260 def find_folders(self, q=None, shape=ID_ONLY, depth=None, additional_fields=None, page_size=None, max_items=None,
259261 offset=0):
260262 # 'depth' controls whether to return direct children or recurse into sub-folders
262264 if not self.folders:
263265 log.debug('Folder list is empty')
264266 return
265 if not self.account:
266 raise ValueError('Folder must have an account')
267267 if q is None or q.is_empty():
268268 restriction = None
269269 else:
22 from ..errors import ErrorAccessDenied, ErrorFolderNotFound, ErrorNoPublicFolderReplicaAvailable, ErrorItemNotFound, \
33 ErrorInvalidOperation
44 from ..fields import EffectiveRightsField
5 from ..properties import Fields
56 from ..version import EXCHANGE_2007_SP1, EXCHANGE_2010_SP1
67 from .collections import FolderCollection
78 from .base import BaseFolder
2122 # 'RootOfHierarchy' subclasses must not be in this list.
2223 WELLKNOWN_FOLDERS = []
2324
24 LOCAL_FIELDS = [
25 LOCAL_FIELDS = Fields(
2526 # This folder type also has 'folder:PermissionSet' on some server versions, but requesting it sometimes causes
2627 # 'ErrorAccessDenied', as reported by some users. Ignore it entirely for root folders - it's usefulness is
2728 # deemed minimal at best.
2829 EffectiveRightsField('effective_rights', field_uri='folder:EffectiveRights', is_read_only=True,
2930 supported_from=EXCHANGE_2007_SP1),
30 ]
31 )
3132 FIELDS = BaseFolder.FIELDS + LOCAL_FIELDS
3233 __slots__ = tuple(f.name for f in LOCAL_FIELDS) + ('_account', '_subfolders')
3334
00 import logging
11
22 from .fields import EmailSubField, LabelField, SubField, NamedSubField, Choice
3 from .properties import EWSElement
3 from .properties import EWSElement, Fields
44
55 log = logging.getLogger(__name__)
66
2727 class EmailAddress(SingleFieldIndexedElement):
2828 """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/entry-emailaddress"""
2929 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]),
3433 EmailSubField('email'),
35 ]
34 )
3635
3736 __slots__ = tuple(f.name for f in FIELDS)
3837
4039 class PhoneNumber(SingleFieldIndexedElement):
4140 """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/entry-phonenumber"""
4241 ELEMENT_NAME = 'Entry'
43 FIELDS = [
42 FIELDS = Fields(
4443 LabelField('label', field_uri='Key', choices={
4544 Choice('AssistantPhone'), Choice('BusinessFax'), Choice('BusinessPhone'), Choice('BusinessPhone2'),
4645 Choice('Callback'), Choice('CarPhone'), Choice('CompanyMainPhone'), Choice('HomeFax'), Choice('HomePhone'),
4847 Choice('Pager'), Choice('PrimaryPhone'), Choice('RadioPhone'), Choice('Telex'), Choice('TtyTddPhone'),
4948 }, default='PrimaryPhone'),
5049 SubField('phone_number'),
51 ]
50 )
5251
5352 __slots__ = tuple(f.name for f in FIELDS)
5453
6160 class PhysicalAddress(MultiFieldIndexedElement):
6261 """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/entry-physicaladdress"""
6362 ELEMENT_NAME = 'Entry'
64 FIELDS = [
63 FIELDS = Fields(
6564 LabelField('label', field_uri='Key', choices={
6665 Choice('Business'), Choice('Home'), Choice('Other')
6766 }, default='Business'),
7069 NamedSubField('state', field_uri='State'),
7170 NamedSubField('country', field_uri='CountryOrRegion'),
7271 NamedSubField('zipcode', field_uri='PostalCode'),
73 ]
72 )
7473
7574 __slots__ = tuple(f.name for f in FIELDS)
7675
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
17 from .calendar_item import CalendarItem, AcceptItem, TentativelyAcceptItem, DeclineItem, CancelCalendarItem, \
28 MeetingRequest, MeetingResponse, MeetingCancellation, CONFERENCE_TYPES
39 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
911 from .message import Message, ReplyToItem, ReplyAllToItem, ForwardItem
1012 from .post import PostItem, PostReplyItem
1113 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)
1231
1332 __all__ = [
1433 'RegisterMixIn', 'MESSAGE_DISPOSITION_CHOICES', 'SAVE_ONLY', 'SEND_ONLY', 'SEND_AND_SAVE_COPY',
2443 'Message', 'ReplyToItem', 'ReplyAllToItem', 'ForwardItem',
2544 'PostItem', 'PostReplyItem',
2645 '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',
2750 ]
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)
11
22 from ..extended_properties import ExtendedProperty
33 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
68 from ..version import EXCHANGE_2007_SP1
79
810 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)
919
1020 # MessageDisposition values. See
1121 # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/createitem
1323 SEND_ONLY = 'SendOnly'
1424 SEND_AND_SAVE_COPY = 'SendAndSaveCopy'
1525 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)
1662
1763
1864 class RegisterMixIn(IdChangeKeyMixIn):
63109
64110 class BaseItem(RegisterMixIn):
65111 """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')
67119
68120 def __init__(self, **kwargs):
69121 # 'account' is optional but allows calling 'send()' and 'delete()'
92144 item.account = account
93145 return item
94146
147 def to_id_xml(self, version):
148 return self._id.to_xml(version=version)
149
95150
96151 class BaseReplyItem(EWSElement):
97152 """Base class for reply/forward elements that share the same fields"""
98 FIELDS = [
153 FIELDS = Fields(
99154 CharField('subject', field_uri='Subject'),
100155 BodyField('body', field_uri='Body'), # Accepts and returns Body or HTMLBody instances
101156 MailboxListField('to_recipients', field_uri='ToRecipients'),
108163 BodyField('new_body', field_uri='NewBodyContent'), # Accepts and returns Body or HTMLBody instances
109164 MailboxField('received_by', field_uri='ReceivedBy', supported_from=EXCHANGE_2007_SP1),
110165 MailboxField('received_by_representing', field_uri='ReceivedRepresenting', supported_from=EXCHANGE_2007_SP1),
111 ]
166 )
112167
113168 __slots__ = tuple(f.name for f in FIELDS) + ('account',)
114169
120175 raise ValueError("'account' %r must be an Account instance" % self.account)
121176 super().__init__(**kwargs)
122177
178 @require_account
123179 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__)
126180 if copy_to_folder:
127181 if not save_copy:
128182 raise AttributeError("'save_copy' must be True when 'copy_to_folder' is set")
129183 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
134193 def save(self, folder):
135194 """
136195 save reply/forward and retrieve the item result for further modification,
137196 you may want to use account.drafts as the folder.
138197 """
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
01 import logging
12
3 from ..ewsdatetime import EWSDate, EWSDateTime
24 from ..fields import BooleanField, IntegerField, TextField, ChoiceField, URIField, BodyField, DateTimeField, \
35 MessageHeaderField, AttachmentField, RecurrenceField, MailboxField, AttendeesField, Choice, OccurrenceField, \
46 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
710 from ..recurrence import FirstOccurrence, LastOccurrence, Occurrence, DeletedOccurrence
11 from ..services import CreateItem
12 from ..util import set_xml_value, require_account
813 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
1015 from .item import Item
1116 from .message import Message
1217
5257 MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/calendaritem
5358 """
5459 ELEMENT_NAME = 'CalendarItem'
55 LOCAL_FIELDS = [
60 LOCAL_FIELDS = Fields(
5661 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),
5964 DateTimeField('original_start', field_uri='calendar:OriginalStart', is_read_only=True),
6065 BooleanField('is_all_day', field_uri='calendar:IsAllDayEvent', is_required=True, default=False),
6166 FreeBusyStatusField('legacy_free_busy_status', field_uri='calendar:LegacyFreeBusyStatus', is_required=True,
111116 is_read_only=True),
112117 URIField('meeting_workspace_url', field_uri='calendar:MeetingWorkspaceUrl'),
113118 URIField('net_show_url', field_uri='calendar:NetShowUrl'),
114 ]
119 )
115120 FIELDS = Item.FIELDS + LOCAL_FIELDS
116121
117122 __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 )
118147
119148 @classmethod
120149 def timezone_fields(cls):
123152 def clean_timezone_fields(self, version):
124153 # pylint: disable=access-member-before-definition
125154 # 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
126167 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
129170 self._start_timezone = None
130171 self._end_timezone = None
131172 else:
132173 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
137178
138179 def clean(self, version=None):
139180 # pylint: disable=access-member-before-definition
159200 update_fields.remove('uid')
160201 return update_fields
161202
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
162259
163260 class BaseMeetingItem(Item):
164261 """
171268 Therefore BaseMeetingItem inherits from EWSElement has no save() or send() method
172269
173270 """
174 LOCAL_FIELDS = Message.LOCAL_FIELDS[:-2] + [
271 LOCAL_FIELDS = Message.LOCAL_FIELDS[:-2] + Fields(
175272 AssociatedCalendarItemIdField('associated_calendar_item_id', field_uri='meeting:AssociatedCalendarItemId',
176273 value_cls=AssociatedCalendarItemId),
177274 BooleanField('is_delegated', field_uri='meeting:IsDelegated', is_read_only=True, default=False),
181278 choices={Choice('Unknown'), Choice('Organizer'), Choice('Tentative'),
182279 Choice('Accept'), Choice('Decline'), Choice('NoResponseReceived')},
183280 is_required=True, default='Unknown'),
184 ]
281 )
185282 FIELDS = Item.FIELDS + LOCAL_FIELDS
186283
187284 __slots__ = tuple(f.name for f in LOCAL_FIELDS)
192289 MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/meetingrequest
193290 """
194291 ELEMENT_NAME = 'MeetingRequest'
195 LOCAL_FIELDS = [
292 LOCAL_FIELDS = Fields(
196293 ChoiceField('meeting_request_type', field_uri='meetingRequest:MeetingRequestType',
197294 choices={Choice('FullUpdate'), Choice('InformationalUpdate'), Choice('NewMeetingRequest'),
198295 Choice('None'), Choice('Outdated'), Choice('PrincipalWantsCopy'),
201298 ChoiceField('intended_free_busy_status', field_uri='meetingRequest:IntendedFreeBusyStatus', choices={
202299 Choice('Free'), Choice('Tentative'), Choice('Busy'), Choice('OOF'), Choice('NoData')},
203300 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'))
205302
206303 # FIELDS on this element are shuffled compared to other elements
207304 culture_idx = None
233330 class MeetingResponse(BaseMeetingItem):
234331 """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/meetingresponse"""
235332 ELEMENT_NAME = 'MeetingResponse'
236 LOCAL_FIELDS = [
333 LOCAL_FIELDS = Fields(
237334 MailboxField('received_by', field_uri='message:ReceivedBy', is_read_only=True),
238335 MailboxField('received_representing', field_uri='message:ReceivedRepresenting', is_read_only=True),
239 ]
336 )
240337 # FIELDS on this element are shuffled compared to other elements
241338 culture_idx = None
242339 for i, field in enumerate(Item.FIELDS):
260357
261358 class BaseMeetingReplyItem(BaseItem):
262359 """Base class for meeting request reply items that share the same fields (Accept, TentativelyAccept, Decline)"""
263 FIELDS = [
360 FIELDS = Fields(
264361 CharField('item_class', field_uri='item:ItemClass', is_read_only=True),
265362 ChoiceField('sensitivity', field_uri='item:Sensitivity', choices={
266363 Choice('Normal'), Choice('Personal'), Choice('Private'), Choice('Confidential')
268365 BodyField('body', field_uri='item:Body'), # Accepts and returns Body or HTMLBody instances
269366 AttachmentField('attachments', field_uri='item:Attachments'), # ItemAttachment or FileAttachment
270367 MessageHeaderField('headers', field_uri='item:InternetMessageHeaders', is_read_only=True),
271 ] + Message.LOCAL_FIELDS[:6] + [
368 ) + Message.LOCAL_FIELDS[:6] + Fields(
272369 ReferenceItemIdField('reference_item_id', field_uri='item:ReferenceItemId', value_cls=ReferenceItemId),
273370 MailboxField('received_by', field_uri='message:ReceivedBy', is_read_only=True),
274371 MailboxField('received_representing', field_uri='message:ReceivedRepresenting', is_read_only=True),
275372 DateTimeField('proposed_start', field_uri='meeting:ProposedStart', supported_from=EXCHANGE_2013),
276373 DateTimeField('proposed_end', field_uri='meeting:ProposedEnd', supported_from=EXCHANGE_2013),
277 ]
374 )
278375
279376 __slots__ = tuple(f.name for f in FIELDS)
280377
378 @require_account
281379 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)
291388
292389
293390 class AcceptItem(BaseMeetingReplyItem):
314411 class CancelCalendarItem(BaseReplyItem):
315412 """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/cancelcalendaritem"""
316413 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()
00 import logging
11
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
56 from ..version import EXCHANGE_2010, EXCHANGE_2013
67 from .item import Item
78
1314 MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/contact
1415 """
1516 ELEMENT_NAME = 'Contact'
16 LOCAL_FIELDS = [
17 LOCAL_FIELDS = Fields(
1718 TextField('file_as', field_uri='contacts:FileAs'),
1819 ChoiceField('file_as_mapping', field_uri='contacts:FileAsMapping', choices={
1920 Choice('None'), Choice('LastCommaFirst'), Choice('FirstSpaceLast'), Choice('Company'),
3435 PhysicalAddressField('physical_addresses', field_uri='contacts:PhysicalAddress'),
3536 PhoneNumberField('phone_numbers', field_uri='contacts:PhoneNumber'),
3637 TextField('assistant_name', field_uri='contacts:AssistantName'),
37 DateTimeField('birthday', field_uri='contacts:Birthday'),
38 DateTimeBackedDateField('birthday', field_uri='contacts:Birthday'),
3839 URIField('business_homepage', field_uri='contacts:BusinessHomePage'),
3940 TextListField('children', field_uri='contacts:Children'),
4041 TextListField('companies', field_uri='contacts:Companies', is_searchable=False),
5455 TextField('profession', field_uri='contacts:Profession'),
5556 TextField('spouse_name', field_uri='contacts:SpouseName'),
5657 CharField('surname', field_uri='contacts:Surname'),
57 DateTimeField('wedding_anniversary', field_uri='contacts:WeddingAnniversary'),
58 DateTimeBackedDateField('wedding_anniversary', field_uri='contacts:WeddingAnniversary'),
5859 BooleanField('has_picture', field_uri='contacts:HasPicture', supported_from=EXCHANGE_2010, is_read_only=True),
5960 TextField('phonetic_full_name', field_uri='contacts:PhoneticFullName', supported_from=EXCHANGE_2013,
6061 is_read_only=True),
7576 TextField('directory_id', field_uri='contacts:DirectoryId', supported_from=EXCHANGE_2013, is_read_only=True),
7677 # Placeholder for ManagerMailbox
7778 # Placeholder for DirectReports
78 ]
79 )
7980 FIELDS = Item.FIELDS + LOCAL_FIELDS
8081
8182 __slots__ = tuple(f.name for f in LOCAL_FIELDS)
8586 """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/persona"""
8687 ELEMENT_NAME = 'Persona'
8788 ID_ELEMENT_CLS = PersonaId
88 LOCAL_FIELDS = [
89 LOCAL_FIELDS = Fields(
90 IdElementField('_id', field_uri='persona:PersonaId', value_cls=ID_ELEMENT_CLS),
8991 CharField('file_as', field_uri='persona:FileAs'),
9092 CharField('display_name', field_uri='persona:DisplayName'),
9193 CharField('given_name', field_uri='persona:GivenName'),
98100 CharField('company_name', field_uri='persona:CompanyName'),
99101 CharField('im_address', field_uri='persona:ImAddress'),
100102 TextField('initials', field_uri='persona:Initials'),
101 ]
103 )
102104 FIELDS = IdChangeKeyMixIn.FIELDS + LOCAL_FIELDS
103105
104106 __slots__ = tuple(f.name for f in LOCAL_FIELDS)
109111 MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/distributionlist
110112 """
111113 ELEMENT_NAME = 'DistributionList'
112 LOCAL_FIELDS = [
114 LOCAL_FIELDS = Fields(
113115 CharField('display_name', field_uri='contacts:DisplayName', is_required=True),
114116 CharField('file_as', field_uri='contacts:FileAs', is_read_only=True),
115117 ChoiceField('contact_source', field_uri='contacts:ContactSource', choices={
116118 Choice('Store'), Choice('ActiveDirectory')
117119 }, is_read_only=True),
118120 MemberListField('members', field_uri='distributionlist:Members'),
119 ]
121 )
120122 FIELDS = Item.FIELDS + LOCAL_FIELDS
121123
122124 __slots__ = tuple(f.name for f in LOCAL_FIELDS)
11
22 from ..fields import BooleanField, IntegerField, TextField, CharListField, ChoiceField, URIField, BodyField, \
33 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
79 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
912
1013 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)
4714
4815
4916 class Item(BaseItem):
5219 """
5320 ELEMENT_NAME = 'Item'
5421
55 LOCAL_FIELDS = [
22 LOCAL_FIELDS = Fields(
5623 MimeContentField('mime_content', field_uri='item:MimeContent', is_read_only_after_send=True),
5724 EWSElementField('parent_folder_id', field_uri='item:ParentFolderId', value_cls=ParentFolderId,
5825 is_read_only=True),
8552 BooleanField('reminder_is_set', field_uri='item:ReminderIsSet', is_required=True, default=False),
8653 IntegerField('reminder_minutes_before_start', field_uri='item:ReminderMinutesBeforeStart',
8754 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),
9057 BooleanField('has_attachments', field_uri='item:HasAttachments', is_read_only=True),
9158 # ExtendedProperty fields go here
9259 CultureField('culture', field_uri='item:Culture', is_required_after_save=True, is_searchable=False),
10168 EWSElementField('conversation_id', field_uri='item:ConversationId', value_cls=ConversationId,
10269 is_read_only=True, supported_from=EXCHANGE_2010),
10370 BodyField('unique_body', field_uri='item:UniqueBody', is_read_only=True, supported_from=EXCHANGE_2010),
104 ]
105
71 )
10672 FIELDS = LOCAL_FIELDS[0:1] + BaseItem.FIELDS + LOCAL_FIELDS[1:]
10773
10874 __slots__ = tuple(f.name for f in LOCAL_FIELDS)
13298 conflict_resolution=conflict_resolution,
13399 send_meeting_invitations=send_meeting_invitations
134100 )
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.
136104 raise ValueError("'id' mismatch in returned update response")
137105 # 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)
139107 else:
140108 if update_fields:
141109 raise ValueError("'update_fields' is only valid for updates")
142110 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.
146114 tmp_attachments, self.attachments = self.attachments, []
147115 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)
149117 for old_att, new_att in zip(self.attachments, item.attachments):
150118 if old_att.attachment_id is not None:
151119 raise ValueError("Old 'attachment_id' is not empty")
157125 self.attach(tmp_attachments)
158126 return self
159127
128 @require_account
160129 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)
176141
177142 def _update_fieldnames(self):
178143 from .contact import Contact, DistributionList
201166 update_fieldnames.append(f.name)
202167 return update_fieldnames
203168
169 @require_account
204170 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__)
207171 if not self.changekey:
208172 raise ValueError('%s must have changekey' % self.__class__.__name__)
209173 if not update_fieldnames:
210174 # The fields to update was not specified explicitly. Update all fields where update is possible
211175 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,
215179 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
227189 def refresh(self):
228190 # 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")
241202 for f in self.FIELDS:
242 setattr(self, f.name, getattr(fresh_item, f.name))
203 setattr(self, f.name, getattr(res, f.name))
243204 # 'parent_item' should point to 'self', not 'fresh_item'. That way, 'fresh_item' can be garbage collected.
244205 for a in self.attachments:
245206 a.parent_item = self
246 del fresh_item
247
207 del res
208
209 @require_id
248210 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:
255217 # Assume 'to_folder' is a public folder or a folder in a different mailbox
256218 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
263222 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:
270229 # 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
272231 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))
278233 self.folder = to_folder
279234
280235 def move_to_trash(self, send_meeting_cancellations=SEND_TO_NONE, affected_task_occurrences=ALL_OCCURRENCIES,
282237 # Delete and move to the trash folder.
283238 self._delete(delete_type=MOVE_TO_DELETED_ITEMS, send_meeting_cancellations=send_meeting_cancellations,
284239 affected_task_occurrences=affected_task_occurrences, suppress_read_receipts=suppress_read_receipts)
285 self.id, self.changekey = None, None
240 self._id = None
286241 self.folder = self.account.trash
287242
288243 def soft_delete(self, send_meeting_cancellations=SEND_TO_NONE, affected_task_occurrences=ALL_OCCURRENCIES,
290245 # Delete and move to the dumpster, if it is enabled.
291246 self._delete(delete_type=SOFT_DELETE, send_meeting_cancellations=send_meeting_cancellations,
292247 affected_task_occurrences=affected_task_occurrences, suppress_read_receipts=suppress_read_receipts)
293 self.id, self.changekey = None, None
248 self._id = None
294249 self.folder = self.account.recoverable_items_deletions
295250
296251 def delete(self, send_meeting_cancellations=SEND_TO_NONE, affected_task_occurrences=ALL_OCCURRENCIES,
298253 # Remove the item permanently. No copies are stored anywhere.
299254 self._delete(delete_type=HARD_DELETE, send_meeting_cancellations=send_meeting_cancellations,
300255 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
303259 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
316269 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)
327271
328272 def attach(self, attachments):
329273 """Add an attachment, or a list of attachments, to this item. If the item has already been saved, the
364308 if a in self.attachments:
365309 self.attachments.remove(a)
366310
311 @require_id
367312 def create_forward(self, subject, body, to_recipients, cc_recipients=None, bcc_recipients=None):
368313 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__)
373314 return ForwardItem(
374315 account=self.account,
375316 reference_item_id=ReferenceItemId(id=self.id, changekey=self.changekey),
388329 cc_recipients,
389330 bcc_recipients,
390331 ).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 = []
00 import logging
11
22 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
57 from .base import BaseReplyItem
68 from .item import Item, AUTO_RESOLVE, SEND_TO_NONE, SEND_ONLY, SEND_AND_SAVE_COPY
79
1315 MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/message-ex15websvcsotherref
1416 """
1517 ELEMENT_NAME = 'Message'
16 LOCAL_FIELDS = [
18 LOCAL_FIELDS = Fields(
1719 MailboxField('sender', field_uri='message:Sender', is_read_only=True, is_read_only_after_send=True),
1820 MailboxListField('to_recipients', field_uri='message:ToRecipients', is_read_only_after_send=True,
1921 is_searchable=False),
3739 MailboxField('received_by', field_uri='message:ReceivedBy', is_read_only=True),
3840 MailboxField('received_representing', field_uri='message:ReceivedRepresenting', is_read_only=True),
3941 # Placeholder for ReminderMessageData
40 ]
42 )
4143 FIELDS = Item.FIELDS + LOCAL_FIELDS
4244
4345 __slots__ = tuple(f.name for f in LOCAL_FIELDS)
4446
47 @require_account
4548 def send(self, save_copy=True, copy_to_folder=None, conflict_resolution=AUTO_RESOLVE,
4649 send_meeting_invitations=SEND_TO_NONE):
4750 # Only sends a message. The message can either be an existing draft stored in EWS or a new message that does
4851 # 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
5156 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)
5758 # The item will be deleted from the original folder
58 self.id, self.changekey = None, None
59 self._id = None
5960 self.folder = copy_to_folder
6061 return None
6162
6263 # New message
6364 if copy_to_folder:
64 if not save_copy:
65 raise AttributeError("'save_copy' must be True when 'copy_to_folder' is set")
6665 # This would better be done via send_and_save() but lets just support it here
6766 self.folder = copy_to_folder
6867 return self.send_and_save(conflict_resolution=conflict_resolution,
6968 send_meeting_invitations=send_meeting_invitations)
7069
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.
7473 self.send_and_save(conflict_resolution=conflict_resolution,
7574 send_meeting_invitations=send_meeting_invitations)
7675 return None
7776
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)
8178 return None
8279
8380 def send_and_save(self, update_fields=None, conflict_resolution=AUTO_RESOLVE,
9188 send_meeting_invitations=send_meeting_invitations
9289 )
9390 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().
9794 self.save(update_fields=update_fields, conflict_resolution=conflict_resolution,
9895 send_meeting_invitations=send_meeting_invitations)
9996 self.send(save_copy=False, conflict_resolution=conflict_resolution,
106103 if res:
107104 raise ValueError('Unexpected response in send-only mode')
108105
106 @require_id
109107 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__)
114108 if to_recipients is None:
115109 if not self.author:
116110 raise ValueError("'to_recipients' must be set when message has no 'author'")
134128 bcc_recipients
135129 ).send()
136130
131 @require_id
137132 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__)
142133 to_recipients = list(self.to_recipients) if self.to_recipients else []
143134 if self.author:
144135 to_recipients.append(self.author)
00 import logging
11
22 from ..fields import TextField, BodyField, DateTimeField, MailboxField
3 from ..properties import Fields
34 from .item import Item
45 from .message import Message
56
1112 MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/postitem
1213 """
1314 ELEMENT_NAME = 'PostItem'
14 LOCAL_FIELDS = Message.LOCAL_FIELDS[6:11] + [
15 LOCAL_FIELDS = Message.LOCAL_FIELDS[6:11] + Fields(
1516 DateTimeField('posted_time', field_uri='postitem:PostedTime', is_read_only=True),
1617 TextField('references', field_uri='message:References'),
1718 MailboxField('sender', field_uri='message:Sender', is_read_only=True, is_read_only_after_send=True),
18 ]
19 )
1920 FIELDS = Item.FIELDS + LOCAL_FIELDS
2021
2122 __slots__ = tuple(f.name for f in LOCAL_FIELDS)
2829 # TODO: Untested and unfinished.
2930 ELEMENT_NAME = 'PostReplyItem'
3031
31 LOCAL_FIELDS = Message.LOCAL_FIELDS + [
32 LOCAL_FIELDS = Message.LOCAL_FIELDS + Fields(
3233 BodyField('new_body', field_uri='NewBodyContent'), # Accepts and returns Body or HTMLBody instances
33 ]
34 )
3435 # FIELDS on this element only has Item fields up to 'culture'
3536 culture_idx = None
3637 for i, field in enumerate(Item.FIELDS):
33 from ..ewsdatetime import UTC_NOW
44 from ..fields import BooleanField, IntegerField, DecimalField, TextField, ChoiceField, DateTimeField, Choice, \
55 CharField, TextListField
6 from ..properties import Fields
67 from .item import Item
78
89 log = logging.getLogger(__name__)
1516 ELEMENT_NAME = 'Task'
1617 NOT_STARTED = 'NotStarted'
1718 COMPLETED = 'Completed'
18 LOCAL_FIELDS = [
19 LOCAL_FIELDS = Fields(
1920 IntegerField('actual_work', field_uri='task:ActualWork', min=0),
2021 DateTimeField('assigned_time', field_uri='task:AssignedTime', is_read_only=True),
2122 TextField('billing_information', field_uri='task:BillingInformation'),
4445 }, is_required=True, is_searchable=False, default=NOT_STARTED),
4546 CharField('status_description', field_uri='task:StatusDescription', is_read_only=True),
4647 IntegerField('total_work', field_uri='task:TotalWork', min=0),
47 ]
48 )
4849 FIELDS = Item.FIELDS + LOCAL_FIELDS
4950
5051 __slots__ = tuple(f.name for f in LOCAL_FIELDS)
99 from .fields import SubField, TextField, EmailAddressField, ChoiceField, DateTimeField, EWSElementField, MailboxField, \
1010 Choice, BooleanField, IdField, ExtendedPropertyField, IntegerField, TimeField, EnumField, CharField, EmailField, \
1111 EWSElementListField, EnumListField, FreeBusyStatusField, UnknownEntriesField, MessageField, RecipientAddressField, \
12 WEEKDAY_NAMES, FieldPath, Field
12 RoutingTypeField, WEEKDAY_NAMES, FieldPath, Field
1313 from .util import get_xml_attr, create_element, set_xml_value, value_to_xml_text, MNS, TNS
1414 from .version import Version, EXCHANGE_2013
1515
2222
2323 class InvalidFieldForVersion(ValueError):
2424 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]
2571
2672
2773 class Body(str):
97143 class EWSElement(metaclass=abc.ABCMeta):
98144 """Base class for all XML element implementations"""
99145 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
101147 NAMESPACE = TNS # The XML tag namespace. Either TNS or MNS
102148
103149 _fields_lock = Lock()
134180 # Avoid silently accepting spelling errors to field names that are not set via __init__. We need to be able to
135181 # set values for predefined and registered fields, whatever non-field attributes this class defines, and
136182 # 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)
140185 if key in self._slots_keys():
141186 return super().__setattr__(key, value)
142187 if hasattr(self, key):
162207 # Clears an XML element to reduce memory consumption
163208 elem.clear()
164209 # 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)
166214
167215 @classmethod
168216 def from_xml(cls, elem, account):
225273
226274 @classmethod
227275 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__))
232280
233281 @classmethod
234282 def validate_field(cls, field, version):
255303 def add_field(cls, field, insert_after):
256304 """Insert a new field at the preferred place in the tuple and update the slots cache"""
257305 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
259307 # This class may not have its own FIELDS attribute. Make sure not to edit an attribute belonging to a parent
260308 # class.
261 cls.FIELDS = list(cls.FIELDS)
309 cls.FIELDS = cls.FIELDS.copy()
262310 cls.FIELDS.insert(idx, field)
263311
264312 @classmethod
267315 with cls._fields_lock:
268316 # This class may not have its own FIELDS attribute. Make sure not to edit an attribute belonging to a parent
269317 # class.
270 cls.FIELDS = list(cls.FIELDS)
318 cls.FIELDS = cls.FIELDS.copy()
271319 cls.FIELDS.remove(field)
272320
273321 def __eq__(self, other):
302350 """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/internetmessageheader"""
303351 ELEMENT_NAME = 'InternetMessageHeader'
304352
305 FIELDS = [
353 FIELDS = Fields(
306354 TextField('name', field_uri='HeaderName', is_attribute=True),
307355 SubField('value'),
308 ]
356 )
309357
310358 __slots__ = tuple(f.name for f in FIELDS)
311359
319367
320368 ID_ATTR = 'Id'
321369 CHANGEKEY_ATTR = 'ChangeKey'
322 FIELDS = [
370 FIELDS = Fields(
323371 IdField('id', field_uri=ID_ATTR, is_required=True),
324372 IdField('changekey', field_uri=CHANGEKEY_ATTR, is_required=False),
325 ]
373 )
326374
327375 __slots__ = tuple(f.name for f in FIELDS)
328376
348396
349397 ID_ATTR = 'RootItemId'
350398 CHANGEKEY_ATTR = 'RootItemChangeKey'
351 FIELDS = [
399 FIELDS = Fields(
352400 IdField('id', field_uri=ID_ATTR, is_required=True),
353401 IdField('changekey', field_uri=CHANGEKEY_ATTR, is_required=True),
354 ]
402 )
355403
356404 __slots__ = tuple()
357405
368416 """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/conversationid"""
369417 ELEMENT_NAME = 'ConversationId'
370418
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
377420 __slots__ = tuple()
378421
379422
411454 __slots__ = tuple()
412455
413456
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
414486 class Mailbox(EWSElement):
415487 """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/mailbox"""
416488 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(
419497 TextField('name', field_uri='Name'),
420498 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),
426501 EWSElementField('item_id', value_cls=ItemId, is_read_only=True),
427 ]
502 )
428503
429504 __slots__ = tuple(f.name for f in FIELDS)
430505
431506 def clean(self, version=None):
432507 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
435512 # 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)
437514
438515 def __hash__(self):
439516 # Exchange may add 'mailbox_type' and 'name' on insert. We're satisfied if the item_id or email address matches.
476553 MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/mailbox-availability
477554 """
478555 ELEMENT_NAME = 'Mailbox'
479 FIELDS = [
556 FIELDS = Fields(
480557 TextField('name', field_uri='Name'),
481558 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 )
484561
485562 __slots__ = tuple(f.name for f in FIELDS)
486563
511588 ELEMENT_NAME = 'MailboxData'
512589 ATTENDEE_TYPES = {'Optional', 'Organizer', 'Required', 'Resource', 'Room'}
513590
514 FIELDS = [
591 FIELDS = Fields(
515592 EmailField('email'),
516593 ChoiceField('attendee_type', field_uri='AttendeeType', choices={Choice(c) for c in ATTENDEE_TYPES}),
517594 BooleanField('exclude_conflicts', field_uri='ExcludeConflicts'),
518 ]
595 )
519596
520597 __slots__ = tuple(f.name for f in FIELDS)
521598
524601 """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/distinguishedfolderid"""
525602 ELEMENT_NAME = 'DistinguishedFolderId'
526603
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(
530605 MailboxField('mailbox'),
531 ]
606 )
607 FIELDS = ItemId.FIELDS + LOCAL_FIELDS
532608
533609 __slots__ = ('mailbox',)
534610
543619 class TimeWindow(EWSElement):
544620 """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/timewindow"""
545621 ELEMENT_NAME = 'TimeWindow'
546 FIELDS = [
622 FIELDS = Fields(
547623 DateTimeField('start', field_uri='StartTime', is_required=True),
548624 DateTimeField('end', field_uri='EndTime', is_required=True),
549 ]
625 )
550626
551627 __slots__ = tuple(f.name for f in FIELDS)
552628
555631 """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/freebusyviewoptions"""
556632 ELEMENT_NAME = 'FreeBusyViewOptions'
557633 REQUESTED_VIEWS = {'MergedOnly', 'FreeBusy', 'FreeBusyMerged', 'Detailed', 'DetailedMerged'}
558 FIELDS = [
634 FIELDS = Fields(
559635 EWSElementField('time_window', value_cls=TimeWindow, is_required=True),
560636 # Interval value is in minutes
561637 IntegerField('merged_free_busy_interval', field_uri='MergedFreeBusyIntervalInMinutes', min=6, max=1440,
562638 default=30, is_required=True),
563639 ChoiceField('requested_view', field_uri='RequestedView', choices={Choice(c) for c in REQUESTED_VIEWS},
564640 is_required=True), # Choice('None') is also valid, but only for responses
565 ]
641 )
566642
567643 __slots__ = tuple(f.name for f in FIELDS)
568644
573649
574650 RESPONSE_TYPES = {'Unknown', 'Organizer', 'Tentative', 'Accept', 'Decline', 'NoResponseReceived'}
575651
576 FIELDS = [
652 FIELDS = Fields(
577653 MailboxField('mailbox', is_required=True),
578654 ChoiceField('response_type', field_uri='ResponseType', choices={Choice(c) for c in RESPONSE_TYPES},
579655 default='Unknown'),
580656 DateTimeField('last_response_time', field_uri='LastResponseTime'),
581 ]
657 )
582658
583659 __slots__ = tuple(f.name for f in FIELDS)
584660
589665
590666 class TimeZoneTransition(EWSElement):
591667 """Base class for StandardTime and DaylightTime classes"""
592 FIELDS = [
668 FIELDS = Fields(
593669 IntegerField('bias', field_uri='Bias', is_required=True), # Offset from the default bias, in minutes
594670 TimeField('time', field_uri='Time', is_required=True),
595671 IntegerField('occurrence', field_uri='DayOrder', is_required=True), # n'th occurrence of weekday in iso_month
596672 IntegerField('iso_month', field_uri='Month', is_required=True),
597673 EnumField('weekday', field_uri='DayOfWeek', enum=WEEKDAY_NAMES, is_required=True),
598674 # 'Year' is not implemented yet
599 ]
675 )
600676
601677 __slots__ = tuple(f.name for f in FIELDS)
602678
633709 """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/timezone-availability"""
634710 ELEMENT_NAME = 'TimeZone'
635711
636 FIELDS = [
712 FIELDS = Fields(
637713 IntegerField('bias', field_uri='Bias', is_required=True), # Standard (non-DST) offset from UTC, in minutes
638714 EWSElementField('standard_time', value_cls=StandardTime),
639715 EWSElementField('daylight_time', value_cls=DaylightTime),
640 ]
716 )
641717
642718 __slots__ = tuple(f.name for f in FIELDS)
643719
769845 ELEMENT_NAME = 'CalendarView'
770846 NAMESPACE = MNS
771847
772 FIELDS = [
848 FIELDS = Fields(
773849 DateTimeField('start', field_uri='StartDate', is_required=True, is_attribute=True),
774850 DateTimeField('end', field_uri='EndDate', is_required=True, is_attribute=True),
775851 IntegerField('max_items', field_uri='MaxEntriesReturned', min=1, is_attribute=True),
776 ]
852 )
777853
778854 __slots__ = tuple(f.name for f in FIELDS)
779855
786862 class CalendarEventDetails(EWSElement):
787863 """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/calendareventdetails"""
788864 ELEMENT_NAME = 'CalendarEventDetails'
789 FIELDS = [
865 FIELDS = Fields(
790866 CharField('id', field_uri='ID'),
791867 CharField('subject', field_uri='Subject'),
792868 CharField('location', field_uri='Location'),
795871 BooleanField('is_exception', field_uri='IsException'),
796872 BooleanField('is_reminder_set', field_uri='IsReminderSet'),
797873 BooleanField('is_private', field_uri='IsPrivate'),
798 ]
874 )
799875
800876 __slots__ = tuple(f.name for f in FIELDS)
801877
803879 class CalendarEvent(EWSElement):
804880 """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/calendarevent"""
805881 ELEMENT_NAME = 'CalendarEvent'
806 FIELDS = [
882 FIELDS = Fields(
807883 DateTimeField('start', field_uri='StartTime'),
808884 DateTimeField('end', field_uri='EndTime'),
809885 FreeBusyStatusField('busy_type', field_uri='BusyType', is_required=True, default='Busy'),
810886 EWSElementField('details', field_uri='CalendarEventDetails', value_cls=CalendarEventDetails),
811 ]
887 )
812888
813889 __slots__ = tuple(f.name for f in FIELDS)
814890
816892 class WorkingPeriod(EWSElement):
817893 """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/workingperiod"""
818894 ELEMENT_NAME = 'WorkingPeriod'
819 FIELDS = [
895 FIELDS = Fields(
820896 EnumListField('weekdays', field_uri='DayOfWeek', enum=WEEKDAY_NAMES, is_required=True),
821897 TimeField('start', field_uri='StartTimeInMinutes', is_required=True),
822898 TimeField('end', field_uri='EndTimeInMinutes', is_required=True),
823 ]
899 )
824900
825901 __slots__ = tuple(f.name for f in FIELDS)
826902
829905 """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/freebusyview"""
830906 ELEMENT_NAME = 'FreeBusyView'
831907 NAMESPACE = MNS
832 FIELDS = [
908 FIELDS = Fields(
833909 ChoiceField('view_type', field_uri='FreeBusyViewType', choices={
834910 Choice('None'), Choice('MergedOnly'), Choice('FreeBusy'), Choice('FreeBusyMerged'), Choice('Detailed'),
835911 Choice('DetailedMerged'),
842918 # TimeZone is also inside the WorkingHours element. It contains information about the timezone which the
843919 # account is located in.
844920 EWSElementField('working_hours_timezone', field_uri='TimeZone', value_cls=TimeZone),
845 ]
921 )
846922
847923 __slots__ = tuple(f.name for f in FIELDS)
848924
900976 """
901977 ELEMENT_NAME = 'Member'
902978
903 FIELDS = [
979 FIELDS = Fields(
904980 MailboxField('mailbox', is_required=True),
905981 ChoiceField('status', field_uri='Status', choices={
906982 Choice('Unrecognized'), Choice('Normal'), Choice('Demoted')
907983 }, default='Normal'),
908 ]
984 )
909985
910986 __slots__ = tuple(f.name for f in FIELDS)
911987
918994 """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/userid"""
919995 ELEMENT_NAME = 'UserId'
920996
921 FIELDS = [
997 FIELDS = Fields(
922998 CharField('sid', field_uri='SID'),
923999 EmailAddressField('primary_smtp_address', field_uri='PrimarySmtpAddress'),
9241000 CharField('display_name', field_uri='DisplayName'),
9261002 Choice('Default'), Choice('Anonymous')
9271003 }),
9281004 CharField('external_user_identity', field_uri='ExternalUserIdentity'),
929 ]
1005 )
9301006
9311007 __slots__ = tuple(f.name for f in FIELDS)
9321008
9361012 ELEMENT_NAME = 'Permission'
9371013
9381014 PERMISSION_ENUM = {Choice('None'), Choice('Owned'), Choice('All')}
939 FIELDS = [
1015 FIELDS = Fields(
9401016 ChoiceField('permission_level', field_uri='PermissionLevel', choices={
9411017 Choice('None'), Choice('Owner'), Choice('PublishingEditor'), Choice('Editor'), Choice('PublishingAuthor'),
9421018 Choice('Author'), Choice('NoneditingAuthor'), Choice('Reviewer'), Choice('Contributor'), Choice('Custom')
9521028 Choice('None'), Choice('FullDetails')
9531029 }, default='None'),
9541030 EWSElementField('user_id', field_uri='UserId', value_cls=UserId, is_required=True)
955 ]
1031 )
9561032
9571033 __slots__ = tuple(f.name for f in FIELDS)
9581034
9621038 ELEMENT_NAME = 'Permission'
9631039
9641040 PERMISSION_ENUM = {Choice('None'), Choice('Owned'), Choice('All')}
965 FIELDS = [
1041 FIELDS = Fields(
9661042 ChoiceField('calendar_permission_level', field_uri='CalendarPermissionLevel', choices={
9671043 Choice('None'), Choice('Owner'), Choice('PublishingEditor'), Choice('Editor'), Choice('PublishingAuthor'),
9681044 Choice('Author'), Choice('NoneditingAuthor'), Choice('Reviewer'), Choice('Contributor'),
9691045 Choice('FreeBusyTimeOnly'), Choice('FreeBusyTimeAndSubjectAndLocation'), Choice('Custom')
9701046 }, default='None'),
971 ] + Permission.FIELDS[1:]
1047 ) + Permission.FIELDS[1:]
9721048
9731049 __slots__ = tuple(f.name for f in FIELDS)
9741050
9831059 # For simplicity, we implement the two distinct but equally names elements as one class.
9841060 ELEMENT_NAME = 'PermissionSet'
9851061
986 FIELDS = [
1062 FIELDS = Fields(
9871063 EWSElementListField('permissions', field_uri='Permissions', value_cls=Permission),
9881064 EWSElementListField('calendar_permissions', field_uri='CalendarPermissions', value_cls=CalendarPermission),
9891065 UnknownEntriesField('unknown_entries', field_uri='UnknownEntries'),
990 ]
1066 )
9911067
9921068 __slots__ = tuple(f.name for f in FIELDS)
9931069
9961072 """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/effectiverights"""
9971073 ELEMENT_NAME = 'EffectiveRights'
9981074
999 FIELDS = [
1075 FIELDS = Fields(
10001076 BooleanField('create_associated', field_uri='CreateAssociated', default=False),
10011077 BooleanField('create_contents', field_uri='CreateContents', default=False),
10021078 BooleanField('create_hierarchy', field_uri='CreateHierarchy', default=False),
10041080 BooleanField('modify', field_uri='Modify', default=False),
10051081 BooleanField('read', field_uri='Read', default=False),
10061082 BooleanField('view_private_items', field_uri='ViewPrivateItems', default=False),
1007 ]
1083 )
10081084
10091085 __slots__ = tuple(f.name for f in FIELDS)
10101086
10171093 PERMISSION_LEVEL_CHOICES = {
10181094 Choice('None'), Choice('Editor'), Choice('Reviewer'), Choice('Author'), Choice('Custom'),
10191095 }
1020 FIELDS = [
1096 FIELDS = Fields(
10211097 ChoiceField('calendar_folder_permission_level', field_uri='CalendarFolderPermissionLevel',
10221098 choices=PERMISSION_LEVEL_CHOICES, default='None'),
10231099 ChoiceField('tasks_folder_permission_level', field_uri='TasksFolderPermissionLevel',
10301106 choices=PERMISSION_LEVEL_CHOICES, default='None'),
10311107 ChoiceField('journal_folder_permission_level', field_uri='JournalFolderPermissionLevel',
10321108 choices=PERMISSION_LEVEL_CHOICES, default='None'),
1033 ]
1109 )
10341110
10351111 __slots__ = tuple(f.name for f in FIELDS)
10361112
10401116 ELEMENT_NAME = 'DelegateUser'
10411117 NAMESPACE = MNS
10421118
1043 FIELDS = [
1119 FIELDS = Fields(
10441120 EWSElementField('user_id', field_uri='UserId', value_cls=UserId),
10451121 EWSElementField('delegate_permissions', field_uri='DelegatePermissions', value_cls=DelegatePermissions),
10461122 BooleanField('receive_copies_of_meeting_messages', field_uri='ReceiveCopiesOfMeetingMessages', default=False),
10471123 BooleanField('view_private_items', field_uri='ViewPrivateItems', default=False),
1048 ]
1124 )
10491125
10501126 __slots__ = tuple(f.name for f in FIELDS)
10511127
10541130 """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/searchablemailbox"""
10551131 ELEMENT_NAME = 'SearchableMailbox'
10561132
1057 FIELDS = [
1133 FIELDS = Fields(
10581134 CharField('guid', field_uri='Guid'),
10591135 EmailAddressField('primary_smtp_address', field_uri='PrimarySmtpAddress'),
10601136 BooleanField('is_external', field_uri='IsExternalMailbox'),
10621138 CharField('display_name', field_uri='DisplayName'),
10631139 BooleanField('is_membership_group', field_uri='IsMembershipGroup'),
10641140 CharField('reference_id', field_uri='ReferenceId'),
1065 ]
1141 )
10661142
10671143 __slots__ = tuple(f.name for f in FIELDS)
10681144
10691145
10701146 class FailedMailbox(EWSElement):
10711147 """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/failedmailbox"""
1072 FIELDS = [
1148 FIELDS = Fields(
10731149 CharField('mailbox', field_uri='Mailbox'),
10741150 IntegerField('error_code', field_uri='ErrorCode'),
10751151 CharField('error_message', field_uri='ErrorMessage'),
10761152 BooleanField('is_archive', field_uri='IsArchive'),
1077 ]
1153 )
10781154
10791155 __slots__ = tuple(f.name for f in FIELDS)
10801156
10981174 """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/outofoffice"""
10991175 ELEMENT_NAME = 'OutOfOffice'
11001176
1101 FIELDS = [
1177 FIELDS = Fields(
11021178 MessageField('reply_body', field_uri='ReplyBody'),
11031179 DateTimeField('start', field_uri='StartTime', is_required=False),
11041180 DateTimeField('end', field_uri='EndTime', is_required=False),
1105 ]
1181 )
11061182
11071183 __slots__ = tuple(f.name for f in FIELDS)
11081184
11321208 ELEMENT_NAME = 'MailTips'
11331209 NAMESPACE = MNS
11341210
1135 FIELDS = [
1211 FIELDS = Fields(
11361212 RecipientAddressField('recipient_address'),
11371213 ChoiceField('pending_mail_tips', field_uri='PendingMailTips', choices={Choice(c) for c in MAIL_TIPS_TYPES}),
11381214 EWSElementField('out_of_office', field_uri='OutOfOffice', value_cls=OutOfOffice),
11441220 BooleanField('delivery_restricted', field_uri='DeliveryRestricted'),
11451221 BooleanField('is_moderated', field_uri='IsModerated'),
11461222 BooleanField('invalid_recipient', field_uri='InvalidRecipient'),
1147 ]
1223 )
11481224
11491225 __slots__ = tuple(f.name for f in FIELDS)
11501226
11621238 class AlternateId(EWSElement):
11631239 """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/alternateid"""
11641240 ELEMENT_NAME = 'AlternateId'
1165 FIELDS = [
1241 FIELDS = Fields(
11661242 CharField('id', field_uri='Id', is_required=True, is_attribute=True),
11671243 ChoiceField('format', field_uri='Format', is_required=True, is_attribute=True,
11681244 choices={Choice(c) for c in ID_FORMATS}),
11691245 EmailAddressField('mailbox', field_uri='Mailbox', is_required=True, is_attribute=True),
11701246 BooleanField('is_archive', field_uri='IsArchive', is_required=False, is_attribute=True),
1171 ]
1247 )
11721248
11731249 __slots__ = tuple(f.name for f in FIELDS)
11741250
11811257 class AlternatePublicFolderId(EWSElement):
11821258 """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/alternatepublicfolderid"""
11831259 ELEMENT_NAME = 'AlternatePublicFolderId'
1184 FIELDS = [
1260 FIELDS = Fields(
11851261 CharField('folder_id', field_uri='FolderId', is_required=True, is_attribute=True),
11861262 ChoiceField('format', field_uri='Format', is_required=True, is_attribute=True,
11871263 choices={Choice(c) for c in ID_FORMATS}),
1188 ]
1264 )
11891265
11901266 __slots__ = tuple(f.name for f in FIELDS)
11911267
11951271 https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/alternatepublicfolderitemid
11961272 """
11971273 ELEMENT_NAME = 'AlternatePublicFolderItemId'
1198 FIELDS = [
1274 FIELDS = Fields(
11991275 CharField('folder_id', field_uri='FolderId', is_required=True, is_attribute=True),
12001276 ChoiceField('format', field_uri='Format', is_required=True, is_attribute=True,
12011277 choices={Choice(c) for c in ID_FORMATS}),
12021278 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 )
12041326
12051327 __slots__ = tuple(f.name for f in FIELDS)
12061328
12071329
12081330 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
12181372
12191373 @classmethod
12201374 def id_from_xml(cls, elem):
1375 # This method must be reasonably fast
12211376 id_elem = elem.find(cls.ID_ELEMENT_CLS.response_tag())
12221377 if id_elem is None:
12231378 return None, None
12241379 return id_elem.get(cls.ID_ELEMENT_CLS.ID_ATTR), id_elem.get(cls.ID_ELEMENT_CLS.CHANGEKEY_ATTR)
12251380
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()
12351383
12361384 def __eq__(self, other):
12371385 if isinstance(other, tuple):
55 """
66 import datetime
77 import logging
8 from multiprocessing.pool import ThreadPool
98 import os
109 from threading import Lock
1110 from queue import LifoQueue, Empty, Full
1211
13 from cached_property import threaded_cached_property
1412 import requests.adapters
1513 import requests.sessions
1614 import requests.utils
2220 from .properties import FreeBusyViewOptions, MailboxData, TimeWindow, TimeZone
2321 from .services import GetServerTimeZones, GetRoomLists, GetRooms, ResolveNames, GetUserAvailability, \
2422 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
2624 from .version import Version, API_VERSIONS
2725
2826 log = logging.getLogger(__name__)
3735
3836 # The maximum number of sessions (== TCP connections, see below) we will open to this service endpoint. Keep this
3937 # 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
4241 # We want only 1 TCP connection per Session object. We may have lots of different credentials hitting the server and
4342 # each credential needs its own session (NTLM auth will only send credentials once and then secure the connection,
4443 # so a connection can only handle requests for one credential). Having multiple connections ser Session could
4544 # quickly exhaust the maximum number of concurrent connections the Exchange server allows from one client.
4645 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
4749 # Timeout for HTTP requests
4850 TIMEOUT = 120
4951
143145 def get_auth_type(self):
144146 # Autodetect and return authentication type
145147 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
154148
155149 def _create_session_pool(self):
156150 # Create a pool to reuse sessions containing connections to the server
187181 log.debug('Server %s: Waiting for session', self.server)
188182 session = self._session_pool.get(timeout=_timeout)
189183 log.debug('Server %s: Got session %s', self.server, session.session_id)
184 session.usage_count += 1
190185 return session
191186 except Empty:
192187 # This is normal when we have many worker threads starving for available sessions
195190 def release_session(self, session):
196191 # This should never fail, as we don't have more sessions than the queue contains
197192 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)
198196 try:
199197 self._session_pool.put(session, block=False)
200198 except Full:
220218 # application didn't provide an OAuth client secret, so we can't
221219 # handle token refreshing for it.
222220 with self.credentials.lock:
223 if hash(self.credentials) == session.credentials_hash:
221 if self.credentials.sig() == session.credentials_sig:
224222 # Credentials have not been refreshed by another thread:
225223 # they're the same as the session was created with. If
226224 # this isn't the case, we can just go ahead with a new
229227 return self.renew_session(session)
230228
231229 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()
240247 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
256256 # Add some extra info
257257 session.session_id = sum(map(ord, str(os.urandom(100)))) # Used for debugging messages in services
258 session.usage_count = 0
258259 session.protocol = self
259260 log.debug('Server %s: Created session %s', self.server, session.session_id)
260261 return session
328329 else:
329330 session = requests.sessions.Session()
330331 session.headers.update(DEFAULT_HEADERS)
331 session.headers["User-Agent"] = cls.get_useragent()
332 session.headers['User-Agent'] = cls.USERAGENT
332333 session.mount('http://', adapter=cls.get_adapter())
333334 session.mount('https://', adapter=cls.get_adapter())
334335 return session
418419 self.config.version = Version.guess(self, api_version_hint=self._api_version_hint)
419420 return self.config.version
420421
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
441422 def get_timezones(self, timezones=None, return_full_timezone_data=False):
442423 """ Get timezone definitions from the server
443424
452433 def get_free_busy_info(self, accounts, start, end, merged_free_busy_interval=30, requested_view='DetailedMerged'):
453434 """ Returns free/busy information for a list of accounts
454435
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.
457439 :param start: The start datetime of the request
458440 :param end: The end datetime of the request
459441 :param merged_free_busy_interval: The interval, in minutes, of merged free/busy information
463445 """
464446 from .account import Account
465447 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)
468450 if attendee_type not in MailboxData.ATTENDEE_TYPES:
469451 raise ValueError("'accounts' item %r must be one of %s" % (attendee_type, MailboxData.ATTENDEE_TYPES))
470452 if not isinstance(exclude_conflicts, bool):
488470 for_year=start.year
489471 ),
490472 mailbox_data=[MailboxData(
491 email=account.primary_smtp_address,
473 email=account.primary_smtp_address if isinstance(account, Account) else account,
492474 attendee_type=attendee_type,
493475 exclude_conflicts=exclude_conflicts
494476 ) for account, attendee_type, exclude_conflicts in accounts],
515497 :param shape:
516498 :return: A list of Mailbox items or, if return_full_contact_data is True, tuples of (Mailbox, Contact) items
517499 """
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))
525500 return list(ResolveNames(protocol=self).call(
526501 unresolved_entries=names, return_full_contact_data=return_full_contact_data, search_scope=search_scope,
527502 contact_data_shape=shape,
558533 :param destination_format: A string
559534 :return: a generator of AlternateId, AlternatePublicFolderId or AlternatePublicFolderItemId instances
560535 """
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
564537 cls_map = {cls.response_tag(): cls for cls in (
565538 AlternateId, AlternatePublicFolderId, AlternatePublicFolderItemId
566539 )}
610583 super().cert_verify(conn=conn, url=url, verify=False, cert=cert)
611584
612585
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
613595 class RetryPolicy:
614596 """Stores retry logic used when faced with errors from the server"""
615597 @property
624606
625607 @back_off_until.setter
626608 def back_off_until(self, value):
609 raise NotImplementedError()
610
611 def back_off(self, seconds):
627612 raise NotImplementedError()
628613
629614
131131 raise InvalidField("Unknown field path %r on folders %s" % (field_path, self.folder_collection.folders))
132132
133133 @property
134 def _item_id_field(self):
134 def _id_field(self):
135135 return self._get_field_path('id')
136136
137137 @property
287287 yield val
288288 self._cache = _cache
289289
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
290297 def __len__(self):
291298 if self.is_cached:
292299 return len(self._cache)
293300 # This queryset has no cache yet. Call the optimized counting implementation
294301 return self.count()
302 """
295303
296304 def __getitem__(self, idx_or_slice):
297305 # Support indexing and slicing. This is non-greedy when possible (slicing start, stop and step are not negative,
349357 # _query() will return an iterator of (id, changekey) tuples
350358 if self._changekey_field not in self.only_fields:
351359 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:
353361 transform_func = changekey_only_func
354362 else:
355363 transform_func = id_and_changekey_func
543551 # We allow calling get(id=..., changekey=...) to get a single item, but only if exactly these two
544552 # kwargs are present.
545553 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)
547555 changekey = self._changekey_field.field.clean(kwargs.get('changekey'), version=account.version)
548556 items = list(account.fetch(ids=[(item_id, changekey)], only_fields=self.only_fields))
549557 else:
666674 def __str__(self):
667675 fmt_args = [('q', str(self.q)), ('folders', '[%s]' % ', '.join(str(f) for f in self.folder_collection.folders))]
668676 if self.is_cached:
669 fmt_args.append(('len', str(len(self))))
677 fmt_args.append(('len', str(len(self._cache))))
670678 return self.__class__.__name__ + '(%s)' % ', '.join('%s=%s' % (k, v) for k, v in fmt_args)
671679
672680
673681 def _get_value_or_default(item, field_order):
674682 # Python can only sort values when <, > and = are implemented for the two types. Try as best we can to sort
675683 # 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()
679688 if isinstance(item, Exception):
680 return field_order.field_path.field.default
689 return default
681690 val = field_order.field_path.get_value(item)
682691 if val is None:
683 return field_order.field_path.field.default
692 return default
684693 return val
685694
686695
00 import logging
11
22 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
55
66 log = logging.getLogger(__name__)
77
2828 """
2929 ELEMENT_NAME = 'AbsoluteYearlyRecurrence'
3030
31 FIELDS = [
31 FIELDS = Fields(
3232 # The day of month of an occurrence, in range 1 -> 31. If a particular month has less days than the day_of_month
3333 # value, the last day in the month is assumed
3434 IntegerField('day_of_month', field_uri='DayOfMonth', min=1, max=31, is_required=True),
3535 # The month of the year, from 1 - 12
3636 EnumField('month', field_uri='Month', enum=MONTHS, is_required=True),
37 ]
37 )
3838
3939 __slots__ = tuple(f.name for f in FIELDS)
4040
4747 """
4848 ELEMENT_NAME = 'RelativeYearlyRecurrence'
4949
50 FIELDS = [
50 FIELDS = Fields(
5151 # The weekday of the occurrence, as a valid ISO 8601 weekday number in range 1 -> 7 (1 being Monday).
5252 # Alternatively, the weekday can be one of the DAY (or 8), WEEK_DAY (or 9) or WEEKEND_DAY (or 10) consts which
5353 # is interpreted as the first day, weekday, or weekend day in the month, respectively.
5757 EnumField('week_number', field_uri='DayOfWeekIndex', enum=WEEK_NUMBERS, is_required=True),
5858 # The month of the year, from 1 - 12
5959 EnumField('month', field_uri='Month', enum=MONTHS, is_required=True),
60 ]
60 )
6161
6262 __slots__ = tuple(f.name for f in FIELDS)
6363
7474 """
7575 ELEMENT_NAME = 'AbsoluteMonthlyRecurrence'
7676
77 FIELDS = [
77 FIELDS = Fields(
7878 # Interval, in months, in range 1 -> 99
7979 IntegerField('interval', field_uri='Interval', min=1, max=99, is_required=True),
8080 # The day of month of an occurrence, in range 1 -> 31. If a particular month has less days than the day_of_month
8181 # value, the last day in the month is assumed
8282 IntegerField('day_of_month', field_uri='DayOfMonth', min=1, max=31, is_required=True),
83 ]
83 )
8484
8585 __slots__ = tuple(f.name for f in FIELDS)
8686
9393 """
9494 ELEMENT_NAME = 'RelativeMonthlyRecurrence'
9595
96 FIELDS = [
96 FIELDS = Fields(
9797 # Interval, in months, in range 1 -> 99
9898 IntegerField('interval', field_uri='Interval', min=1, max=99, is_required=True),
9999 # The weekday of the occurrence, as a valid ISO 8601 weekday number in range 1 -> 7 (1 being Monday).
103103 # Week number of the month, in range 1 -> 5. If 5 is specified, this assumes the last week of the month for
104104 # months that have only 4 weeks.
105105 EnumField('week_number', field_uri='DayOfWeekIndex', enum=WEEK_NUMBERS, is_required=True),
106 ]
106 )
107107
108108 __slots__ = tuple(f.name for f in FIELDS)
109109
120120 """
121121 ELEMENT_NAME = 'WeeklyRecurrence'
122122
123 FIELDS = [
123 FIELDS = Fields(
124124 # Interval, in weeks, in range 1 -> 99
125125 IntegerField('interval', field_uri='Interval', min=1, max=99, is_required=True),
126126 # List of valid ISO 8601 weekdays, as list of numbers in range 1 -> 7 (1 being Monday)
127127 EnumListField('weekdays', field_uri='DaysOfWeek', enum=WEEKDAYS, is_required=True),
128128 # The first day of the week. Defaults to Monday
129129 EnumField('first_day_of_week', field_uri='FirstDayOfWeek', enum=WEEKDAYS, default=1, is_required=True),
130 ]
130 )
131131
132132 __slots__ = tuple(f.name for f in FIELDS)
133133
148148 """
149149 ELEMENT_NAME = 'DailyRecurrence'
150150
151 FIELDS = [
151 FIELDS = Fields(
152152 # Interval, in days, in range 1 -> 999
153153 IntegerField('interval', field_uri='Interval', min=1, max=999, is_required=True),
154 ]
154 )
155155
156156 __slots__ = tuple(f.name for f in FIELDS)
157157
169169 """
170170 ELEMENT_NAME = 'NoEndRecurrence'
171171
172 FIELDS = [
172 FIELDS = Fields(
173173 # Start date, as EWSDate
174174 DateField('start', field_uri='StartDate', is_required=True),
175 ]
175 )
176176
177177 __slots__ = tuple(f.name for f in FIELDS)
178178
182182 """
183183 ELEMENT_NAME = 'EndDateRecurrence'
184184
185 FIELDS = [
185 FIELDS = Fields(
186186 # Start date, as EWSDate
187187 DateField('start', field_uri='StartDate', is_required=True),
188188 # End date, as EWSDate
189189 DateField('end', field_uri='EndDate', is_required=True),
190 ]
190 )
191191
192192 __slots__ = tuple(f.name for f in FIELDS)
193193
196196 """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/numberedrecurrence"""
197197 ELEMENT_NAME = 'NumberedRecurrence'
198198
199 FIELDS = [
199 FIELDS = Fields(
200200 # Start date, as EWSDate
201201 DateField('start', field_uri='StartDate', is_required=True),
202202 # The number of occurrences in this pattern, in range 1 -> 999
203203 IntegerField('number', field_uri='NumberOfOccurrences', min=1, max=999, is_required=True),
204 ]
204 )
205205
206206 __slots__ = tuple(f.name for f in FIELDS)
207207
209209 class Occurrence(IdChangeKeyMixIn):
210210 """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/occurrence"""
211211 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),
214216 # The modified start time of the item, as EWSDateTime
215217 DateTimeField('start', field_uri='Start'),
216218 # The modified end time of the item, as EWSDateTime
217219 DateTimeField('end', field_uri='End'),
218220 # The original start time of the item, as EWSDateTime
219221 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)
224225
225226
226227 # Container elements:
244245 """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/deletedoccurrence"""
245246 ELEMENT_NAME = 'DeletedOccurrence'
246247
247 FIELDS = [
248 FIELDS = Fields(
248249 # The modified start time of the item, as EWSDateTime
249250 DateTimeField('start', field_uri='Start'),
250 ]
251 )
251252
252253 __slots__ = tuple(f.name for f in FIELDS)
253254
262263 """
263264 ELEMENT_NAME = 'Recurrence'
264265
265 FIELDS = [
266 FIELDS = Fields(
266267 EWSElementField('pattern', value_cls=Pattern),
267268 EWSElementField('boundary', value_cls=Boundary),
268 ]
269 )
269270
270271 __slots__ = tuple(f.name for f in FIELDS)
271272
383383 # return None.
384384 from .indexed_properties import SingleFieldIndexedElement
385385 from .extended_properties import ExtendedProperty
386 from .fields import DateTimeBackedDateField
386387 # Don't check self.value just yet. We want to return error messages on the field path first, and then the value.
387388 # This is done in _get_field_path() and _get_clean_value(), respectively.
388389 self._check_integrity()
399400 # We allow a filter shortcut of e.g. email_addresses__contains=EmailAddress(label='Foo', ...) instead of
400401 # email_addresses__Foo_email_address=.... Set FieldPath label now so we can generate the field_uri.
401402 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)
402406 elem.append(field_path.to_xml())
403407 constant = create_element('t:Constant')
404408 if self.op != self.EXISTS:
1414 ErrorCannotDeleteTaskOccurrence, ErrorMimeContentConversionFailed, ErrorRecurrenceHasNoOccurrence, \
1515 ErrorNoPublicFolderReplicaAvailable, MalformedResponseError, ErrorExceededConnectionCount, \
1616 SessionPoolMinSizeReached, ErrorIncorrectSchemaVersion, ErrorInvalidRequest
17 from ..properties import FieldURI, IndexedFieldURI, ExtendedFieldURI, ExceptionFieldURI
1718 from ..transport import wrap, extra_headers
1819 from ..util import chunkify, create_element, add_xml_child, get_xml_attr, to_xml, post_ratelimited, \
1920 xml_to_str, set_xml_value, SOAPNS, TNS, MNS, ENS, ParseError
5758 # @abc.abstractmethod
5859 # def get_payload(self, **kwargs):
5960 # 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]
6078
6179 def _get_elements(self, payload):
6280 while True:
109127 # guessing tango, but then the server may decide that any arbitrary legacy backend server may actually process
110128 # the request for an account. Prepare to handle ErrorInvalidSchemaVersionForMailboxVersion errors and set the
111129 # server version per-account.
130 from ..credentials import IMPERSONATION, OAuth2Credentials
112131 from ..version import API_VERSIONS
132 account_to_impersonate = None
133 timezone = None
134 primary_smtp_address = None
113135 if isinstance(self, EWSAccountService):
114 account = self.account
115136 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
116141 else:
117 account = None
118142 # We may be here due to version guessing in Protocol.version, so we can't use the Protocol.version property
119143 version_hint = self.protocol.config.version
144 if isinstance(self.protocol.credentials, OAuth2Credentials):
145 account_to_impersonate = self.protocol.credentials.identity
120146 api_versions = [version_hint.api_version] + [v for v in API_VERSIONS if v != version_hint.api_version]
121147 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)
123149 r, session = post_ratelimited(
124150 protocol=self.protocol,
125151 session=self.protocol.get_session(),
126152 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 ),
129160 allow_redirects=False,
130161 stream=self.streaming,
131162 )
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)
132167 if self.streaming:
133168 # Let 'requests' decode raw data automatically
134169 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)
138170 try:
139171 header, body = self._get_soap_parts(response=r, **parse_opts)
140172 except ParseError as e:
173 r.close() # Release memory
141174 raise SOAPError('Bad SOAP response: %s' % e)
142175 # The body may contain error messages from Exchange, but we still want to collect version info
143176 if header is not None:
147180 except TransportError as te:
148181 log.debug('Failed to update version info (%s)', te)
149182 try:
150 res = self._get_soap_messages(body=body, **parse_opts)
183 return self._get_soap_messages(body=body, **parse_opts)
151184 except (ErrorInvalidServerVersion, ErrorIncorrectSchemaVersion, ErrorInvalidRequest):
152185 # The guessed server version is wrong. Try the next version
153186 log.debug('API version %s was invalid', api_version)
154187 continue
155188 except ErrorInvalidSchemaVersionForMailboxVersion:
156 if not account:
157 # This should never happen for non-account services
158 raise ValueError("'account' should not be None")
159189 # 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)
161191 continue
162192 except ErrorExceededConnectionCount as e:
163193 # ErrorExceededConnectionCount indicates that the connecting user has too many open TCP connections to
164194 # 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
175201 except (ErrorTooManyObjectsOpened, ErrorTimeoutExpired) as e:
176202 # ErrorTooManyObjectsOpened means there are too many connections to the Exchange database. This is very
177203 # often a symptom of sending too many requests.
187213 # Re-raise as an ErrorServerBusy with a default delay of 5 minutes
188214 raise ErrorServerBusy(msg='Reraised from %s(%s)' % (e.__class__.__name__, e), back_off=300)
189215 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)
198222 raise ErrorInvalidServerVersion('Tried versions %s but all were invalid' % api_versions)
199223
200224 def _handle_backoff(self, e):
338362 except self.ERRORS_TO_CATCH_IN_RESPONSE as e:
339363 return e
340364
341 @classmethod
342 def _get_exception(cls, code, text, msg_xml):
365 @staticmethod
366 def _get_exception(code, text, msg_xml):
343367 if not code:
344368 return TransportError('Empty ResponseCode in ResponseMessage (MessageText: %s, MessageXml: %s)' % (
345369 text, msg_xml))
346370 if msg_xml is not None:
347371 # 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
352377 # If this is an ErrorInternalServerError error, the xml may contain a more specific error code
353378 inner_code, inner_text = None, None
354379 for value_elem in msg_xml.findall('{%s}Value' % TNS):
507532 class EWSPooledMixIn(EWSService):
508533 def _pool_requests(self, payload_func, items, **kwargs):
509534 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)):
543540 yield elem
544541
545542
546 def to_item_id(item, item_cls):
543 def to_item_id(item, item_cls, version):
547544 # Coerce a tuple, dict or object to an 'item_cls' instance. Used to create [Parent][Item|Folder]Id instances from a
548545 # variety of input.
549546 if isinstance(item, item_cls):
547 # Allow any subclass of item_cls, e.g. OccurrenceItemId when ItemId is passed
550548 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)
551553 if isinstance(item, (tuple, list)):
552554 return item_cls(*item)
553555 if isinstance(item, dict):
567569
568570
569571 def create_folder_ids_element(tag, folders, version):
570 from ..folders import BaseFolder, FolderId, DistinguishedFolderId
572 from ..folders import FolderId, DistinguishedFolderId
571573 folder_ids = create_element(tag)
572574 for folder in folders:
573575 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)
576578 set_xml_value(folder_ids, folder, version=version)
577579 if not len(folder_ids):
578580 raise ValueError('"folders" must not be empty')
584586 item_ids = create_element('m:ItemIds')
585587 for item in items:
586588 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)
588590 if not len(item_ids):
589591 raise ValueError('"items" must not be empty')
590592 return item_ids
2020 if self.protocol.version.build < EXCHANGE_2007_SP1:
2121 raise NotImplementedError(
2222 '%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))
2326 return self._pool_requests(payload_func=self.get_payload, **dict(
2427 items=items,
2528 destination_format=destination_format,
1616
1717 def get_payload(self, parent_item, items):
1818 from ..properties import ParentItemId
19 from ..items import BaseItem
1920 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)
2226 attachments = create_element('m:Attachments')
2327 for item in items:
2428 set_xml_value(attachments, item, version=self.account.version)
1717 element_container_name = '{%s}Items' % MNS
1818
1919 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")
2042 return self._pool_requests(payload_func=self.get_payload, **dict(
2143 items=items,
2244 folder=folder,
1616 element_container_name = None # DeleteItem doesn't return a response object, just status in XML attrs
1717
1818 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)
1934 return self._pool_requests(payload_func=self.get_payload, **dict(
2035 items=items,
2136 delete_type=delete_type,
4747 return super()._get_soap_messages(body, **parse_opts)
4848
4949 # 'body' is actually the raw response passed on by '_get_soap_parts'
50 r = body
5051 from ..attachments import FileAttachment
5152 parser = StreamingBase64Parser()
5253 field = FileAttachment.get_field_by_fieldname('_content')
5354 handler = StreamingContentHandler(parser=parser, ns=field.namespace, element_name=field.field_uri)
5455 parser.setContentHandler(handler)
55 return parser.parse(body)
56 return parser.parse(r)
5657
5758 def stream_file_content(self, attachment_id):
5859 # The streaming XML parser can only stream content of one attachment
1414 '%r is only supported for Exchange 2007 SP1 servers and later' % self.SERVICE_NAME)
1515 from ..properties import DLMailbox, DelegateUser # The service expects a Mailbox element in the MNS namespace
1616
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(
2130 mailbox=DLMailbox(email_address=self.account.primary_smtp_address),
31 user_ids=user_ids,
2232 include_permissions=include_permissions,
23 )
24 ):
33 ))
34
35 for elem in res:
2536 if isinstance(elem, Exception):
2637 raise elem
2738 yield DelegateUser.from_xml(elem=elem, account=self.account)
1919
2020 def get_payload(self, persona):
2121 from ..properties import PersonaId
22 version = self.protocol.version
2223 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)
2425 return payload
2526
2627 @classmethod
99 element_container_name = '{%s}Items' % MNS
1010
1111 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)
1215 return self._get_elements(payload=self.get_payload(
1316 items=items,
1417 to_folder=to_folder,
1515
1616 def call(self, unresolved_entries, parent_folders=None, return_full_contact_data=False, search_scope=None,
1717 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))
1925 from ..properties import Mailbox
2026 elements = self._get_elements(payload=self.get_payload(
2127 unresolved_entries=unresolved_entries,
99 element_container_name = None # SendItem doesn't return a response object, just status in XML attrs
1010
1111 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)
1215 return self._get_elements(payload=self.get_payload(items=items, saved_item_folder=saved_item_folder))
1316
1417 def get_payload(self, items, saved_item_folder):
99 SERVICE_NAME = 'SetUserOofSettings'
1010
1111 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))
1619
1720 def get_payload(self, oof_settings, mailbox):
1821 from ..properties import AvailabilityMailbox
6565 from ..folders import BaseFolder, FolderId, DistinguishedFolderId
6666 updatefolder = create_element('m:%s' % self.SERVICE_NAME)
6767 folderchanges = create_element('m:FolderChanges')
68 version = self.account.version
6869 for folder, fieldnames in folders:
69 log.debug('Updating folder %s', folder)
70 log.debug('Updating folder %s fields %s', folder, fieldnames)
7071 folderchange = create_element('t:FolderChange')
7172 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)
7475 updates = create_element('t:Updates')
7576 for elem in self._get_folder_update_elems(folder=folder, fieldnames=fieldnames):
7677 updates.append(elem)
00 from collections import OrderedDict
11 import logging
22
3 from ..ewsdatetime import EWSDate
34 from ..util import create_element, set_xml_value, MNS
45 from ..version import EXCHANGE_2010, EXCHANGE_2013_SP1
5 from .common import EWSAccountService, EWSPooledMixIn
6 from .common import EWSAccountService, EWSPooledMixIn, to_item_id
67
78 log = logging.getLogger(__name__)
89
1617
1718 def call(self, items, conflict_resolution, message_disposition, send_meeting_invitations_or_cancellations,
1819 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')
1938 return self._pool_requests(payload_func=self.get_payload, **dict(
2039 items=items,
2140 conflict_resolution=conflict_resolution,
87106 value = field.clean(getattr(item, field.name), version=self.account.version) # Make sure the value is OK
88107 if item.__class__ == CalendarItem:
89108 # 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)
90112 if self.account.version.build < EXCHANGE_2010:
91113 if field.name in ('start', 'end'):
92114 value = value.astimezone(getattr(item, meeting_tz_field.name))
158180 ])
159181 )
160182 itemchanges = create_element('m:ItemChanges')
183 version = self.account.version
161184 for item, fieldnames in items:
162185 if not fieldnames:
163186 raise ValueError('"fieldnames" must not be empty')
164187 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)
167190 updates = create_element('t:Updates')
168191 for elem in self._get_item_update_elems(item=item, fieldnames=fieldnames):
169192 updates.append(elem)
1212 SERVICE_NAME = 'UploadItems'
1313 element_container_name = '{%s}ItemId' % MNS
1414
15 def call(self, data):
15 def call(self, items):
1616 # _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))
1818
1919 def get_payload(self, items):
2020 """Upload given items to given account
00 from .ewsdatetime import UTC_NOW
11 from .fields import DateTimeField, MessageField, ChoiceField, Choice
2 from .properties import EWSElement, OutOfOffice
2 from .properties import EWSElement, OutOfOffice, Fields
33 from .util import create_element, set_xml_value
44
55
1111 ENABLED = 'Enabled'
1212 SCHEDULED = 'Scheduled'
1313 DISABLED = 'Disabled'
14 FIELDS = [
14 FIELDS = Fields(
1515 ChoiceField('state', field_uri='OofState', is_required=True,
1616 choices={Choice(ENABLED), Choice(SCHEDULED), Choice(DISABLED)}),
1717 ChoiceField('external_audience', field_uri='ExternalAudience',
2020 DateTimeField('end', field_uri='EndTime'),
2121 MessageField('internal_reply', field_uri='InternalReply'),
2222 MessageField('external_reply', field_uri='ExternalReply'),
23 ]
23 )
2424
2525 __slots__ = tuple(f.name for f in FIELDS)
2626
44 import requests_ntlm
55 import requests_oauthlib
66
7 from .credentials import IMPERSONATION
87 from .errors import UnauthorizedError, TransportError
98 from .util import create_element, add_xml_child, xml_to_str, ns_translation, _may_retry_on_error, _back_off_if_needed, \
109 DummyResponse, CONNECTION_ERRORS
1918 GSSAPI = 'gssapi'
2019 SSPI = 'sspi'
2120 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)
2225
2326 AUTH_TYPE_MAP = {
2427 NTLM: requests_ntlm.HttpNtlmAuth,
2528 BASIC: requests.auth.HTTPBasicAuth,
2629 DIGEST: requests.auth.HTTPDigestAuth,
2730 OAUTH2: requests_oauthlib.OAuth2,
31 CBA: None,
2832 NOAUTH: None,
2933 }
3034 try:
4448 DEFAULT_HEADERS = {'Content-Type': 'text/xml; charset=%s' % DEFAULT_ENCODING, 'Accept-Encoding': 'gzip, deflate'}
4549
4650
47 def extra_headers(account):
51 def extra_headers(primary_smtp_address):
4852 """Generate extra HTTP headers
4953 """
50 if account:
54 if primary_smtp_address:
5155 # See
5256 # 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}
5458 return None
5559
5660
57 def wrap(content, api_version, account=None):
61 def wrap(content, api_version, account_to_impersonate=None, timezone=None):
5862 """
5963 Generate the necessary boilerplate XML for a raw SOAP request. The XML is specific to the server version.
6064 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
6174 """
6275 envelope = create_element('s:Envelope', nsmap=ns_translation)
6376 header = create_element('s:Header')
6477 requestserverversion = create_element('t:RequestServerVersion', attrs=dict(Version=api_version))
6578 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:
7397 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))
7599 timezonecontext.append(timezonedefinition)
76100 header.append(timezonecontext)
77101 envelope.append(header)
118142 try:
119143 r = s.post(url=service_endpoint, headers=headers, data=data, allow_redirects=False,
120144 timeout=BaseProtocol.TIMEOUT)
145 r.close() # Release memory
121146 break
122147 except CONNECTION_ERRORS as e:
123148 # Don't retry on TLS errors. They will most likely be persistent.
22 from collections import OrderedDict
33 import datetime
44 from decimal import Decimal
5 from functools import wraps
56 import io
67 import itertools
78 import logging
1011 from threading import get_ident
1112 import time
1213 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
1717 from defusedxml.expatreader import DefusedExpatParser
1818 from defusedxml.sax import _InputSource
1919 import dns.resolver
3030 log = logging.getLogger(__name__)
3131
3232
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):
3454 """Used to wrap lxml ParseError in our own class"""
3555 pass
3656
5979 ('t', TNS),
6080 ])
6181 for item in ns_translation.items():
62 _etree.register_namespace(*item)
82 lxml.etree.register_namespace(*item)
6383
6484
6585 def is_iterable(value, generators_allowed=False):
123143 if xml_declaration and not encoding:
124144 raise ValueError("'xml_declaration' is not supported when 'encoding' is None")
125145 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)
128148
129149
130150 def get_xml_attr(tree, name):
190210 from .version import Version
191211 if isinstance(value, (str, bool, bytes, int, Decimal, datetime.time, EWSDate, EWSDateTime)):
192212 elem.text = value_to_xml_text(value)
193 elif isinstance(value, RestrictedElement):
213 elif isinstance(value, _element_class):
194214 elem.append(value)
195215 elif is_iterable(value, generators_allowed=True):
196216 for v in value:
200220 if not isinstance(version, Version):
201221 raise ValueError("'version' %r must be a Version instance" % version)
202222 elem.append(v.to_xml(version=version))
203 elif isinstance(v, RestrictedElement):
223 elif isinstance(v, _element_class):
204224 elem.append(v)
205225 elif isinstance(v, str):
206226 add_xml_child(elem, 't:String', v)
227247 if ':' in name:
228248 ns, name = name.split(':')
229249 name = '{%s}%s' % (ns_translation[ns], name)
230 elem = RestrictedElement(nsmap=nsmap)
250 elem = _forgiving_parser.makeelement(name, nsmap=nsmap)
231251 if attrs:
232252 # Try hard to keep attribute order, to ensure deterministic output. This simplifies testing.
233253 for k, v in attrs.items():
234254 elem.set(k, v)
235 elem.tag = name
236255 return elem
237256
238257
299318 self.buffer = None
300319 self.element_found = None
301320
302 def parse(self, source):
303 raw_source = source.raw
321 def parse(self, r):
322 raw_source = r.raw
304323 # Like upstream but yields the return value of self.feed()
305324 raw_source = prepare_input_source(raw_source)
306325 self.prepareParser(raw_source)
317336 buffer = file.read(self._bufsize)
318337 # Any remaining data in self.buffer should be padding chars now
319338 self.buffer = None
320 source.close()
339 r.close() # Release memory
321340 self.close()
322341 if not self.element_found:
323342 data = bytes(collected_data)
343362 self.buffer = [remainder] if remainder else []
344363
345364
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__
355371
356372
357373 class BytesGeneratorIO(io.RawIOBase):
409425 stream = io.BytesIO(bytes_content)
410426 else:
411427 stream = BytesGeneratorIO(bytes_content)
412 forgiving_parser = _forgiving_parser.getDefaultParser()
413428 try:
414 return parse(stream, parser=forgiving_parser)
429 res = lxml.etree.parse(stream, parser=_forgiving_parser) # nosec
415430 except AssertionError as e:
416431 raise ParseError(e.args[0], '<not from file>', -1, 0)
417 except _etree.ParseError as e:
432 except lxml.etree.ParseError as e:
418433 if hasattr(e, 'position'):
419434 e.lineno, e.offset = e.position
420435 if not e.lineno:
427442 else:
428443 offending_excerpt = offending_line[max(0, e.offset - 20):e.offset + 20]
429444 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)
431446 except TypeError:
432447 try:
433448 stream.seek(0)
434449 except (IndexError, io.UnsupportedOperation):
435450 pass
436451 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
437461
438462
439463 def is_xml(text):
451475 """A steaming log handler that prettifies log statements containing XML when output is a terminal"""
452476 @staticmethod
453477 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
455479
456480 @classmethod
457481 def prettify_xml(cls, xml_bytes):
458482 # Re-formats an XML document to a consistent style
459 return tostring(
483 return lxml.etree.tostring(
460484 cls.parse_bytes(xml_bytes),
461485 xml_declaration=True,
462486 encoding='utf-8',
504528 super().__init__(*args, **kwargs)
505529
506530 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
508532 for elem in root.iter():
509533 for attr in set(elem.keys()) & {'RootItemId', 'ItemId', 'Id', 'RootItemChangeKey', 'ChangeKey'}:
510534 elem.set(attr, 'DEADBEEF=')
519543
520544
521545 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):
523547 self.status_code = status_code
524548 self.url = url
525549 self.headers = headers
526550 self.content = content
527551 self.text = content.decode('utf-8', errors='ignore')
528552 self.request = DummyRequest(headers=request_headers)
553 self.history = history
529554
530555 def iter_content(self):
531556 return self.content
557
558 def close(self):
559 pass
532560
533561
534562 def get_domain(email):
707735 )
708736 log.debug(log_msg, log_vals)
709737 if _need_new_credentials(response=r):
738 r.close() # Release memory
710739 session = protocol.refresh_credentials(session)
711740 continue
712741 total_wait = time.monotonic() - t_start
713742 if _may_retry_on_error(response=r, retry_policy=protocol.retry_policy, wait=total_wait):
743 r.close() # Release memory
714744 log.info("Session %s thread %s: Connection error on URL %s (code %s). Cool down %s secs",
715745 session.session_id, thread_id, r.url, r.status_code, wait)
716746 protocol.retry_policy.back_off(wait)
718748 wait *= 2 # Increase delay for every retry
719749 continue
720750 if r.status_code in (301, 302):
721 if stream:
722 r.close()
751 r.close() # Release memory
723752 url, redirects = _redirect_or_fail(r, redirects, allow_redirects)
724753 continue
725754 break
737766 log.debug('Got status code %s but trying to parse content anyway', r.status_code)
738767 elif r.status_code != 200:
739768 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
745770 log.debug('Session %s thread %s: Useful response from %s', session.session_id, thread_id, url)
746771 return r, session
747772
818843 raise TransportError('The service account is currently locked out')
819844 if response.status_code == 401 and protocol.retry_policy.fail_fast:
820845 # 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)
822847 if 'TimeoutException' in response.headers:
823848 raise response.headers['TimeoutException']
824849 # This could be anything. Let higher layers handle this. Add full context for better debugging.
257257 api_version_from_server = requested_api_version
258258 else:
259259 # 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)
262262 return cls(build=build, api_version=api_version_from_server)
263263
264264 def __eq__(self, other):
00 """ A dict to translate from pytz location name to Windows timezone name. Translations taken from
11 http://unicode.org/repos/cldr/trunk/common/supplemental/windowsZones.xml """
2 import re
3
24 import requests
35
46 from .util import to_xml
1416 raise ValueError('Unexpected response: %s' % r)
1517 tz_map = {}
1618 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')):
1820 if e.get('territory') == DEFAULT_TERRITORY or location not in tz_map:
1921 # Prefer default territory. This is so MS_TIMEZONE_TO_PYTZ_MAP maps from MS timezone ID back to the
2022 # "preferred" region/location timezone name.
23 if not location:
24 raise ValueError('Expected location')
2125 tz_map[location] = e.get('other'), e.get('territory')
2226 return tz_map
2327
119123 'America/Cuiaba': ('Central Brazilian Standard Time', '001'),
120124 'America/Curacao': ('SA Western Standard Time', 'CW'),
121125 'America/Danmarkshavn': ('UTC', 'GL'),
122 'America/Dawson': ('Pacific Standard Time', 'CA'),
126 'America/Dawson': ('US Mountain Standard Time', 'CA'),
123127 'America/Dawson_Creek': ('US Mountain Standard Time', 'CA'),
124128 'America/Denver': ('Mountain Standard Time', '001'),
125129 'America/Detroit': ('Eastern Standard Time', 'US'),
224228 'America/Toronto': ('Eastern Standard Time', 'CA'),
225229 'America/Tortola': ('SA Western Standard Time', 'VG'),
226230 'America/Vancouver': ('Pacific Standard Time', 'CA'),
227 'America/Whitehorse': ('Pacific Standard Time', 'CA'),
231 'America/Whitehorse': ('US Mountain Standard Time', 'CA'),
228232 'America/Winnipeg': ('Central Standard Time', 'CA'),
229233 'America/Yakutat': ('Alaskan Standard Time', 'US'),
230234 'America/Yellowknife': ('Mountain Standard Time', 'CA'),
4545 'sspi': ['requests_negotiate_sspi'], # Only for Win32 environments
4646 'complete': ['requests_kerberos', 'requests_negotiate_sspi'], # Only for Win32 environments
4747 },
48 packages=find_packages(exclude=('tests',)),
48 packages=find_packages(exclude=('tests', 'tests.*')),
4949 tests_require=['PyYAML', 'requests_mock', 'psutil', 'flake8'],
5050 python_requires=">=3.5",
5151 test_suite='tests',
1919 from exchangelib.fields import BooleanField, IntegerField, DecimalField, TextField, EmailAddressField, URIField, \
2020 ChoiceField, BodyField, DateTimeField, Base64Field, PhoneNumberField, EmailAddressesField, TimeZoneField, \
2121 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
2324 from exchangelib.indexed_properties import EmailAddress, PhysicalAddress, PhoneNumber
2425 from exchangelib.properties import Attendee, Mailbox, PermissionSet, Permission, UserId
2526 from exchangelib.protocol import BaseProtocol, NoVerifyHTTPAdapter, FaultTolerance
2627 from exchangelib.recurrence import Recurrence, DailyPattern
28 from exchangelib.util import DummyResponse
2729
2830 mock_account = namedtuple('mock_account', ('protocol', 'version'))
2931 mock_protocol = namedtuple('mock_protocol', ('version', 'service_endpoint'))
3133
3234
3335 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 )
3939
4040
4141 def mock_session_exception(exc_cls):
4545 return raise_exc
4646
4747
48 class MockResponse:
48 class MockResponse(DummyResponse):
4949 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)
5451
5552
5653 class TimedTestCase(unittest.TestCase):
158155 return get_random_decimal(field.min or 1, field.max or 99)
159156 if isinstance(field, IntegerField):
160157 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()
161162 if isinstance(field, DateTimeField):
162163 return get_random_datetime(tz=self.account.default_timezone)
163164 if isinstance(field, AttachmentField):
8585 item = Message(folder=self.account.inbox, subject='XXX', categories=self.categories).save()
8686 attachment = FileAttachment(name='pickle_me.txt', content=b'')
8787 for o in (
88 Credentials('XXX', 'YYY'),
8988 FaultTolerance(max_wait=3600),
9089 self.account.protocol,
9190 attachment,
107106 self.assertEqual(self.account.mail_tips.recipient_address, self.account.primary_smtp_address)
108107
109108 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
111116 xml = b'''<?xml version="1.0" encoding="utf-8"?>
112117 <soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/"
113118 xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
200205
201206 def _mock2(response, protocol, log_msg, log_vals):
202207 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)
204209 return _orig2(response, protocol, log_msg, log_vals)
205210
206211 exchangelib.util._may_retry_on_error = _mock1
44 from exchangelib.services import GetAttachment
55 from exchangelib.util import chunkify, TNS
66
7 from .test_items import BaseItemTest
7 from .test_items.test_basics import BaseItemTest
88 from .common import get_random_string
99
1010
0 from exchangelib import Credentials
0 import pickle
1
2 from exchangelib import Credentials, OAuth2Credentials, OAuth2AuthorizationCodeCredentials, Identity
13
24 from .common import TimedTestCase
35
1820 self.assertEqual(Credentials('a', 'b').type, Credentials.UPN)
1921 self.assertEqual(Credentials('a@example.com', 'b').type, Credentials.EMAIL)
2022 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))
22 from exchangelib.folders import Inbox
33
44 from .common import get_random_int
5 from .test_items import BaseItemTest
5 from .test_items.test_basics import BaseItemTest
66
77
88 class ExtendedPropertyTest(BaseItemTest):
77 Base64Field, TimeZoneField, ExtendedPropertyField, CharListField, Choice, DateField, EnumField, EnumListField, \
88 CharField
99 from exchangelib.indexed_properties import SingleFieldIndexedElement
10 from exchangelib.properties import Fields
1011 from exchangelib.version import EXCHANGE_2007, EXCHANGE_2010, EXCHANGE_2013
1112 from exchangelib.util import to_xml, TNS
1213
226227 def test_single_field_indexed_element(self):
227228 # A SingleFieldIndexedElement must have only one field defined
228229 class TestField(SingleFieldIndexedElement):
229 FIELDS = [CharField('a'), CharField('b')]
230 FIELDS = Fields(CharField('a'), CharField('b'))
230231
231232 with self.assertRaises(ValueError):
232233 TestField.value_field()
273273 def test_glob(self):
274274 self.assertGreaterEqual(len(list(self.account.root.glob('*'))), 5)
275275 self.assertEqual(len(list(self.account.contacts.glob('GAL*'))), 1)
276 self.assertEqual(len(list(self.account.contacts.glob('gal*'))), 1) # Test case-insensitivity
276277 self.assertGreaterEqual(len(list(self.account.contacts.glob('/'))), 5)
277278 self.assertGreaterEqual(len(list(self.account.contacts.glob('../*'))), 5)
278279 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
-2534
tests/test_items.py less more
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
5050 self.assertEqual(base_p, p)
5151 self.assertEqual(id(base_p), id(p))
5252 self.assertEqual(hash(base_p), hash(p))
53 self.assertEqual(id(base_p.thread_pool), id(p.thread_pool))
5453 self.assertEqual(id(base_p._session_pool), id(p._session_pool))
5554
5655 def test_close(self):
7170 self.assertEqual(len({p.raddr[0] for p in proc.connections() if p.raddr[0] in ip_addresses}), 0)
7271
7372 def test_poolsize(self):
74 self.assertEqual(self.account.protocol.SESSION_POOLSIZE, 4)
73 self.assertEqual(self.account.protocol.SESSION_POOLSIZE, 1)
7574
7675 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
7779 protocol = Protocol(config=Configuration(
7880 service_endpoint='https://example.com/Foo.asmx', credentials=Credentials('A', 'B'),
7981 auth_type=NTLM, version=Version(Build(15, 1)), retry_policy=FailFast()
8082 ))
81 self.assertEqual(protocol._session_pool.qsize(), Protocol.SESSION_POOLSIZE)
83 Protocol.SESSION_POOLSIZE = tmp
84 self.assertEqual(protocol._session_pool.qsize(), 4)
8285 protocol.decrease_poolsize()
8386 self.assertEqual(protocol._session_pool.qsize(), 3)
8487 protocol.decrease_poolsize()
109112 accounts = [(self.account, 'Organizer', False)]
110113
111114 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)
113116 with self.assertRaises(ValueError):
114117 self.account.protocol.get_free_busy_info(accounts=[(self.account, 'XXX', 'XXX')], start=0, end=0)
115118 with self.assertRaises(ValueError):
127130 self.assertIsInstance(view_info.working_hours_timezone, TimeZone)
128131 ms_id = view_info.working_hours_timezone.to_server_timezone(server_timezones, start.year)
129132 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)
130139
131140 def test_get_roomlists(self):
132141 # The test server is not guaranteed to have any room lists which makes this test less useful
431440 self.assertEqual(len(return_ids), len(items))
432441 ids = self.account.calendar.filter(start__lt=end, end__gt=start, categories__contains=self.categories) \
433442 .values_list('id', 'changekey')
434 self.assertEqual(len(ids), len(items))
443 self.assertEqual(ids.count(), len(items))
435444
436445 def test_disable_ssl_verification(self):
437446 # Test that we can make requests when SSL verification is turned off. I don't know how to mock TLS responses
33 import requests_mock
44
55 from exchangelib import DELEGATE, IMPERSONATION
6 from exchangelib.account import Identity
67 from exchangelib.errors import UnauthorizedError
78 from exchangelib.transport import wrap, get_auth_method_from_response, BASIC, NOAUTH, NTLM, DIGEST
89 from exchangelib.util import PrettyXmlHandler, create_element
8485 def test_wrap(self):
8586 # Test payload wrapper with both delegation, impersonation and timezones
8687 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 )
8891 content = create_element('AAA')
8992 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)
9295 self.assertEqual(
9396 PrettyXmlHandler.prettify_xml(wrapped),
9497 b'''<?xml version='1.0' encoding='utf-8'?>
107110 </s:Body>
108111 </s:Envelope>
109112 ''')
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'?>
115130 <s:Envelope
116131 xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"
117132 xmlns:m="http://schemas.microsoft.com/exchange/services/2006/messages"
120135 <t:RequestServerVersion Version="BBB"/>
121136 <t:ExchangeImpersonation>
122137 <t:ConnectingSID>
123 <t:PrimarySmtpAddress>foo@example.com</t:PrimarySmtpAddress>
138 <t:{tag}>{val}</t:{tag}>
124139 </t:ConnectingSID>
125140 </t:ExchangeImpersonation>
126141 <t:TimeZoneContext>
131146 <AAA/>
132147 </s:Body>
133148 </s:Envelope>
134 ''')
149 '''.format(tag=tag, val=val).encode())
7373 'Exchange2013',
7474 to_xml(b'''\
7575 <s:Header>
76 <foo/>
7677 </s:Header>''')
7778 )
7879 with self.assertRaises(TransportError):