pyxecm 1.4__py3-none-any.whl → 1.6__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of pyxecm might be problematic. Click here for more details.

pyxecm/customizer/m365.py CHANGED
@@ -12,8 +12,10 @@ credentials_user: In some cases MS Graph APIs cannot be called via
12
12
  application permissions (client_id, client_secret)
13
13
  but requires a token of a user authenticated
14
14
  with username + password
15
+
15
16
  request_header: Returns the request header for MS Graph API calls
16
17
  request_header_user: Returns the request header used for user specific calls
18
+ do_request: Call an M365 Graph API in a safe way
17
19
  parse_request_response: Parse the REST API responses and convert
18
20
  them to Python dict in a safe way
19
21
  exist_result_item: Check if an dict item is in the response
@@ -30,6 +32,7 @@ update_user: Update selected properties of an M365 user
30
32
  get_user_licenses: Get the assigned license SKUs of a user
31
33
  assign_license_to_user: Add an M365 license to a user (e.g. to use Office 365)
32
34
  get_user_photo: Get the photo of a M365 user
35
+ download_user_photo: Download the M365 user photo and save it to the local file system
33
36
  update_user_photo: Update a user with a profile photo (which must be in local file system)
34
37
 
35
38
  get_groups: Get list all all groups in M365 tenant
@@ -63,6 +66,7 @@ upload_teams_app: Upload a new app package to the catalog of MS Teams apps
63
66
  remove_teams_app: Remove MS Teams App for the app catalog
64
67
  assign_teams_app_to_user: Assign (add) a MS Teams app to a M365 user.
65
68
  upgrade_teams_app_of_user: Upgrade a MS teams app for a user.
69
+ remove_teams_app_from_user: Remove a M365 Teams app from a M365 user.
66
70
  assign_teams_app_to_team: Assign (add) a MS Teams app to a M365 team
67
71
  (so that it afterwards can be added as a Tab in a M365 Teams Channel)
68
72
  upgrade_teams_app_of_team: Upgrade a MS teams app for a specific team.
@@ -74,6 +78,17 @@ add_sensitivity_label: Assign a existing sensitivity label to a user.
74
78
  THIS IS CURRENTLY NOT WORKING!
75
79
  assign_sensitivity_label_to_user: Create a new sensitivity label in M365
76
80
  THIS IS CURRENTLY NOT WORKING!
81
+
82
+ upload_outlook_app: Upload the M365 Outlook Add-In as "Integrated" App to M365 Admin Center. (NOT WORKING)
83
+ get_app_registration: Find an Azure App Registration based on its name
84
+ add_app_registration: Add an Azure App Registration
85
+ update_app_registration: Update an Azure App Registration
86
+
87
+ get_mail: Get email from inbox of a given user and a given sender (from)
88
+ get_mail_body: Get full email body for a given email ID
89
+ extract_url_from_message_body: Parse the email body to extract (a potentially multi-line) URL from the body.
90
+ delete_mail: Delete email from inbox of a given user and a given email ID.
91
+ email_verification: Process email verification
77
92
  """
78
93
 
79
94
  __author__ = "Dr. Marc Diefenbruch"
@@ -89,10 +104,15 @@ import re
89
104
  import time
90
105
  import urllib.parse
91
106
  import zipfile
92
- from urllib.parse import quote
107
+ from datetime import datetime
93
108
 
109
+ from urllib.parse import quote
110
+ from http import HTTPStatus
94
111
  import requests
95
112
 
113
+ from pyxecm.helper.web import HTTP
114
+ from pyxecm.customizer.browser_automation import BrowserAutomation
115
+
96
116
  logger = logging.getLogger("pyxecm.customizer.m365")
97
117
 
98
118
  request_login_headers = {
@@ -100,6 +120,9 @@ request_login_headers = {
100
120
  "Accept": "application/json",
101
121
  }
102
122
 
123
+ REQUEST_TIMEOUT = 60
124
+ REQUEST_RETRY_DELAY = 20
125
+ REQUEST_MAX_RETRIES = 3
103
126
 
104
127
  class M365(object):
105
128
  """Used to automate stettings in Microsoft 365 via the Graph API."""
@@ -107,6 +130,7 @@ class M365(object):
107
130
  _config: dict
108
131
  _access_token = None
109
132
  _user_access_token = None
133
+ _http_object: HTTP | None = None
110
134
 
111
135
  def __init__(
112
136
  self,
@@ -116,6 +140,7 @@ class M365(object):
116
140
  domain: str,
117
141
  sku_id: str,
118
142
  teams_app_name: str,
143
+ teams_app_external_id: str,
119
144
  ):
120
145
  """Initialize the M365 object
121
146
 
@@ -126,6 +151,7 @@ class M365(object):
126
151
  domain (str): M365 domain
127
152
  sku_id (str): License SKU for M365 users
128
153
  teams_app_name (str): name of the Extended ECM app for MS Teams
154
+ teams_app_external_id (str): external ID of the Extended ECM app for MS Teams
129
155
  """
130
156
 
131
157
  m365_config = {}
@@ -137,6 +163,10 @@ class M365(object):
137
163
  m365_config["domain"] = domain
138
164
  m365_config["skuId"] = sku_id
139
165
  m365_config["teamsAppName"] = teams_app_name
166
+ m365_config["teamsAppExternalId"] = (
167
+ teams_app_external_id # this is the external App ID
168
+ )
169
+ m365_config["teamsAppInternalId"] = None # will be set later...
140
170
  m365_config[
141
171
  "authenticationUrl"
142
172
  ] = "https://login.microsoftonline.com/{}/oauth2/v2.0/token".format(tenant_id)
@@ -162,6 +192,7 @@ class M365(object):
162
192
  m365_config["applicationsUrl"] = m365_config["graphUrl"] + "applications"
163
193
 
164
194
  self._config = m365_config
195
+ self._http_object = HTTP()
165
196
 
166
197
  def config(self) -> dict:
167
198
  """Returns the configuration dictionary
@@ -242,6 +273,183 @@ class M365(object):
242
273
 
243
274
  # end method definition
244
275
 
276
+ def do_request(
277
+ self,
278
+ url: str,
279
+ method: str = "GET",
280
+ headers: dict | None = None,
281
+ data: dict | None = None,
282
+ json_data: dict | None = None,
283
+ files: dict | None = None,
284
+ params: dict | None = None,
285
+ timeout: int | None = REQUEST_TIMEOUT,
286
+ show_error: bool = True,
287
+ show_warning: bool = False,
288
+ warning_message: str = "",
289
+ failure_message: str = "",
290
+ success_message: str = "",
291
+ max_retries: int = REQUEST_MAX_RETRIES,
292
+ retry_forever: bool = False,
293
+ parse_request_response: bool = True,
294
+ stream: bool = False,
295
+ ) -> dict | None:
296
+ """Call an M365 Graph API in a safe way
297
+
298
+ Args:
299
+ url (str): URL to send the request to.
300
+ method (str, optional): HTTP method (GET, POST, etc.). Defaults to "GET".
301
+ headers (dict | None, optional): Request Headers. Defaults to None.
302
+ data (dict | None, optional): Request payload. Defaults to None
303
+ files (dict | None, optional): Dictionary of {"name": file-tuple} for multipart encoding upload.
304
+ file-tuple can be a 2-tuple ("filename", fileobj) or a 3-tuple ("filename", fileobj, "content_type")
305
+ params (dict | None, optional): Add key-value pairs to the query string of the URL.
306
+ When you use the params parameter, requests automatically appends
307
+ the key-value pairs to the URL as part of the query string
308
+ timeout (int | None, optional): Timeout for the request in seconds. Defaults to REQUEST_TIMEOUT.
309
+ show_error (bool, optional): Whether or not an error should be logged in case of a failed REST call.
310
+ If False, then only a warning is logged. Defaults to True.
311
+ warning_message (str, optional): Specific warning message. Defaults to "". If not given the error_message will be used.
312
+ failure_message (str, optional): Specific error message. Defaults to "".
313
+ success_message (str, optional): Specific success message. Defaults to "".
314
+ max_retries (int, optional): How many retries on Connection errors? Default is REQUEST_MAX_RETRIES.
315
+ retry_forever (bool, optional): Eventually wait forever - without timeout. Defaults to False.
316
+ parse_request_response (bool, optional): should the response.text be interpreted as json and loaded into a dictionary. True is the default.
317
+ stream (bool, optional): parameter is used to control whether the response content should be immediately downloaded or streamed incrementally
318
+
319
+ Returns:
320
+ dict | None: Response of OTDS REST API or None in case of an error.
321
+ """
322
+
323
+ if headers is None:
324
+ logger.error("Missing request header. Cannot send request to Core Share!")
325
+ return None
326
+
327
+ # In case of an expired session we reauthenticate and
328
+ # try 1 more time. Session expiration should not happen
329
+ # twice in a row:
330
+ retries = 0
331
+
332
+ while True:
333
+ try:
334
+ response = requests.request(
335
+ method=method,
336
+ url=url,
337
+ data=data,
338
+ json=json_data,
339
+ files=files,
340
+ params=params,
341
+ headers=headers,
342
+ timeout=timeout,
343
+ stream=stream,
344
+ )
345
+
346
+ if response.ok:
347
+ if success_message:
348
+ logger.info(success_message)
349
+ if parse_request_response:
350
+ return self.parse_request_response(response)
351
+ else:
352
+ return response
353
+ # Check if Session has expired - then re-authenticate and try once more
354
+ elif response.status_code == 401 and retries == 0:
355
+ logger.debug("Session has expired - try to re-authenticate...")
356
+ self.authenticate(revalidate=True)
357
+ headers = self.request_header()
358
+ retries += 1
359
+ elif (
360
+ response.status_code in [502, 503, 504]
361
+ and retries < REQUEST_MAX_RETRIES
362
+ ):
363
+ logger.warning(
364
+ "M365 Graph API delivered server side error -> %s; retrying in %s seconds...",
365
+ response.status_code,
366
+ (retries + 1) * 60,
367
+ )
368
+ time.sleep((retries + 1) * 60)
369
+ retries += 1
370
+ else:
371
+ # Handle plain HTML responses to not pollute the logs
372
+ content_type = response.headers.get("content-type", None)
373
+ if content_type == "text/html":
374
+ response_text = "HTML content (only printed in debug log)"
375
+ else:
376
+ response_text = response.text
377
+
378
+ if show_error:
379
+ logger.error(
380
+ "%s; status -> %s/%s; error -> %s",
381
+ failure_message,
382
+ response.status_code,
383
+ HTTPStatus(response.status_code).phrase,
384
+ response_text,
385
+ )
386
+ elif show_warning:
387
+ logger.warning(
388
+ "%s; status -> %s/%s; warning -> %s",
389
+ warning_message if warning_message else failure_message,
390
+ response.status_code,
391
+ HTTPStatus(response.status_code).phrase,
392
+ response_text,
393
+ )
394
+ if content_type == "text/html":
395
+ logger.debug(
396
+ "%s; status -> %s/%s; warning -> %s",
397
+ failure_message,
398
+ response.status_code,
399
+ HTTPStatus(response.status_code).phrase,
400
+ response.text,
401
+ )
402
+ return None
403
+ except requests.exceptions.Timeout:
404
+ if retries <= max_retries:
405
+ logger.warning(
406
+ "Request timed out. Retrying in %s seconds...",
407
+ str(REQUEST_RETRY_DELAY),
408
+ )
409
+ retries += 1
410
+ time.sleep(REQUEST_RETRY_DELAY) # Add a delay before retrying
411
+ else:
412
+ logger.error(
413
+ "%s; timeout error",
414
+ failure_message,
415
+ )
416
+ if retry_forever:
417
+ # If it fails after REQUEST_MAX_RETRIES retries we let it wait forever
418
+ logger.warning("Turn timeouts off and wait forever...")
419
+ timeout = None
420
+ else:
421
+ return None
422
+ except requests.exceptions.ConnectionError:
423
+ if retries <= max_retries:
424
+ logger.warning(
425
+ "Connection error. Retrying in %s seconds...",
426
+ str(REQUEST_RETRY_DELAY),
427
+ )
428
+ retries += 1
429
+ time.sleep(REQUEST_RETRY_DELAY) # Add a delay before retrying
430
+ else:
431
+ logger.error(
432
+ "%s; connection error",
433
+ failure_message,
434
+ )
435
+ if retry_forever:
436
+ # If it fails after REQUEST_MAX_RETRIES retries we let it wait forever
437
+ logger.warning("Turn timeouts off and wait forever...")
438
+ timeout = None
439
+ time.sleep(REQUEST_RETRY_DELAY) # Add a delay before retrying
440
+ else:
441
+ return None
442
+ # end try
443
+ logger.debug(
444
+ "Retrying REST API %s call -> %s... (retry = %s)",
445
+ method,
446
+ url,
447
+ str(retries),
448
+ )
449
+ # end while True
450
+
451
+ # end method definition
452
+
245
453
  def parse_request_response(
246
454
  self,
247
455
  response_object: requests.Response,
@@ -396,7 +604,7 @@ class M365(object):
396
604
 
397
605
  # Already authenticated and session still valid?
398
606
  if self._access_token and not revalidate:
399
- logger.info(
607
+ logger.debug(
400
608
  "Session still valid - return existing access token -> %s",
401
609
  str(self._access_token),
402
610
  )
@@ -405,7 +613,7 @@ class M365(object):
405
613
  request_url = self.config()["authenticationUrl"]
406
614
  request_header = request_login_headers
407
615
 
408
- logger.info("Requesting M365 Access Token from -> %s", request_url)
616
+ logger.debug("Requesting M365 Access Token from -> %s", request_url)
409
617
 
410
618
  authenticate_post_body = self.credentials()
411
619
  authenticate_response = None
@@ -415,7 +623,7 @@ class M365(object):
415
623
  request_url,
416
624
  data=authenticate_post_body,
417
625
  headers=request_header,
418
- timeout=60,
626
+ timeout=REQUEST_TIMEOUT,
419
627
  )
420
628
  except requests.exceptions.ConnectionError as exception:
421
629
  logger.warning(
@@ -459,7 +667,7 @@ class M365(object):
459
667
  request_url = self.config()["authenticationUrl"]
460
668
  request_header = request_login_headers
461
669
 
462
- logger.info(
670
+ logger.debug(
463
671
  "Requesting M365 Access Token for user -> %s from -> %s",
464
672
  username,
465
673
  request_url,
@@ -473,7 +681,7 @@ class M365(object):
473
681
  request_url,
474
682
  data=authenticate_post_body,
475
683
  headers=request_header,
476
- timeout=60,
684
+ timeout=REQUEST_TIMEOUT,
477
685
  )
478
686
  except requests.exceptions.ConnectionError as exception:
479
687
  logger.warning(
@@ -492,7 +700,7 @@ class M365(object):
492
700
  logger.debug("User Access Token -> %s", access_token)
493
701
  else:
494
702
  logger.error(
495
- "Failed to request an M365 Access Token for user -> %s; error -> %s",
703
+ "Failed to request an M365 Access Token for user -> '%s'; error -> %s",
496
704
  username,
497
705
  authenticate_response.text,
498
706
  )
@@ -509,40 +717,21 @@ class M365(object):
509
717
  """Get list all all users in M365 tenant
510
718
 
511
719
  Returns:
512
- dict: Dictionary of all users.
720
+ dict: Dictionary of all M365 users.
513
721
  """
514
722
 
515
723
  request_url = self.config()["usersUrl"]
516
724
  request_header = self.request_header()
517
725
 
518
- logger.info("Get list of all users; calling -> %s", request_url)
726
+ logger.debug("Get list of all M365 users; calling -> %s", request_url)
519
727
 
520
- retries = 0
521
- while True:
522
- response = requests.get(request_url, headers=request_header, timeout=60)
523
- if response.ok:
524
- return self.parse_request_response(response)
525
- # Check if Session has expired - then re-authenticate and try once more
526
- elif response.status_code == 401 and retries == 0:
527
- logger.warning("Session has expired - try to re-authenticate...")
528
- self.authenticate(revalidate=True)
529
- request_header = self.request_header()
530
- retries += 1
531
- elif response.status_code in [502, 503, 504] and retries < 3:
532
- logger.warning(
533
- "M365 Graph API delivered server side error -> %s; retrying in %s seconds...",
534
- response.status_code,
535
- (retries + 1) * 60,
536
- )
537
- time.sleep((retries + 1) * 60)
538
- retries += 1
539
- else:
540
- logger.error(
541
- "Failed to get list of users; status -> %s; error -> %s",
542
- response.status_code,
543
- response.text,
544
- )
545
- return None
728
+ return self.do_request(
729
+ url=request_url,
730
+ method="GET",
731
+ headers=request_header,
732
+ timeout=REQUEST_TIMEOUT,
733
+ failure_message="Failed to get list of M365 users!",
734
+ )
546
735
 
547
736
  # end method definition
548
737
 
@@ -573,41 +762,44 @@ class M365(object):
573
762
  }
574
763
  """
575
764
 
765
+ # Some sanity checks:
766
+ if not "@" in user_email or not "." in user_email:
767
+ logger.error("User email -> %s is not a valid email address", user_email)
768
+ return None
769
+
770
+ # if there's an alias in the E-Mail Adress we remove it as
771
+ # MS Graph seems to not support an alias to lookup a user object.
772
+ if "+" in user_email:
773
+ logger.info(
774
+ "Removing Alias from email address -> %s to determine M365 principal name...",
775
+ user_email,
776
+ )
777
+ # Find the index of the '+' character
778
+ alias_index = user_email.find("+")
779
+
780
+ # Find the index of the '@' character
781
+ domain_index = user_email.find("@")
782
+
783
+ # Construct the email address without the alias
784
+ user_email = user_email[:alias_index] + user_email[domain_index:]
785
+ logger.info(
786
+ "M365 user principal name -> %s",
787
+ user_email,
788
+ )
789
+
576
790
  request_url = self.config()["usersUrl"] + "/" + user_email
577
791
  request_header = self.request_header()
578
792
 
579
- logger.info("Get M365 user -> %s; calling -> %s", user_email, request_url)
793
+ logger.debug("Get M365 user -> %s; calling -> %s", user_email, request_url)
580
794
 
581
- retries = 0
582
- while True:
583
- response = requests.get(request_url, headers=request_header, timeout=60)
584
- if response.ok:
585
- return self.parse_request_response(response)
586
- # Check if Session has expired - then re-authenticate and try once more
587
- elif response.status_code == 401 and retries == 0:
588
- logger.warning("Session has expired - try to re-authenticate...")
589
- self.authenticate(revalidate=True)
590
- request_header = self.request_header()
591
- retries += 1
592
- elif response.status_code in [502, 503, 504] and retries < 3:
593
- logger.warning(
594
- "M365 Graph API delivered server side error -> %s; retrying in %s seconds...",
595
- response.status_code,
596
- (retries + 1) * 60,
597
- )
598
- time.sleep((retries + 1) * 60)
599
- retries += 1
600
- else:
601
- if show_error:
602
- logger.error(
603
- "Failed to get M365 user -> %s; status -> %s; error -> %s",
604
- user_email,
605
- response.status_code,
606
- response.text,
607
- )
608
- else:
609
- logger.info("M365 User -> %s not found.", user_email)
610
- return None
795
+ return self.do_request(
796
+ url=request_url,
797
+ method="GET",
798
+ headers=request_header,
799
+ timeout=REQUEST_TIMEOUT,
800
+ failure_message="Failed to get M365 user -> '{}'".format(user_email),
801
+ show_error=show_error,
802
+ )
611
803
 
612
804
  # end method definition
613
805
 
@@ -657,40 +849,16 @@ class M365(object):
657
849
  request_url = self.config()["usersUrl"]
658
850
  request_header = self.request_header()
659
851
 
660
- logger.info("Adding M365 user -> %s; calling -> %s", email, request_url)
852
+ logger.debug("Adding M365 user -> %s; calling -> %s", email, request_url)
661
853
 
662
- retries = 0
663
- while True:
664
- response = requests.post(
665
- request_url,
666
- data=json.dumps(user_post_body),
667
- headers=request_header,
668
- timeout=60,
669
- )
670
- if response.ok:
671
- return self.parse_request_response(response)
672
- # Check if Session has expired - then re-authenticate and try once more
673
- elif response.status_code == 401 and retries == 0:
674
- logger.warning("Session has expired - try to re-authenticate...")
675
- self.authenticate(revalidate=True)
676
- request_header = self.request_header()
677
- retries += 1
678
- elif response.status_code in [502, 503, 504] and retries < 3:
679
- logger.warning(
680
- "M365 Graph API delivered server side error -> %s; retrying in %s seconds...",
681
- response.status_code,
682
- (retries + 1) * 60,
683
- )
684
- time.sleep((retries + 1) * 60)
685
- retries += 1
686
- else:
687
- logger.error(
688
- "Failed to add M365 user -> %s; status -> %s; error -> %s",
689
- email,
690
- response.status_code,
691
- response.text,
692
- )
693
- return None
854
+ return self.do_request(
855
+ url=request_url,
856
+ method="POST",
857
+ headers=request_header,
858
+ data=json.dumps(user_post_body),
859
+ timeout=REQUEST_TIMEOUT,
860
+ failure_message="Failed to add M365 user -> '{}'".format(email),
861
+ )
694
862
 
695
863
  # end method definition
696
864
 
@@ -698,6 +866,9 @@ class M365(object):
698
866
  """Update selected properties of an M365 user. Documentation
699
867
  on user properties is here: https://learn.microsoft.com/en-us/graph/api/user-update
700
868
 
869
+ Args:
870
+ user_id (str): ID of the user (can also be email). This is also the unique identifier
871
+ updated_settings (dict): new data to update the user with
701
872
  Returns:
702
873
  dict | None: Response of the M365 Graph API or None if the call fails.
703
874
  """
@@ -705,46 +876,23 @@ class M365(object):
705
876
  request_url = self.config()["usersUrl"] + "/" + user_id
706
877
  request_header = self.request_header()
707
878
 
708
- logger.info(
709
- "Updating M365 user -> %s with -> %s; calling -> %s",
879
+ logger.debug(
880
+ "Updating M365 user with ID -> %s with -> %s; calling -> %s",
710
881
  user_id,
711
882
  str(updated_settings),
712
883
  request_url,
713
884
  )
714
885
 
715
- retries = 0
716
- while True:
717
- response = requests.patch(
718
- request_url,
719
- json=updated_settings,
720
- headers=request_header,
721
- timeout=60,
722
- )
723
- if response.ok:
724
- return self.parse_request_response(response)
725
- # Check if Session has expired - then re-authenticate and try once more
726
- elif response.status_code == 401 and retries == 0:
727
- logger.warning("Session has expired - try to re-authenticate...")
728
- self.authenticate(revalidate=True)
729
- request_header = self.request_header()
730
- retries += 1
731
- elif response.status_code in [502, 503, 504] and retries < 3:
732
- logger.warning(
733
- "M365 Graph API delivered server side error -> %s; retrying in %s seconds...",
734
- response.status_code,
735
- (retries + 1) * 60,
736
- )
737
- time.sleep((retries + 1) * 60)
738
- retries += 1
739
- else:
740
- logger.error(
741
- "Failed to update M365 user -> %s with -> %s; status -> %s; error -> %s",
742
- user_id,
743
- str(updated_settings),
744
- response.status_code,
745
- response.text,
746
- )
747
- return None
886
+ return self.do_request(
887
+ url=request_url,
888
+ method="PATCH",
889
+ headers=request_header,
890
+ json_data=updated_settings,
891
+ timeout=REQUEST_TIMEOUT,
892
+ failure_message="Failed to update M365 user -> '{}' with -> {}".format(
893
+ user_id, updated_settings
894
+ ),
895
+ )
748
896
 
749
897
  # end method definition
750
898
 
@@ -773,33 +921,13 @@ class M365(object):
773
921
  request_url = self.config()["usersUrl"] + "/" + user_id + "/licenseDetails"
774
922
  request_header = self.request_header()
775
923
 
776
- retries = 0
777
- while True:
778
- response = requests.get(request_url, headers=request_header, timeout=60)
779
- if response.ok:
780
- return self.parse_request_response(response)
781
- # Check if Session has expired - then re-authenticate and try once more
782
- elif response.status_code == 401 and retries == 0:
783
- logger.warning("Session has expired - try to re-authenticate...")
784
- self.authenticate(revalidate=True)
785
- request_header = self.request_header()
786
- retries += 1
787
- elif response.status_code in [502, 503, 504] and retries < 3:
788
- logger.warning(
789
- "M365 Graph API delivered server side error -> %s; retrying in %s seconds...",
790
- response.status_code,
791
- (retries + 1) * 60,
792
- )
793
- time.sleep((retries + 1) * 60)
794
- retries += 1
795
- else:
796
- logger.error(
797
- "Failed to get M365 licenses of user -> %s; status -> %s; error -> %s",
798
- user_id,
799
- response.status_code,
800
- response.text,
801
- )
802
- return None
924
+ return self.do_request(
925
+ url=request_url,
926
+ method="GET",
927
+ headers=request_header,
928
+ timeout=REQUEST_TIMEOUT,
929
+ failure_message="Failed to get M365 licenses of user -> {}".format(user_id),
930
+ )
803
931
 
804
932
  # end method definition
805
933
 
@@ -830,43 +958,23 @@ class M365(object):
830
958
  "removeLicenses": [],
831
959
  }
832
960
 
833
- logger.info(
961
+ logger.debug(
834
962
  "Assign M365 license -> %s to M365 user -> %s; calling -> %s",
835
963
  sku_id,
836
964
  user_id,
837
965
  request_url,
838
966
  )
839
967
 
840
- retries = 0
841
- while True:
842
- response = requests.post(
843
- request_url, json=license_post_body, headers=request_header, timeout=60
844
- )
845
- if response.ok:
846
- return self.parse_request_response(response)
847
- # Check if Session has expired - then re-authenticate and try once more
848
- elif response.status_code == 401 and retries == 0:
849
- logger.warning("Session has expired - try to re-authenticate...")
850
- self.authenticate(revalidate=True)
851
- request_header = self.request_header()
852
- retries += 1
853
- elif response.status_code in [502, 503, 504] and retries < 3:
854
- logger.warning(
855
- "M365 Graph API delivered server side error -> %s; retrying in %s seconds...",
856
- response.status_code,
857
- (retries + 1) * 60,
858
- )
859
- time.sleep((retries + 1) * 60)
860
- retries += 1
861
- else:
862
- logger.error(
863
- "Failed to add M365 license -> %s to M365 user -> %s; status -> %s; error -> %s",
864
- sku_id,
865
- user_id,
866
- response.status_code,
867
- response.text,
868
- )
869
- return None
968
+ return self.do_request(
969
+ url=request_url,
970
+ method="POST",
971
+ headers=request_header,
972
+ json_data=license_post_body,
973
+ timeout=REQUEST_TIMEOUT,
974
+ failure_message="Failed to add M365 license -> {} to M365 user -> {}".format(
975
+ sku_id, user_id
976
+ ),
977
+ )
870
978
 
871
979
  # end method definition
872
980
 
@@ -885,38 +993,90 @@ class M365(object):
885
993
  # Set image as content type:
886
994
  request_header = self.request_header("image/*")
887
995
 
888
- logger.info("Get photo of user -> %s; calling -> %s", user_id, request_url)
996
+ logger.debug("Get photo of user -> %s; calling -> %s", user_id, request_url)
889
997
 
890
- retries = 0
891
- while True:
892
- response = requests.get(request_url, headers=request_header, timeout=60)
893
- if response.ok:
894
- return response.content # this is the actual image - not json!
895
- # Check if Session has expired - then re-authenticate and try once more
896
- elif response.status_code == 401 and retries == 0:
897
- logger.warning("Session has expired - try to re-authenticate...")
898
- self.authenticate(revalidate=True)
899
- request_header = self.request_header()
900
- retries += 1
901
- elif response.status_code in [502, 503, 504] and retries < 3:
902
- logger.warning(
903
- "M365 Graph API delivered server side error -> %s; retrying in %s seconds...",
904
- response.status_code,
905
- (retries + 1) * 60,
906
- )
907
- time.sleep((retries + 1) * 60)
908
- retries += 1
998
+ response = self.do_request(
999
+ url=request_url,
1000
+ method="GET",
1001
+ headers=request_header,
1002
+ timeout=REQUEST_TIMEOUT,
1003
+ failure_message="Failed to get photo of M365 user -> {}".format(user_id),
1004
+ warning_message="M365 User -> {} does not yet have a photo.".format(
1005
+ user_id
1006
+ ),
1007
+ show_error=show_error,
1008
+ parse_request_response=False, # the response is NOT JSON!
1009
+ )
1010
+
1011
+ if response and response.ok and response.content:
1012
+ return response.content # this is the actual image - not json!
1013
+
1014
+ return None
1015
+
1016
+ # end method definition
1017
+
1018
+ def download_user_photo(self, user_id: str, photo_path: str) -> str | None:
1019
+ """Download the M365 user photo and save it to the local file system
1020
+
1021
+ Args:
1022
+ user_id (str): M365 GUID of the user (can also be the M365 email of the user)
1023
+ photo_path (str): Directory where the photo should be saved
1024
+ Returns:
1025
+ str: name of the photo file in the file system (with full path) or None if
1026
+ the call of the REST API fails.
1027
+ """
1028
+
1029
+ request_url = self.config()["usersUrl"] + "/" + user_id + "/photo/$value"
1030
+ request_header = self.request_header("application/json")
1031
+
1032
+ logger.debug(
1033
+ "Downloading photo for M365 user with ID -> %s; calling -> %s",
1034
+ user_id,
1035
+ request_url,
1036
+ )
1037
+
1038
+ response = self.do_request(
1039
+ url=request_url,
1040
+ method="GET",
1041
+ headers=request_header,
1042
+ timeout=REQUEST_TIMEOUT,
1043
+ failure_message="Failed to download photo for user with ID -> {}".format(
1044
+ user_id
1045
+ ),
1046
+ stream=True,
1047
+ parse_request_response=False,
1048
+ )
1049
+
1050
+ if response and response.ok:
1051
+ content_type = response.headers.get("Content-Type", "image/png")
1052
+ if content_type == "image/jpeg":
1053
+ file_extension = "jpg"
1054
+ elif content_type == "image/png":
1055
+ file_extension = "png"
909
1056
  else:
910
- if show_error:
911
- logger.error(
912
- "Failed to get photo of user -> %s; status -> %s; error -> %s",
913
- user_id,
914
- response.status_code,
915
- response.text,
916
- )
917
- else:
918
- logger.info("User -> %s does not yet have a photo.", user_id)
919
- return None
1057
+ file_extension = "img" # Default extension if type is unknown
1058
+ file_path = os.path.join(
1059
+ photo_path, "{}.{}".format(user_id, file_extension)
1060
+ )
1061
+
1062
+ try:
1063
+ with open(file_path, "wb") as file:
1064
+ for chunk in response.iter_content(chunk_size=8192):
1065
+ file.write(chunk)
1066
+ logger.info(
1067
+ "Photo for M365 user with ID -> %s saved to -> '%s'",
1068
+ user_id,
1069
+ file_path,
1070
+ )
1071
+ return file_path
1072
+ except OSError as exception:
1073
+ logger.error(
1074
+ "Error saving photo for user with ID -> %s; error -> %s",
1075
+ user_id,
1076
+ exception,
1077
+ )
1078
+
1079
+ return None
920
1080
 
921
1081
  # end method definition
922
1082
 
@@ -952,43 +1112,23 @@ class M365(object):
952
1112
 
953
1113
  data = photo_data
954
1114
 
955
- logger.info(
956
- "Update M365 user -> %s with photo -> %s; calling -> %s",
1115
+ logger.debug(
1116
+ "Update M365 user with ID -> %s with photo -> %s; calling -> %s",
957
1117
  user_id,
958
1118
  photo_path,
959
1119
  request_url,
960
1120
  )
961
1121
 
962
- retries = 0
963
- while True:
964
- response = requests.put(
965
- request_url, headers=request_header, data=data, timeout=60
966
- )
967
- if response.ok:
968
- return self.parse_request_response(response)
969
- # Check if Session has expired - then re-authenticate and try once more
970
- elif response.status_code == 401 and retries == 0:
971
- logger.warning("Session has expired - try to re-authenticate...")
972
- self.authenticate(revalidate=True)
973
- request_header = self.request_header()
974
- retries += 1
975
- elif response.status_code in [502, 503, 504] and retries < 3:
976
- logger.warning(
977
- "M365 Graph API delivered server side error -> %s; retrying in %s seconds...",
978
- response.status_code,
979
- (retries + 1) * 60,
980
- )
981
- time.sleep((retries + 1) * 60)
982
- retries += 1
983
- else:
984
- logger.error(
985
- "Failed to update user -> %s with photo -> %s; status -> %s; error -> %s",
986
- user_id,
987
- photo_path,
988
- response.status_code,
989
- response.text,
990
- )
991
- return None
1122
+ return self.do_request(
1123
+ url=request_url,
1124
+ method="PUT",
1125
+ headers=request_header,
1126
+ data=data,
1127
+ timeout=REQUEST_TIMEOUT,
1128
+ failure_message="Failed to update M365 user with ID -> {} with photo -> '{}'".format(
1129
+ user_id, photo_path
1130
+ ),
1131
+ )
992
1132
 
993
1133
  # end method definition
994
1134
 
@@ -1004,39 +1144,16 @@ class M365(object):
1004
1144
  request_url = self.config()["groupsUrl"]
1005
1145
  request_header = self.request_header()
1006
1146
 
1007
- logger.info("Get list of all M365 groups; calling -> %s", request_url)
1147
+ logger.debug("Get list of all M365 groups; calling -> %s", request_url)
1008
1148
 
1009
- retries = 0
1010
- while True:
1011
- response = requests.get(
1012
- request_url,
1013
- headers=request_header,
1014
- params={"$top": str(max_number)},
1015
- timeout=60,
1016
- )
1017
- if response.ok:
1018
- return self.parse_request_response(response)
1019
- # Check if Session has expired - then re-authenticate and try once more
1020
- elif response.status_code == 401 and retries == 0:
1021
- logger.warning("Session has expired - try to re-authenticate...")
1022
- self.authenticate(revalidate=True)
1023
- request_header = self.request_header()
1024
- retries += 1
1025
- elif response.status_code in [502, 503, 504] and retries < 3:
1026
- logger.warning(
1027
- "M365 Graph API delivered server side error -> %s; retrying in %s seconds...",
1028
- response.status_code,
1029
- (retries + 1) * 60,
1030
- )
1031
- time.sleep((retries + 1) * 60)
1032
- retries += 1
1033
- else:
1034
- logger.error(
1035
- "Failed to get list of M365 groups; status -> %s; error -> %s",
1036
- response.status_code,
1037
- response.text,
1038
- )
1039
- return None
1149
+ return self.do_request(
1150
+ url=request_url,
1151
+ method="GET",
1152
+ headers=request_header,
1153
+ params={"$top": str(max_number)},
1154
+ timeout=REQUEST_TIMEOUT,
1155
+ failure_message="Failed to get list of M365 groups",
1156
+ )
1040
1157
 
1041
1158
  # end method definition
1042
1159
 
@@ -1101,38 +1218,16 @@ class M365(object):
1101
1218
  request_url = self.config()["groupsUrl"] + "?" + encoded_query
1102
1219
  request_header = self.request_header()
1103
1220
 
1104
- logger.info("Get M365 group -> %s; calling -> %s", group_name, request_url)
1221
+ logger.debug("Get M365 group -> '%s'; calling -> %s", group_name, request_url)
1105
1222
 
1106
- retries = 0
1107
- while True:
1108
- response = requests.get(request_url, headers=request_header, timeout=60)
1109
- if response.ok:
1110
- return self.parse_request_response(response)
1111
- # Check if Session has expired - then re-authenticate and try once more
1112
- elif response.status_code == 401 and retries == 0:
1113
- logger.warning("Session has expired - try to re-authenticate...")
1114
- self.authenticate(revalidate=True)
1115
- request_header = self.request_header()
1116
- retries += 1
1117
- elif response.status_code in [502, 503, 504] and retries < 3:
1118
- logger.warning(
1119
- "M365 Graph API delivered server side error -> %s; retrying in %s seconds...",
1120
- response.status_code,
1121
- (retries + 1) * 60,
1122
- )
1123
- time.sleep((retries + 1) * 60)
1124
- retries += 1
1125
- else:
1126
- if show_error:
1127
- logger.error(
1128
- "Failed to get M365 group -> %s; status -> %s; error -> %s",
1129
- group_name,
1130
- response.status_code,
1131
- response.text,
1132
- )
1133
- else:
1134
- logger.info("M365 Group -> %s not found.", group_name)
1135
- return None
1223
+ return self.do_request(
1224
+ url=request_url,
1225
+ method="GET",
1226
+ headers=request_header,
1227
+ timeout=REQUEST_TIMEOUT,
1228
+ failure_message="Failed to get M365 group -> '{}'".format(group_name),
1229
+ show_error=show_error,
1230
+ )
1136
1231
 
1137
1232
  # end method definition
1138
1233
 
@@ -1197,41 +1292,17 @@ class M365(object):
1197
1292
  request_url = self.config()["groupsUrl"]
1198
1293
  request_header = self.request_header()
1199
1294
 
1200
- logger.info("Adding M365 group -> %s; calling -> %s", name, request_url)
1201
- logger.debug("M365 group attributes -> %s", group_post_body)
1295
+ logger.debug("Adding M365 group -> '%s'; calling -> %s", name, request_url)
1296
+ logger.debug("M365 group attributes -> %s", str(group_post_body))
1202
1297
 
1203
- retries = 0
1204
- while True:
1205
- response = requests.post(
1206
- request_url,
1207
- data=json.dumps(group_post_body),
1208
- headers=request_header,
1209
- timeout=60,
1210
- )
1211
- if response.ok:
1212
- return self.parse_request_response(response)
1213
- # Check if Session has expired - then re-authenticate and try once more
1214
- elif response.status_code == 401 and retries == 0:
1215
- logger.warning("Session has expired - try to re-authenticate...")
1216
- self.authenticate(revalidate=True)
1217
- request_header = self.request_header()
1218
- retries += 1
1219
- elif response.status_code in [502, 503, 504] and retries < 3:
1220
- logger.warning(
1221
- "M365 Graph API delivered server side error -> %s; retrying in %s seconds...",
1222
- response.status_code,
1223
- (retries + 1) * 60,
1224
- )
1225
- time.sleep((retries + 1) * 60)
1226
- retries += 1
1227
- else:
1228
- logger.error(
1229
- "Failed to add M365 group -> %s; status -> %s; error -> %s",
1230
- name,
1231
- response.status_code,
1232
- response.text,
1233
- )
1234
- return None
1298
+ return self.do_request(
1299
+ url=request_url,
1300
+ method="POST",
1301
+ headers=request_header,
1302
+ data=json.dumps(group_post_body),
1303
+ timeout=REQUEST_TIMEOUT,
1304
+ failure_message="Failed to add M365 group -> '{}'".format(name),
1305
+ )
1235
1306
 
1236
1307
  # end method definition
1237
1308
 
@@ -1261,41 +1332,22 @@ class M365(object):
1261
1332
  )
1262
1333
  request_header = self.request_header()
1263
1334
 
1264
- logger.info(
1335
+ logger.debug(
1265
1336
  "Get members of M365 group -> %s (%s); calling -> %s",
1266
1337
  group_name,
1267
1338
  group_id,
1268
1339
  request_url,
1269
1340
  )
1270
1341
 
1271
- retries = 0
1272
- while True:
1273
- response = requests.get(request_url, headers=request_header, timeout=60)
1274
- if response.ok:
1275
- return self.parse_request_response(response)
1276
- # Check if Session has expired - then re-authenticate and try once more
1277
- elif response.status_code == 401 and retries == 0:
1278
- logger.warning("Session has expired - try to re-authenticate...")
1279
- self.authenticate(revalidate=True)
1280
- request_header = self.request_header()
1281
- retries += 1
1282
- elif response.status_code in [502, 503, 504] and retries < 3:
1283
- logger.warning(
1284
- "M365 Graph API delivered server side error -> %s; retrying in %s seconds...",
1285
- response.status_code,
1286
- (retries + 1) * 60,
1287
- )
1288
- time.sleep((retries + 1) * 60)
1289
- retries += 1
1290
- else:
1291
- logger.error(
1292
- "Failed to get members of M365 group -> %s (%s); status -> %s; error -> %s",
1293
- group_name,
1294
- group_id,
1295
- response.status_code,
1296
- response.text,
1297
- )
1298
- return None
1342
+ return self.do_request(
1343
+ url=request_url,
1344
+ method="GET",
1345
+ headers=request_header,
1346
+ timeout=REQUEST_TIMEOUT,
1347
+ failure_message="Failed to get members of M365 group -> '{}' ({})".format(
1348
+ group_name, group_id
1349
+ ),
1350
+ )
1299
1351
 
1300
1352
  # end method definition
1301
1353
 
@@ -1316,47 +1368,23 @@ class M365(object):
1316
1368
  "@odata.id": self.config()["directoryObjects"] + "/" + member_id
1317
1369
  }
1318
1370
 
1319
- logger.info(
1371
+ logger.debug(
1320
1372
  "Adding member -> %s to group -> %s; calling -> %s",
1321
1373
  member_id,
1322
1374
  group_id,
1323
1375
  request_url,
1324
1376
  )
1325
1377
 
1326
- retries = 0
1327
- while True:
1328
- response = requests.post(
1329
- request_url,
1330
- headers=request_header,
1331
- data=json.dumps(group_member_post_body),
1332
- timeout=60,
1333
- )
1334
- if response.ok:
1335
- return self.parse_request_response(response)
1336
-
1337
- # Check if Session has expired - then re-authenticate and try once more
1338
- if response.status_code == 401 and retries == 0:
1339
- logger.warning("Session has expired - try to re-authenticate...")
1340
- self.authenticate(revalidate=True)
1341
- request_header = self.request_header()
1342
- retries += 1
1343
- elif response.status_code in [502, 503, 504] and retries < 3:
1344
- logger.warning(
1345
- "M365 Graph API delivered server side error -> %s; retrying in %s seconds...",
1346
- response.status_code,
1347
- (retries + 1) * 60,
1348
- )
1349
- time.sleep((retries + 1) * 60)
1350
- retries += 1
1351
- else:
1352
- logger.error(
1353
- "Failed to add member -> %s to M365 group -> %s; status -> %s; error -> %s",
1354
- member_id,
1355
- group_id,
1356
- response.status_code,
1357
- response.text,
1358
- )
1359
- return None
1378
+ return self.do_request(
1379
+ url=request_url,
1380
+ method="POST",
1381
+ headers=request_header,
1382
+ data=json.dumps(group_member_post_body),
1383
+ timeout=REQUEST_TIMEOUT,
1384
+ failure_message="Failed to add member -> {} to M365 group -> {}".format(
1385
+ member_id, group_id
1386
+ ),
1387
+ )
1360
1388
 
1361
1389
  # end method definition
1362
1390
 
@@ -1379,47 +1407,28 @@ class M365(object):
1379
1407
  )
1380
1408
  request_header = self.request_header()
1381
1409
 
1382
- logger.info(
1410
+ logger.debug(
1383
1411
  "Check if user -> %s is in group -> %s; calling -> %s",
1384
1412
  member_id,
1385
1413
  group_id,
1386
1414
  request_url,
1387
1415
  )
1388
1416
 
1389
- retries = 0
1390
- while True:
1391
- response = requests.get(request_url, headers=request_header, timeout=60)
1392
- if response.ok:
1393
- response = self.parse_request_response(response)
1394
- if not "value" in response or len(response["value"]) == 0:
1395
- return False
1396
- return True
1397
- # Check if Session has expired - then re-authenticate and try once more
1398
- elif response.status_code == 401 and retries == 0:
1399
- logger.warning("Session has expired - try to re-authenticate...")
1400
- self.authenticate(revalidate=True)
1401
- request_header = self.request_header()
1402
- retries += 1
1403
- elif response.status_code in [502, 503, 504] and retries < 3:
1404
- logger.warning(
1405
- "M365 Graph API delivered server side error -> %s; retrying in %s seconds...",
1406
- response.status_code,
1407
- (retries + 1) * 60,
1408
- )
1409
- time.sleep((retries + 1) * 60)
1410
- retries += 1
1411
- else:
1412
- # MS Graph API returns an error if the member is not in the
1413
- # group. This is typically not what we want. We just return False.
1414
- if show_error:
1415
- logger.error(
1416
- "Failed to check if user -> %s is in group -> %s; status -> %s; error -> %s",
1417
- member_id,
1418
- group_id,
1419
- response.status_code,
1420
- response.text,
1421
- )
1422
- return False
1417
+ response = self.do_request(
1418
+ url=request_url,
1419
+ method="GET",
1420
+ headers=request_header,
1421
+ timeout=REQUEST_TIMEOUT,
1422
+ failure_message="Failed to check if user -> {} is in group -> {}".format(
1423
+ member_id, group_id
1424
+ ),
1425
+ show_error=show_error,
1426
+ )
1427
+
1428
+ if not response or not "value" in response or len(response["value"]) == 0:
1429
+ return False
1430
+
1431
+ return True
1423
1432
 
1424
1433
  # end method definition
1425
1434
 
@@ -1449,41 +1458,22 @@ class M365(object):
1449
1458
  )
1450
1459
  request_header = self.request_header()
1451
1460
 
1452
- logger.info(
1461
+ logger.debug(
1453
1462
  "Get owners of M365 group -> %s (%s); calling -> %s",
1454
1463
  group_name,
1455
1464
  group_id,
1456
1465
  request_url,
1457
1466
  )
1458
1467
 
1459
- retries = 0
1460
- while True:
1461
- response = requests.get(request_url, headers=request_header, timeout=60)
1462
- if response.ok:
1463
- return self.parse_request_response(response)
1464
- # Check if Session has expired - then re-authenticate and try once more
1465
- elif response.status_code == 401 and retries == 0:
1466
- logger.warning("Session has expired - try to re-authenticate...")
1467
- self.authenticate(revalidate=True)
1468
- request_header = self.request_header()
1469
- retries += 1
1470
- elif response.status_code in [502, 503, 504] and retries < 3:
1471
- logger.warning(
1472
- "M365 Graph API delivered server side error -> %s; retrying in %s seconds...",
1473
- response.status_code,
1474
- (retries + 1) * 60,
1475
- )
1476
- time.sleep((retries + 1) * 60)
1477
- retries += 1
1478
- else:
1479
- logger.error(
1480
- "Failed to get owners of M365 group -> %s (%s); status -> %s; error -> %s",
1481
- group_name,
1482
- group_id,
1483
- response.status_code,
1484
- response.text,
1485
- )
1486
- return None
1468
+ return self.do_request(
1469
+ url=request_url,
1470
+ method="GET",
1471
+ headers=request_header,
1472
+ timeout=REQUEST_TIMEOUT,
1473
+ failure_message="Failed to get owners of M365 group -> '{}' ({})".format(
1474
+ group_name, group_id
1475
+ ),
1476
+ )
1487
1477
 
1488
1478
  # end method definition
1489
1479
 
@@ -1504,46 +1494,23 @@ class M365(object):
1504
1494
  "@odata.id": self.config()["directoryObjects"] + "/" + owner_id
1505
1495
  }
1506
1496
 
1507
- logger.info(
1497
+ logger.debug(
1508
1498
  "Adding owner -> %s to M365 group -> %s; calling -> %s",
1509
1499
  owner_id,
1510
1500
  group_id,
1511
1501
  request_url,
1512
1502
  )
1513
1503
 
1514
- retries = 0
1515
- while True:
1516
- response = requests.post(
1517
- request_url,
1518
- headers=request_header,
1519
- data=json.dumps(group_member_post_body),
1520
- timeout=60,
1521
- )
1522
- if response.ok:
1523
- return self.parse_request_response(response)
1524
- # Check if Session has expired - then re-authenticate and try once more
1525
- elif response.status_code == 401 and retries == 0:
1526
- logger.warning("Session has expired - try to re-authenticate...")
1527
- self.authenticate(revalidate=True)
1528
- request_header = self.request_header()
1529
- retries += 1
1530
- elif response.status_code in [502, 503, 504] and retries < 3:
1531
- logger.warning(
1532
- "M365 Graph API delivered server side error -> %s; retrying in %s seconds...",
1533
- response.status_code,
1534
- (retries + 1) * 60,
1535
- )
1536
- time.sleep((retries + 1) * 60)
1537
- retries += 1
1538
- else:
1539
- logger.error(
1540
- "Failed to add owner -> %s to M365 group -> %s; status -> %s; error -> %s",
1541
- owner_id,
1542
- group_id,
1543
- response.status_code,
1544
- response.text,
1545
- )
1546
- return None
1504
+ return self.do_request(
1505
+ url=request_url,
1506
+ method="POST",
1507
+ headers=request_header,
1508
+ data=json.dumps(group_member_post_body),
1509
+ timeout=REQUEST_TIMEOUT,
1510
+ failure_message="Failed to add owner -> {} to M365 group -> {}".format(
1511
+ owner_id, group_id
1512
+ ),
1513
+ )
1547
1514
 
1548
1515
  # end method definition
1549
1516
 
@@ -1558,7 +1525,9 @@ class M365(object):
1558
1525
  request_url = (
1559
1526
  self.config()["directoryUrl"] + "/deletedItems/microsoft.graph.group"
1560
1527
  )
1561
- response = requests.get(request_url, headers=request_header, timeout=60)
1528
+ response = requests.get(
1529
+ request_url, headers=request_header, timeout=REQUEST_TIMEOUT
1530
+ )
1562
1531
  deleted_groups = self.parse_request_response(response)
1563
1532
 
1564
1533
  for group in deleted_groups["value"]:
@@ -1568,7 +1537,9 @@ class M365(object):
1568
1537
  request_url = (
1569
1538
  self.config()["directoryUrl"] + "/deletedItems/microsoft.graph.user"
1570
1539
  )
1571
- response = requests.get(request_url, headers=request_header, timeout=60)
1540
+ response = requests.get(
1541
+ request_url, headers=request_header, timeout=REQUEST_TIMEOUT
1542
+ )
1572
1543
  deleted_users = self.parse_request_response(response)
1573
1544
 
1574
1545
  for user in deleted_users["value"]:
@@ -1591,35 +1562,15 @@ class M365(object):
1591
1562
  request_url = self.config()["directoryUrl"] + "/deletedItems/" + item_id
1592
1563
  request_header = self.request_header()
1593
1564
 
1594
- logger.info("Purging deleted item -> %s; calling -> %s", item_id, request_url)
1565
+ logger.debug("Purging deleted item -> %s; calling -> %s", item_id, request_url)
1595
1566
 
1596
- retries = 0
1597
- while True:
1598
- response = requests.delete(request_url, headers=request_header, timeout=60)
1599
- if response.ok:
1600
- return self.parse_request_response(response)
1601
- # Check if Session has expired - then re-authenticate and try once more
1602
- elif response.status_code == 401 and retries == 0:
1603
- logger.warning("Session has expired - try to re-authenticate...")
1604
- self.authenticate(revalidate=True)
1605
- request_header = self.request_header()
1606
- retries += 1
1607
- elif response.status_code in [502, 503, 504] and retries < 3:
1608
- logger.warning(
1609
- "M365 Graph API delivered server side error -> %s; retrying in %s seconds...",
1610
- response.status_code,
1611
- (retries + 1) * 60,
1612
- )
1613
- time.sleep((retries + 1) * 60)
1614
- retries += 1
1615
- else:
1616
- logger.error(
1617
- "Failed to purge deleted item -> %s; status -> %s; error -> %s",
1618
- item_id,
1619
- response.status_code,
1620
- response.text,
1621
- )
1622
- return None
1567
+ return self.do_request(
1568
+ url=request_url,
1569
+ method="DELETE",
1570
+ headers=request_header,
1571
+ timeout=REQUEST_TIMEOUT,
1572
+ failure_message="Failed to purge deleted item -> {}".format(item_id),
1573
+ )
1623
1574
 
1624
1575
  # end method definition
1625
1576
 
@@ -1644,43 +1595,33 @@ class M365(object):
1644
1595
  request_url = self.config()["groupsUrl"] + "/" + group_id + "/team"
1645
1596
  request_header = self.request_header()
1646
1597
 
1647
- logger.info(
1598
+ logger.debug(
1648
1599
  "Check if M365 Group -> %s has a M365 Team connected; calling -> %s",
1649
1600
  group_name,
1650
1601
  request_url,
1651
1602
  )
1652
1603
 
1653
- retries = 0
1654
- while True:
1655
- response = requests.get(request_url, headers=request_header, timeout=60)
1604
+ response = self.do_request(
1605
+ url=request_url,
1606
+ method="GET",
1607
+ headers=request_header,
1608
+ timeout=REQUEST_TIMEOUT,
1609
+ failure_message="Failed to check if M365 Group -> '{}' has a M365 Team connected".format(
1610
+ group_name
1611
+ ),
1612
+ parse_request_response=False,
1613
+ )
1656
1614
 
1657
- if response.status_code == 200: # Group has a Team assigned!
1658
- logger.info("Group -> %s has a M365 Team connected.", group_name)
1659
- return True
1660
- elif response.status_code == 404: # Group does not have a Team assigned!
1661
- logger.info("Group -> %s has no M365 Team connected.", group_name)
1662
- return False
1663
- elif response.status_code == 401 and retries == 0:
1664
- logger.warning("Session has expired - try to re-authenticate...")
1665
- self.authenticate(revalidate=True)
1666
- request_header = self.request_header()
1667
- retries += 1
1668
- elif response.status_code in [502, 503, 504] and retries < 3:
1669
- logger.warning(
1670
- "M365 Graph API delivered server side error -> %s; retrying in %s seconds...",
1671
- response.status_code,
1672
- (retries + 1) * 60,
1673
- )
1674
- time.sleep((retries + 1) * 60)
1675
- retries += 1
1676
- else:
1677
- logger.error(
1678
- "Failed to check if M365 Group -> %s has a M365 Team connected; status -> %s; error -> %s",
1679
- group_name,
1680
- response.status_code,
1681
- response.text,
1682
- )
1683
- return False
1615
+ if response and response.status_code == 200: # Group has a Team assigned!
1616
+ logger.debug("Group -> %s has a M365 Team connected.", group_name)
1617
+ return True
1618
+ elif (
1619
+ not response or response.status_code == 404
1620
+ ): # Group does not have a Team assigned!
1621
+ logger.debug("Group -> %s has no M365 Team connected.", group_name)
1622
+ return False
1623
+
1624
+ return False
1684
1625
 
1685
1626
  # end method definition
1686
1627
 
@@ -1734,39 +1675,19 @@ class M365(object):
1734
1675
 
1735
1676
  request_header = self.request_header()
1736
1677
 
1737
- logger.info(
1738
- "Lookup Microsoft 365 Teams with name -> %s; calling -> %s",
1678
+ logger.debug(
1679
+ "Lookup Microsoft 365 Teams with name -> '%s'; calling -> %s",
1739
1680
  name,
1740
1681
  request_url,
1741
1682
  )
1742
1683
 
1743
- retries = 0
1744
- while True:
1745
- response = requests.get(request_url, headers=request_header, timeout=60)
1746
- if response.ok:
1747
- return self.parse_request_response(response)
1748
- # Check if Session has expired - then re-authenticate and try once more
1749
- elif response.status_code == 401 and retries == 0:
1750
- logger.warning("Session has expired - try to re-authenticate...")
1751
- self.authenticate(revalidate=True)
1752
- request_header = self.request_header()
1753
- retries += 1
1754
- elif response.status_code in [502, 503, 504] and retries < 3:
1755
- logger.warning(
1756
- "M365 Graph API delivered server side error -> %s; retrying in %s seconds...",
1757
- response.status_code,
1758
- (retries + 1) * 60,
1759
- )
1760
- time.sleep((retries + 1) * 60)
1761
- retries += 1
1762
- else:
1763
- logger.error(
1764
- "Failed to get M365 Team -> %s; status -> %s; error -> %s",
1765
- name,
1766
- response.status_code,
1767
- response.text,
1768
- )
1769
- return None
1684
+ return self.do_request(
1685
+ url=request_url,
1686
+ method="GET",
1687
+ headers=request_header,
1688
+ timeout=REQUEST_TIMEOUT,
1689
+ failure_message="Failed to get M365 Team -> '{}'".format(name),
1690
+ )
1770
1691
 
1771
1692
  # end method definition
1772
1693
 
@@ -1785,7 +1706,7 @@ class M365(object):
1785
1706
  group_id = self.get_result_value(response, "id", 0)
1786
1707
  if not group_id:
1787
1708
  logger.error(
1788
- "M365 Group -> %s not found. It is required for creating a corresponding M365 Team.",
1709
+ "M365 Group -> '%s' not found. It is required for creating a corresponding M365 Team.",
1789
1710
  name,
1790
1711
  )
1791
1712
  return None
@@ -1793,7 +1714,7 @@ class M365(object):
1793
1714
  response = self.get_group_owners(name)
1794
1715
  if response is None or not "value" in response or not response["value"]:
1795
1716
  logger.warning(
1796
- "M365 Group -> %s has no owners. This is required for creating a corresponding M365 Team.",
1717
+ "M365 Group -> '%s' has no owners. This is required for creating a corresponding M365 Team.",
1797
1718
  name,
1798
1719
  )
1799
1720
  return None
@@ -1808,41 +1729,17 @@ class M365(object):
1808
1729
  request_url = self.config()["teamsUrl"]
1809
1730
  request_header = self.request_header()
1810
1731
 
1811
- logger.info("Adding M365 Team -> %s; calling -> %s", name, request_url)
1812
- logger.debug("M365 Team attributes -> %s", team_post_body)
1732
+ logger.debug("Adding M365 Team -> '%s'; calling -> %s", name, request_url)
1733
+ logger.debug("M365 Team attributes -> %s", str(team_post_body))
1813
1734
 
1814
- retries = 0
1815
- while True:
1816
- response = requests.post(
1817
- request_url,
1818
- data=json.dumps(team_post_body),
1819
- headers=request_header,
1820
- timeout=60,
1821
- )
1822
- if response.ok:
1823
- return self.parse_request_response(response)
1824
- # Check if Session has expired - then re-authenticate and try once more
1825
- elif response.status_code == 401 and retries == 0:
1826
- logger.warning("Session has expired - try to re-authenticate...")
1827
- self.authenticate(revalidate=True)
1828
- request_header = self.request_header()
1829
- retries += 1
1830
- elif response.status_code in [502, 503, 504] and retries < 3:
1831
- logger.warning(
1832
- "M365 Graph API delivered server side error -> %s; retrying in %s seconds...",
1833
- response.status_code,
1834
- (retries + 1) * 60,
1835
- )
1836
- time.sleep((retries + 1) * 60)
1837
- retries += 1
1838
- else:
1839
- logger.error(
1840
- "Failed to add M365 Team -> %s; status -> %s; error -> %s",
1841
- name,
1842
- response.status_code,
1843
- response.text,
1844
- )
1845
- return None
1735
+ return self.do_request(
1736
+ url=request_url,
1737
+ method="POST",
1738
+ data=json.dumps(team_post_body),
1739
+ headers=request_header,
1740
+ timeout=REQUEST_TIMEOUT,
1741
+ failure_message="Failed to add M365 Team -> '{}'".format(name),
1742
+ )
1846
1743
 
1847
1744
  # end method definition
1848
1745
 
@@ -1859,34 +1756,19 @@ class M365(object):
1859
1756
 
1860
1757
  request_header = self.request_header()
1861
1758
 
1862
- logger.info(
1759
+ logger.debug(
1863
1760
  "Delete Microsoft 365 Teams with ID -> %s; calling -> %s",
1864
1761
  team_id,
1865
1762
  request_url,
1866
1763
  )
1867
1764
 
1868
- retries = 0
1869
- while True:
1870
- response = requests.delete(request_url, headers=request_header, timeout=60)
1871
- if response.ok:
1872
- return self.parse_request_response(response)
1873
- # Check if Session has expired - then re-authenticate and try once more
1874
- elif response.status_code == 401 and retries == 0:
1875
- logger.warning("Session has expired - try to re-authenticate...")
1876
- self.authenticate(revalidate=True)
1877
- request_header = self.request_header()
1878
- retries += 1
1879
- elif response.status_code in [502, 503, 504] and retries < 3:
1880
- logger.warning(
1881
- "M365 Graph API delivered server side error -> %s; retrying in %s seconds...",
1882
- response.status_code,
1883
- (retries + 1) * 60,
1884
- )
1885
- time.sleep((retries + 1) * 60)
1886
- retries += 1
1887
- else:
1888
- logger.error("Failed to delete M365 Team with ID -> %s", team_id)
1889
- return None
1765
+ return self.do_request(
1766
+ url=request_url,
1767
+ method="DELETE",
1768
+ headers=request_header,
1769
+ timeout=REQUEST_TIMEOUT,
1770
+ failure_message="Failed to delete M365 Team with ID -> {}".format(team_id),
1771
+ )
1890
1772
 
1891
1773
  # end method definition
1892
1774
 
@@ -1911,40 +1793,19 @@ class M365(object):
1911
1793
 
1912
1794
  request_header = self.request_header()
1913
1795
 
1914
- logger.info(
1915
- "Delete all Microsoft 365 Teams with name -> %s; calling -> %s",
1796
+ logger.debug(
1797
+ "Delete all Microsoft 365 Teams with name -> '%s'; calling -> %s",
1916
1798
  name,
1917
1799
  request_url,
1918
1800
  )
1919
1801
 
1920
- retries = 0
1921
- while True:
1922
- response = requests.get(request_url, headers=request_header, timeout=60)
1923
- if response.ok:
1924
- existing_teams = self.parse_request_response(response)
1925
- break
1926
- # Check if Session has expired - then re-authenticate and try once more
1927
- elif response.status_code == 401 and retries == 0:
1928
- logger.warning("Session has expired - try to re-authenticate...")
1929
- self.authenticate(revalidate=True)
1930
- request_header = self.request_header()
1931
- retries += 1
1932
- elif response.status_code in [502, 503, 504] and retries < 3:
1933
- logger.warning(
1934
- "M365 Graph API delivered server side error -> %s; retrying in %s seconds...",
1935
- response.status_code,
1936
- (retries + 1) * 60,
1937
- )
1938
- time.sleep((retries + 1) * 60)
1939
- retries += 1
1940
- else:
1941
- logger.error(
1942
- "Failed to get list of M365 Teams to delete; status -> %s; error -> %s",
1943
- response.status_code,
1944
- response.text,
1945
- )
1946
- existing_teams = None
1947
- break
1802
+ existing_teams = self.do_request(
1803
+ url=request_url,
1804
+ method="GET",
1805
+ headers=request_header,
1806
+ timeout=REQUEST_TIMEOUT,
1807
+ failure_message="Failed to get list of M365 Teams to delete",
1808
+ )
1948
1809
 
1949
1810
  if existing_teams:
1950
1811
  data = existing_teams.get("value")
@@ -1956,22 +1817,22 @@ class M365(object):
1956
1817
 
1957
1818
  if not response:
1958
1819
  logger.error(
1959
- "Failed to delete M365 Team -> %s (%s)", name, team_id
1820
+ "Failed to delete M365 Team -> '%s' (%s)", name, team_id
1960
1821
  )
1961
1822
  continue
1962
1823
  counter += 1
1963
1824
 
1964
1825
  logger.info(
1965
- "%s M365 Teams with name -> %s have been deleted.",
1826
+ "%s M365 Teams with name -> '%s' have been deleted.",
1966
1827
  str(counter),
1967
1828
  name,
1968
1829
  )
1969
1830
  return True
1970
1831
  else:
1971
- logger.info("No M365 Teams with name -> %s found.", name)
1832
+ logger.info("No M365 Teams with name -> '%s' found.", name)
1972
1833
  return False
1973
1834
  else:
1974
- logger.error("Failed to retrieve M365 Teams with name -> %s", name)
1835
+ logger.error("Failed to retrieve M365 Teams with name -> '%s'", name)
1975
1836
  return False
1976
1837
 
1977
1838
  # end method definition
@@ -1995,19 +1856,20 @@ class M365(object):
1995
1856
  if not "value" in response or not response["value"]:
1996
1857
  return False
1997
1858
  groups = response["value"]
1859
+
1998
1860
  logger.info(
1999
1861
  "Found -> %s existing M365 groups. Checking which ones should be deleted...",
2000
1862
  len(groups),
2001
1863
  )
2002
1864
 
2003
- # Process all groups and check if the< should be
1865
+ # Process all groups and check if they should be
2004
1866
  # deleted:
2005
1867
  for group in groups:
2006
1868
  group_name = group["displayName"]
2007
1869
  # Check if group is in exception list:
2008
1870
  if group_name in exception_list:
2009
1871
  logger.info(
2010
- "M365 Group name -> %s is on the exception list. Skipping...",
1872
+ "M365 Group name -> '%s' is on the exception list. Skipping...",
2011
1873
  group_name,
2012
1874
  )
2013
1875
  continue
@@ -2016,7 +1878,7 @@ class M365(object):
2016
1878
  result = re.search(pattern, group_name)
2017
1879
  if result:
2018
1880
  logger.info(
2019
- "M365 Group name -> %s is matching pattern -> %s. Delete it now...",
1881
+ "M365 Group name -> '%s' is matching pattern -> %s. Delete it now...",
2020
1882
  group_name,
2021
1883
  pattern,
2022
1884
  )
@@ -2024,7 +1886,7 @@ class M365(object):
2024
1886
  break
2025
1887
  else:
2026
1888
  logger.info(
2027
- "M365 Group name -> %s is not matching any delete pattern. Skipping...",
1889
+ "M365 Group name -> '%s' is not matching any delete pattern. Skipping...",
2028
1890
  group_name,
2029
1891
  )
2030
1892
  return True
@@ -2068,39 +1930,21 @@ class M365(object):
2068
1930
 
2069
1931
  request_header = self.request_header()
2070
1932
 
2071
- logger.info(
2072
- "Retrieve channels of Microsoft 365 Team -> %s; calling -> %s",
1933
+ logger.debug(
1934
+ "Retrieve channels of Microsoft 365 Team -> '%s'; calling -> %s",
2073
1935
  name,
2074
1936
  request_url,
2075
1937
  )
2076
1938
 
2077
- retries = 0
2078
- while True:
2079
- response = requests.get(request_url, headers=request_header, timeout=60)
2080
- if response.ok:
2081
- return self.parse_request_response(response)
2082
- # Check if Session has expired - then re-authenticate and try once more
2083
- elif response.status_code == 401 and retries == 0:
2084
- logger.warning("Session has expired - try to re-authenticate...")
2085
- self.authenticate(revalidate=True)
2086
- request_header = self.request_header()
2087
- retries += 1
2088
- elif response.status_code in [502, 503, 504] and retries < 3:
2089
- logger.warning(
2090
- "M365 Graph API delivered server side error -> %s; retrying in %s seconds...",
2091
- response.status_code,
2092
- (retries + 1) * 60,
2093
- )
2094
- time.sleep((retries + 1) * 60)
2095
- retries += 1
2096
- else:
2097
- logger.error(
2098
- "Failed to get Channels for M365 Team -> %s; status -> %s; error -> %s",
2099
- name,
2100
- response.status_code,
2101
- response.text,
2102
- )
2103
- return None
1939
+ return self.do_request(
1940
+ url=request_url,
1941
+ method="GET",
1942
+ headers=request_header,
1943
+ timeout=REQUEST_TIMEOUT,
1944
+ failure_message="Failed to get Channels for M365 Team -> '{}' ({})".format(
1945
+ name, team_id
1946
+ ),
1947
+ )
2104
1948
 
2105
1949
  # end method definition
2106
1950
 
@@ -2151,7 +1995,7 @@ class M365(object):
2151
1995
  None,
2152
1996
  )
2153
1997
  if not channel:
2154
- logger.erro(
1998
+ logger.error(
2155
1999
  "Cannot find Channel -> %s on M365 Team -> %s", channel_name, team_name
2156
2000
  )
2157
2001
  return None
@@ -2168,43 +2012,22 @@ class M365(object):
2168
2012
 
2169
2013
  request_header = self.request_header()
2170
2014
 
2171
- logger.info(
2015
+ logger.debug(
2172
2016
  "Retrieve Tabs of Microsoft 365 Teams -> %s and Channel -> %s; calling -> %s",
2173
2017
  team_name,
2174
2018
  channel_name,
2175
2019
  request_url,
2176
2020
  )
2177
2021
 
2178
- retries = 0
2179
- while True:
2180
- response = requests.get(request_url, headers=request_header, timeout=60)
2181
- if response.ok:
2182
- return self.parse_request_response(response)
2183
- # Check if Session has expired - then re-authenticate and try once more
2184
- elif response.status_code == 401 and retries == 0:
2185
- logger.warning("Session has expired - try to re-authenticate...")
2186
- self.authenticate(revalidate=True)
2187
- request_header = self.request_header()
2188
- retries += 1
2189
- elif response.status_code in [502, 503, 504] and retries < 3:
2190
- logger.warning(
2191
- "M365 Graph API delivered server side error -> %s; retrying in %s seconds...",
2192
- response.status_code,
2193
- (retries + 1) * 60,
2194
- )
2195
- time.sleep((retries + 1) * 60)
2196
- retries += 1
2197
- else:
2198
- logger.error(
2199
- "Failed to get Tabs for M365 Team -> %s (%s) and Channel -> %s (%s); status -> %s; error -> %s",
2200
- team_name,
2201
- team_id,
2202
- channel_name,
2203
- channel_id,
2204
- response.status_code,
2205
- response.text,
2206
- )
2207
- return None
2022
+ return self.do_request(
2023
+ url=request_url,
2024
+ method="GET",
2025
+ headers=request_header,
2026
+ timeout=REQUEST_TIMEOUT,
2027
+ failure_message="Failed to get Tabs for M365 Team -> '{}' ({}) and Channel -> '{}' ({})".format(
2028
+ team_name, team_id, channel_name, channel_id
2029
+ ),
2030
+ )
2208
2031
 
2209
2032
  # end method definition
2210
2033
 
@@ -2257,92 +2080,82 @@ class M365(object):
2257
2080
  request_url = self.config()["teamsAppsUrl"] + "?" + encoded_query
2258
2081
 
2259
2082
  if filter_expression:
2260
- logger.info(
2083
+ logger.debug(
2261
2084
  "Get list of MS Teams Apps using filter -> %s; calling -> %s",
2262
2085
  filter_expression,
2263
2086
  request_url,
2264
2087
  )
2088
+ failure_message = (
2089
+ "Failed to get list of M365 Teams apps using filter -> {}".format(
2090
+ filter_expression
2091
+ )
2092
+ )
2265
2093
  else:
2266
- logger.info("Get list of all MS Teams Apps; calling -> %s", request_url)
2094
+ logger.debug("Get list of all MS Teams Apps; calling -> %s", request_url)
2095
+ failure_message = "Failed to get list of M365 Teams apps"
2267
2096
 
2268
2097
  request_header = self.request_header()
2269
2098
 
2270
- retries = 0
2271
- while True:
2272
- response = requests.get(request_url, headers=request_header, timeout=60)
2273
- if response.ok:
2274
- return self.parse_request_response(response)
2275
- # Check if Session has expired - then re-authenticate and try once more
2276
- elif response.status_code == 401 and retries == 0:
2277
- logger.warning("Session has expired - try to re-authenticate...")
2278
- self.authenticate(revalidate=True)
2279
- request_header = self.request_header()
2280
- retries += 1
2281
- elif response.status_code in [502, 503, 504] and retries < 3:
2282
- logger.warning(
2283
- "M365 Graph API delivered server side error -> %s; retrying in %s seconds...",
2284
- response.status_code,
2285
- (retries + 1) * 60,
2286
- )
2287
- time.sleep((retries + 1) * 60)
2288
- retries += 1
2289
- else:
2290
- logger.error(
2291
- "Failed to get list of M365 Teams apps; status -> %s; error -> %s",
2292
- response.status_code,
2293
- response.text,
2294
- )
2295
- return None
2099
+ return self.do_request(
2100
+ url=request_url,
2101
+ method="GET",
2102
+ headers=request_header,
2103
+ timeout=REQUEST_TIMEOUT,
2104
+ failure_message=failure_message,
2105
+ )
2296
2106
 
2297
2107
  # end method definition
2298
2108
 
2299
2109
  def get_teams_app(self, app_id: str) -> dict | None:
2300
- """Get a specific MS Teams app in catalog based on the known app ID
2110
+ """Get a specific MS Teams app in catalog based on the known (internal) app ID
2301
2111
 
2302
2112
  Args:
2303
- app_id (str): ID of the app
2113
+ app_id (str): ID of the app (this is NOT the external ID but the internal ID)
2304
2114
  Returns:
2305
2115
  dict: response of the MS Graph API call or None if the call fails.
2116
+
2117
+ Examle response:
2118
+ {
2119
+ '@odata.context': 'https://graph.microsoft.com/v1.0/$metadata#appCatalogs/teamsApps(appDefinitions())/$entity',
2120
+ 'id': 'ccabe3fb-316f-40e0-a486-1659682cb8cd',
2121
+ 'externalId': 'dd4af790-d8ff-47a0-87ad-486318272c7a',
2122
+ 'displayName': 'Extended ECM',
2123
+ 'distributionMethod': 'organization',
2124
+ 'appDefinitions@odata.context': "https://graph.microsoft.com/v1.0/$metadata#appCatalogs/teamsApps('ccabe3fb-316f-40e0-a486-1659682cb8cd')/appDefinitions",
2125
+ 'appDefinitions': [
2126
+ {
2127
+ 'id': 'Y2NhYmUzZmItMzE2Zi00MGUwLWE0ODYtMTY1OTY4MmNiOGNkIyMyNC4yLjAjI1B1Ymxpc2hlZA==',
2128
+ 'teamsAppId': 'ccabe3fb-316f-40e0-a486-1659682cb8cd',
2129
+ 'displayName': 'Extended ECM',
2130
+ 'version': '24.2.0',
2131
+ 'publishingState': 'published',
2132
+ 'shortDescription': 'Add a tab for an Extended ECM business workspace.',
2133
+ 'description': 'View and interact with OpenText Extended ECM business workspaces',
2134
+ 'lastModifiedDateTime': None,
2135
+ 'createdBy': None,
2136
+ 'authorization': {...}
2137
+ }
2138
+ ]
2139
+ }
2306
2140
  """
2307
2141
 
2308
2142
  query = {"$expand": "AppDefinitions"}
2309
2143
  encoded_query = urllib.parse.urlencode(query, doseq=True)
2310
2144
  request_url = self.config()["teamsAppsUrl"] + "/" + app_id + "?" + encoded_query
2311
2145
 
2312
- # request_url = self.config()["teamsAppsUrl"] + "/" + app_id
2313
-
2314
- logger.info(
2315
- "Get MS Teams App with ID -> %s; calling -> %s", app_id, request_url
2146
+ logger.debug(
2147
+ "Get M365 Teams App with ID -> %s; calling -> %s", app_id, request_url
2316
2148
  )
2317
2149
 
2318
2150
  request_header = self.request_header()
2319
2151
 
2320
- retries = 0
2321
- while True:
2322
- response = requests.get(request_url, headers=request_header, timeout=60)
2323
- if response.ok:
2324
- return self.parse_request_response(response)
2325
- # Check if Session has expired - then re-authenticate and try once more
2326
- elif response.status_code == 401 and retries == 0:
2327
- logger.warning("Session has expired - try to re-authenticate...")
2328
- self.authenticate(revalidate=True)
2329
- request_header = self.request_header()
2330
- retries += 1
2331
- elif response.status_code in [502, 503, 504] and retries < 3:
2332
- logger.warning(
2333
- "M365 Graph API delivered server side error -> %s; retrying in %s seconds...",
2334
- response.status_code,
2335
- (retries + 1) * 60,
2336
- )
2337
- time.sleep((retries + 1) * 60)
2338
- retries += 1
2339
- else:
2340
- logger.error(
2341
- "Failed to get list of M365 Teams apps; status -> %s; error -> %s",
2342
- response.status_code,
2343
- response.text,
2344
- )
2345
- return None
2152
+ return self.do_request(
2153
+ url=request_url,
2154
+ method="GET",
2155
+ headers=request_header,
2156
+ timeout=REQUEST_TIMEOUT,
2157
+ failure_message="Failed to get M365 Teams app with ID -> {}".format(app_id),
2158
+ )
2346
2159
 
2347
2160
  # end method definition
2348
2161
 
@@ -2370,7 +2183,8 @@ class M365(object):
2370
2183
  + "/teamwork/installedApps?"
2371
2184
  + encoded_query
2372
2185
  )
2373
- logger.info(
2186
+
2187
+ logger.debug(
2374
2188
  "Get list of M365 Teams Apps for user -> %s using query -> %s; calling -> %s",
2375
2189
  user_id,
2376
2190
  query,
@@ -2379,33 +2193,15 @@ class M365(object):
2379
2193
 
2380
2194
  request_header = self.request_header()
2381
2195
 
2382
- retries = 0
2383
- while True:
2384
- response = requests.get(request_url, headers=request_header, timeout=60)
2385
- if response.ok:
2386
- return self.parse_request_response(response)
2387
- # Check if Session has expired - then re-authenticate and try once more
2388
- elif response.status_code == 401 and retries == 0:
2389
- logger.warning("Session has expired - try to re-authenticate...")
2390
- self.authenticate(revalidate=True)
2391
- request_header = self.request_header()
2392
- retries += 1
2393
- elif response.status_code in [502, 503, 504] and retries < 3:
2394
- logger.warning(
2395
- "M365 Graph API delivered server side error -> %s; retrying in %s seconds...",
2396
- response.status_code,
2397
- (retries + 1) * 60,
2398
- )
2399
- time.sleep((retries + 1) * 60)
2400
- retries += 1
2401
- else:
2402
- logger.error(
2403
- "Failed to get list of M365 Teams Apps for user -> %s; status -> %s; error -> %s",
2404
- user_id,
2405
- response.status_code,
2406
- response.text,
2407
- )
2408
- return None
2196
+ return self.do_request(
2197
+ url=request_url,
2198
+ method="GET",
2199
+ headers=request_header,
2200
+ timeout=REQUEST_TIMEOUT,
2201
+ failure_message="Failed to get M365 Teams apps for user -> {}".format(
2202
+ user_id
2203
+ ),
2204
+ )
2409
2205
 
2410
2206
  # end method definition
2411
2207
 
@@ -2433,7 +2229,8 @@ class M365(object):
2433
2229
  + "/installedApps?"
2434
2230
  + encoded_query
2435
2231
  )
2436
- logger.info(
2232
+
2233
+ logger.debug(
2437
2234
  "Get list of M365 Teams Apps for M365 Team -> %s using query -> %s; calling -> %s",
2438
2235
  team_id,
2439
2236
  query,
@@ -2442,33 +2239,15 @@ class M365(object):
2442
2239
 
2443
2240
  request_header = self.request_header()
2444
2241
 
2445
- retries = 0
2446
- while True:
2447
- response = requests.get(request_url, headers=request_header, timeout=60)
2448
- if response.ok:
2449
- return self.parse_request_response(response)
2450
- # Check if Session has expired - then re-authenticate and try once more
2451
- elif response.status_code == 401 and retries == 0:
2452
- logger.warning("Session has expired - try to re-authenticate...")
2453
- self.authenticate(revalidate=True)
2454
- request_header = self.request_header()
2455
- retries += 1
2456
- elif response.status_code in [502, 503, 504] and retries < 3:
2457
- logger.warning(
2458
- "M365 Graph API delivered server side error -> %s; retrying in %s seconds...",
2459
- response.status_code,
2460
- (retries + 1) * 60,
2461
- )
2462
- time.sleep((retries + 1) * 60)
2463
- retries += 1
2464
- else:
2465
- logger.error(
2466
- "Failed to get list of M365 Teams Apps for M365 Team -> %s; status -> %s; error -> %s",
2467
- team_id,
2468
- response.status_code,
2469
- response.text,
2470
- )
2471
- return None
2242
+ return self.do_request(
2243
+ url=request_url,
2244
+ method="GET",
2245
+ headers=request_header,
2246
+ timeout=REQUEST_TIMEOUT,
2247
+ failure_message="Failed to get list of M365 Teams apps for M365 Team -> {}".format(
2248
+ team_id
2249
+ ),
2250
+ )
2472
2251
 
2473
2252
  # end method definition
2474
2253
 
@@ -2501,6 +2280,7 @@ class M365(object):
2501
2280
  but requires a token of a user authenticated with username + password.
2502
2281
  See https://learn.microsoft.com/en-us/graph/api/teamsapp-publish
2503
2282
  (permissions table on that page)
2283
+ For updates see: https://learn.microsoft.com/en-us/graph/api/teamsapp-update?view=graph-rest-1.0&tabs=http
2504
2284
 
2505
2285
  Args:
2506
2286
  app_path (str): file path (with directory) to the app package to upload
@@ -2511,6 +2291,34 @@ class M365(object):
2511
2291
  after installation (which is tenant specific)
2512
2292
  Returns:
2513
2293
  dict: Response of the MS GRAPH API REST call or None if the request fails
2294
+
2295
+ The responses are different depending if it is an install or upgrade!!
2296
+
2297
+ Example return for upgrades ("teamsAppId" is the "internal" ID of the app):
2298
+ {
2299
+ '@odata.context': "https://graph.microsoft.com/v1.0/$metadata#appCatalogs/teamsApps('3f749cca-8cb0-4925-9fa0-ba7aca2014af')/appDefinitions/$entity",
2300
+ 'id': 'M2Y3NDljY2EtOGNiMC00OTI1LTlmYTAtYmE3YWNhMjAxNGFmIyMyNC4yLjAjI1B1Ymxpc2hlZA==',
2301
+ 'teamsAppId': '3f749cca-8cb0-4925-9fa0-ba7aca2014af',
2302
+ 'displayName': 'IDEA-TE - Extended ECM 24.2.0',
2303
+ 'version': '24.2.0',
2304
+ 'publishingState': 'published',
2305
+ 'shortDescription': 'Add a tab for an Extended ECM business workspace.',
2306
+ 'description': 'View and interact with OpenText Extended ECM business workspaces',
2307
+ 'lastModifiedDateTime': None,
2308
+ 'createdBy': None,
2309
+ 'authorization': {
2310
+ 'requiredPermissionSet': {...}
2311
+ }
2312
+ }
2313
+
2314
+ Example return for new installations ("id" is the "internal" ID of the app):
2315
+ {
2316
+ '@odata.context': 'https://graph.microsoft.com/v1.0/$metadata#appCatalogs/teamsApps/$entity',
2317
+ 'id': '6c672afd-37fc-46c6-8365-d499aba3808b',
2318
+ 'externalId': 'dd4af790-d8ff-47a0-87ad-486318272c7a',
2319
+ 'displayName': 'OpenText Extended ECM',
2320
+ 'distributionMethod': 'organization'
2321
+ }
2514
2322
  """
2515
2323
 
2516
2324
  if update_existing_app and not app_catalog_id:
@@ -2520,12 +2328,12 @@ class M365(object):
2520
2328
  return None
2521
2329
 
2522
2330
  if not os.path.exists(app_path):
2523
- logger.error("M365 Teams app file -> {} does not exist!")
2331
+ logger.error("M365 Teams app file -> %s does not exist!", app_path)
2524
2332
  return None
2525
2333
 
2526
2334
  # Ensure that the app file is a zip file
2527
2335
  if not app_path.endswith(".zip"):
2528
- logger.error("M365 Teams app file -> {} must be a zip file!")
2336
+ logger.error("M365 Teams app file -> %s must be a zip file!", app_path)
2529
2337
  return None
2530
2338
 
2531
2339
  request_url = self.config()["teamsAppsUrl"]
@@ -2536,7 +2344,7 @@ class M365(object):
2536
2344
 
2537
2345
  # Here we need the credentials of an authenticated user!
2538
2346
  # (not the application credentials (client_id, client_secret))
2539
- request_header = self.request_header_user("application/zip")
2347
+ request_header = self.request_header_user(content_type="application/zip")
2540
2348
 
2541
2349
  with open(app_path, "rb") as f:
2542
2350
  app_data = f.read()
@@ -2545,55 +2353,27 @@ class M365(object):
2545
2353
  # Ensure that the app file contains a manifest.json file
2546
2354
  if "manifest.json" not in z.namelist():
2547
2355
  logger.error(
2548
- "M365 Teams app file -> {} does not contain a manifest.json file!"
2356
+ "M365 Teams app file -> '%s' does not contain a manifest.json file!",
2357
+ app_path,
2549
2358
  )
2550
2359
  return None
2551
2360
 
2552
- logger.info(
2553
- "Upload M365 Teams app -> %s to the MS Teams catalog; calling -> %s",
2361
+ logger.debug(
2362
+ "Upload M365 Teams app -> '%s' to the MS Teams catalog; calling -> %s",
2554
2363
  app_path,
2555
2364
  request_url,
2556
2365
  )
2557
2366
 
2558
- retries = 0
2559
- while True:
2560
- response = requests.post(
2561
- request_url, headers=request_header, data=app_data, timeout=60
2562
- )
2563
- if response.ok:
2564
- return self.parse_request_response(response)
2565
-
2566
- # Check if Session has expired - then re-authenticate and try once more
2567
- if response.status_code == 401 and retries == 0:
2568
- logger.warning("Session has expired - try to re-authenticate...")
2569
- self.authenticate(revalidate=True)
2570
- request_header = self.request_header()
2571
- retries += 1
2572
- elif response.status_code in [502, 503, 504] and retries < 3:
2573
- logger.warning(
2574
- "M365 Graph API delivered server side error -> %s; retrying in %s seconds...",
2575
- response.status_code,
2576
- (retries + 1) * 60,
2577
- )
2578
- time.sleep((retries + 1) * 60)
2579
- retries += 1
2580
- else:
2581
- if update_existing_app:
2582
- logger.warning(
2583
- "Failed to update existing M365 Teams app -> %s (may be because it is not a new version); status -> %s; error -> %s",
2584
- app_path,
2585
- response.status_code,
2586
- response.text,
2587
- )
2588
-
2589
- else:
2590
- logger.error(
2591
- "Failed to upload new M365 Teams app -> %s; status -> %s; error -> %s",
2592
- app_path,
2593
- response.status_code,
2594
- response.text,
2595
- )
2596
- return None
2367
+ return self.do_request(
2368
+ url=request_url,
2369
+ method="POST",
2370
+ headers=request_header,
2371
+ data=app_data,
2372
+ timeout=REQUEST_TIMEOUT,
2373
+ failure_message="Failed to update existing M365 Teams app -> '{}' (may be because it is not a new version)".format(
2374
+ app_path
2375
+ ),
2376
+ )
2597
2377
 
2598
2378
  # end method definition
2599
2379
 
@@ -2610,11 +2390,13 @@ class M365(object):
2610
2390
  request_header = self.request_header_user()
2611
2391
 
2612
2392
  # Make the DELETE request to remove the app from the app catalog
2613
- response = requests.delete(request_url, headers=request_header, timeout=60)
2393
+ response = requests.delete(
2394
+ request_url, headers=request_header, timeout=REQUEST_TIMEOUT
2395
+ )
2614
2396
 
2615
2397
  # Check the status code of the response
2616
2398
  if response.status_code == 204:
2617
- logger.info(
2399
+ logger.debug(
2618
2400
  "The M365 Teams app with ID -> %s has been successfully removed from the app catalog.",
2619
2401
  app_id,
2620
2402
  )
@@ -2627,93 +2409,111 @@ class M365(object):
2627
2409
 
2628
2410
  # end method definition
2629
2411
 
2630
- def assign_teams_app_to_user(self, user_id: str, app_name: str) -> dict | None:
2412
+ def assign_teams_app_to_user(
2413
+ self,
2414
+ user_id: str,
2415
+ app_name: str = "",
2416
+ app_internal_id: str = "",
2417
+ show_error: bool = False,
2418
+ ) -> dict | None:
2631
2419
  """Assigns (adds) a M365 Teams app to a M365 user.
2632
2420
 
2421
+ See: https://learn.microsoft.com/en-us/graph/api/userteamwork-post-installedapps?view=graph-rest-1.0&tabs=http
2422
+
2633
2423
  Args:
2634
2424
  user_id (str): M365 GUID of the user (can also be the M365 email of the user)
2635
- app_name (str): exact name of the app
2425
+ app_name (str, optional): exact name of the app. Not needed if app_internal_id is provided
2426
+ app_internal_id (str, optional): internal ID of the app. If not provided it will be derived from app_name
2427
+ show_error (bool): whether or not an error should be displayed if the
2428
+ user is not found.
2636
2429
  Returns:
2637
2430
  dict: response of the MS Graph API call or None if the call fails.
2638
2431
  """
2639
2432
 
2640
- response = self.get_teams_apps(f"contains(displayName, '{app_name}')")
2641
- app_id = self.get_result_value(response, "id", 0)
2642
- if not app_id:
2643
- logger.error("M365 Teams App -> %s not found!", app_name)
2433
+ if not app_internal_id and not app_name:
2434
+ logger.error(
2435
+ "Either the internal App ID or the App name need to be provided!"
2436
+ )
2644
2437
  return None
2645
2438
 
2439
+ if not app_internal_id:
2440
+ response = self.get_teams_apps(
2441
+ filter_expression="contains(displayName, '{}')".format(app_name)
2442
+ )
2443
+ app_internal_id = self.get_result_value(
2444
+ response=response, key="id", index=0
2445
+ )
2446
+ if not app_internal_id:
2447
+ logger.error(
2448
+ "M365 Teams App -> '%s' not found! Cannot assign App to user -> %s.",
2449
+ app_name,
2450
+ user_id,
2451
+ )
2452
+ return None
2453
+
2646
2454
  request_url = (
2647
2455
  self.config()["usersUrl"] + "/" + user_id + "/teamwork/installedApps"
2648
2456
  )
2649
2457
  request_header = self.request_header()
2650
2458
 
2651
2459
  post_body = {
2652
- "teamsApp@odata.bind": self.config()["teamsAppsUrl"] + "/" + app_id
2460
+ "teamsApp@odata.bind": self.config()["teamsAppsUrl"] + "/" + app_internal_id
2653
2461
  }
2654
2462
 
2655
- logger.info(
2656
- "Assign M365 Teams app -> %s (%s) to M365 user -> %s; calling -> %s",
2463
+ logger.debug(
2464
+ "Assign M365 Teams app -> '%s' (%s) to M365 user -> %s; calling -> %s",
2657
2465
  app_name,
2658
- app_id,
2466
+ app_internal_id,
2659
2467
  user_id,
2660
2468
  request_url,
2661
2469
  )
2662
2470
 
2663
- retries = 0
2664
- while True:
2665
- response = requests.post(
2666
- request_url, json=post_body, headers=request_header, timeout=60
2667
- )
2668
- if response.ok:
2669
- return self.parse_request_response(response)
2670
- # Check if Session has expired - then re-authenticate and try once more
2671
- elif response.status_code == 401 and retries == 0:
2672
- logger.warning("Session has expired - try to re-authenticate...")
2673
- self.authenticate(revalidate=True)
2674
- request_header = self.request_header()
2675
- retries += 1
2676
- elif response.status_code in [502, 503, 504] and retries < 3:
2677
- logger.warning(
2678
- "M365 Graph API delivered server side error -> %s; retrying in %s seconds...",
2679
- response.status_code,
2680
- (retries + 1) * 60,
2681
- )
2682
- time.sleep((retries + 1) * 60)
2683
- retries += 1
2684
- else:
2685
- logger.error(
2686
- "Failed to assign M365 Teams app -> %s (%s) to M365 user -> %s; status -> %s; error -> %s",
2687
- app_name,
2688
- app_id,
2689
- user_id,
2690
- response.status_code,
2691
- response.text,
2692
- )
2693
- return None
2471
+ return self.do_request(
2472
+ url=request_url,
2473
+ method="POST",
2474
+ headers=request_header,
2475
+ json_data=post_body,
2476
+ timeout=REQUEST_TIMEOUT,
2477
+ failure_message="Failed to assign M365 Teams app -> '{}' ({}) to M365 user -> {}".format(
2478
+ app_name, app_internal_id, user_id
2479
+ ),
2480
+ warning_message="Failed to assign M365 Teams app -> '{}' ({}) to M365 user -> {} (could be because the app is assigned organization-wide)".format(
2481
+ app_name, app_internal_id, user_id
2482
+ ),
2483
+ show_error=show_error,
2484
+ )
2694
2485
 
2695
2486
  # end method definition
2696
2487
 
2697
- def upgrade_teams_app_of_user(self, user_id: str, app_name: str) -> dict | None:
2488
+ def upgrade_teams_app_of_user(
2489
+ self, user_id: str, app_name: str, app_installation_id: str | None = None
2490
+ ) -> dict | None:
2698
2491
  """Upgrade a MS teams app for a user. The call will fail if the user does not
2699
2492
  already have the app assigned. So this needs to be checked before
2700
2493
  calling this method.
2494
+ See: https://learn.microsoft.com/en-us/graph/api/userteamwork-teamsappinstallation-upgrade?view=graph-rest-1.0&tabs=http
2701
2495
 
2702
2496
  Args:
2703
2497
  user_id (str): M365 GUID of the user (can also be the M365 email of the user)
2704
2498
  app_name (str): exact name of the app
2499
+ app_installation_id (str): ID of the app installation for the user. This is neither the internal nor
2500
+ external app ID. It is specific for each user and app.
2705
2501
  Returns:
2706
2502
  dict: response of the MS Graph API call or None if the call fails.
2707
2503
  """
2708
2504
 
2709
- response = self.get_teams_apps_of_user(
2710
- user_id, "contains(teamsAppDefinition/displayName, '{}')".format(app_name)
2711
- )
2712
- # Retrieve the installation specific App ID - this is different from thew App catalalog ID!!
2713
- app_installation_id = self.get_result_value(response, "id", 0)
2505
+ if not app_installation_id:
2506
+ response = self.get_teams_apps_of_user(
2507
+ user_id=user_id,
2508
+ filter_expression="contains(teamsAppDefinition/displayName, '{}')".format(
2509
+ app_name
2510
+ ),
2511
+ )
2512
+ # Retrieve the installation specific App ID - this is different from thew App catalalog ID!!
2513
+ app_installation_id = self.get_result_value(response, "id", 0)
2714
2514
  if not app_installation_id:
2715
2515
  logger.error(
2716
- "M365 Teams app -> %s not found for user with ID -> %s. Cannot upgrade app for this user!",
2516
+ "M365 Teams app -> '%s' not found for user with ID -> %s. Cannot upgrade app for this user!",
2717
2517
  app_name,
2718
2518
  user_id,
2719
2519
  )
@@ -2729,43 +2529,83 @@ class M365(object):
2729
2529
  )
2730
2530
  request_header = self.request_header()
2731
2531
 
2732
- logger.info(
2733
- "Upgrade M365 Teams app -> %s (%s) of M365 user with ID -> %s; calling -> %s",
2532
+ logger.debug(
2533
+ "Upgrade M365 Teams app -> '%s' (%s) of M365 user with ID -> %s; calling -> %s",
2734
2534
  app_name,
2735
2535
  app_installation_id,
2736
2536
  user_id,
2737
2537
  request_url,
2738
2538
  )
2739
2539
 
2740
- retries = 0
2741
- while True:
2742
- response = requests.post(request_url, headers=request_header, timeout=60)
2743
- if response.ok:
2744
- return self.parse_request_response(response)
2745
- # Check if Session has expired - then re-authenticate and try once more
2746
- elif response.status_code == 401 and retries == 0:
2747
- logger.warning("Session has expired - try to re-authenticate...")
2748
- self.authenticate(revalidate=True)
2749
- request_header = self.request_header()
2750
- retries += 1
2751
- elif response.status_code in [502, 503, 504] and retries < 3:
2752
- logger.warning(
2753
- "M365 Graph API delivered server side error -> %s; retrying in %s seconds...",
2754
- response.status_code,
2755
- (retries + 1) * 60,
2756
- )
2757
- time.sleep((retries + 1) * 60)
2758
- retries += 1
2759
- else:
2760
- logger.error(
2761
- "Failed to upgrade M365 Teams app -> %s (%s) of M365 user -> %s; status -> %s; error -> %s",
2762
- app_name,
2763
- app_installation_id,
2764
- user_id,
2765
- response.status_code,
2766
- response.text,
2767
- )
2768
- return None
2540
+ return self.do_request(
2541
+ url=request_url,
2542
+ method="POST",
2543
+ headers=request_header,
2544
+ timeout=REQUEST_TIMEOUT,
2545
+ failure_message="Failed to upgrade M365 Teams app -> '{}' ({}) of M365 user -> {}".format(
2546
+ app_name, app_installation_id, user_id
2547
+ ),
2548
+ )
2549
+
2550
+ # end method definition
2551
+
2552
+ def remove_teams_app_from_user(
2553
+ self, user_id: str, app_name: str, app_installation_id: str | None = None
2554
+ ) -> dict | None:
2555
+ """Remove a M365 Teams app from a M365 user.
2556
+
2557
+ See: https://learn.microsoft.com/en-us/graph/api/userteamwork-delete-installedapps?view=graph-rest-1.0&tabs=http
2558
+
2559
+ Args:
2560
+ user_id (str): M365 GUID of the user (can also be the M365 email of the user)
2561
+ app_name (str): exact name of the app
2562
+ Returns:
2563
+ dict: response of the MS Graph API call or None if the call fails.
2564
+ """
2565
+
2566
+ if not app_installation_id:
2567
+ response = self.get_teams_apps_of_user(
2568
+ user_id=user_id,
2569
+ filter_expression="contains(teamsAppDefinition/displayName, '{}')".format(
2570
+ app_name
2571
+ ),
2572
+ )
2573
+ # Retrieve the installation specific App ID - this is different from thew App catalalog ID!!
2574
+ app_installation_id = self.get_result_value(response, "id", 0)
2575
+ if not app_installation_id:
2576
+ logger.error(
2577
+ "M365 Teams app -> '%s' not found for user with ID -> %s. Cannot remove app from this user!",
2578
+ app_name,
2579
+ user_id,
2580
+ )
2581
+ return None
2582
+
2583
+ request_url = (
2584
+ self.config()["usersUrl"]
2585
+ + "/"
2586
+ + user_id
2587
+ + "/teamwork/installedApps/"
2588
+ + app_installation_id
2589
+ )
2590
+ request_header = self.request_header()
2591
+
2592
+ logger.debug(
2593
+ "Remove M365 Teams app -> '%s' (%s) from M365 user with ID -> %s; calling -> %s",
2594
+ app_name,
2595
+ app_installation_id,
2596
+ user_id,
2597
+ request_url,
2598
+ )
2599
+
2600
+ return self.do_request(
2601
+ url=request_url,
2602
+ method="DELETE",
2603
+ headers=request_header,
2604
+ timeout=REQUEST_TIMEOUT,
2605
+ failure_message="Failed to remove M365 Teams app -> '{}' ({}) from M365 user -> {}".format(
2606
+ app_name, app_installation_id, user_id
2607
+ ),
2608
+ )
2769
2609
 
2770
2610
  # end method definition
2771
2611
 
@@ -2788,43 +2628,24 @@ class M365(object):
2788
2628
  "teamsApp@odata.bind": self.config()["teamsAppsUrl"] + "/" + app_id
2789
2629
  }
2790
2630
 
2791
- logger.info(
2792
- "Assign M365 Teams app -> %s to M365 Team -> %s; calling -> %s",
2631
+ logger.debug(
2632
+ "Assign M365 Teams app -> '%s' (%s) to M365 Team -> %s; calling -> %s",
2633
+ self.config()["teamsAppName"],
2793
2634
  app_id,
2794
2635
  team_id,
2795
2636
  request_url,
2796
2637
  )
2797
2638
 
2798
- retries = 0
2799
- while True:
2800
- response = requests.post(
2801
- request_url, json=post_body, headers=request_header, timeout=60
2802
- )
2803
- if response.ok:
2804
- return self.parse_request_response(response)
2805
- # Check if Session has expired - then re-authenticate and try once more
2806
- elif response.status_code == 401 and retries == 0:
2807
- logger.warning("Session has expired - try to re-authenticate...")
2808
- self.authenticate(revalidate=True)
2809
- request_header = self.request_header()
2810
- retries += 1
2811
- elif response.status_code in [502, 503, 504] and retries < 3:
2812
- logger.warning(
2813
- "M365 Graph API delivered server side error -> %s; retrying in %s seconds...",
2814
- response.status_code,
2815
- (retries + 1) * 60,
2816
- )
2817
- time.sleep((retries + 1) * 60)
2818
- retries += 1
2819
- else:
2820
- logger.error(
2821
- "Failed to assign M365 Teams app -> %s to M365 Team -> %s; status -> %s; error -> %s",
2822
- app_id,
2823
- team_id,
2824
- response.status_code,
2825
- response.text,
2826
- )
2827
- return None
2639
+ return self.do_request(
2640
+ url=request_url,
2641
+ method="POST",
2642
+ headers=request_header,
2643
+ json_data=post_body,
2644
+ timeout=REQUEST_TIMEOUT,
2645
+ failure_message="Failed to assign M365 Teams app -> '{}' ({}) to M365 Team -> {}".format(
2646
+ self.config()["teamsAppName"], app_id, team_id
2647
+ ),
2648
+ )
2828
2649
 
2829
2650
  # end method definition
2830
2651
 
@@ -2848,7 +2669,7 @@ class M365(object):
2848
2669
  app_installation_id = self.get_result_value(response, "id", 0)
2849
2670
  if not app_installation_id:
2850
2671
  logger.error(
2851
- "M365 Teams app -> %s not found for M365 Team with ID -> %s. Cannot upgrade app for this team!",
2672
+ "M365 Teams app -> '%s' not found for M365 Team with ID -> %s. Cannot upgrade app for this team!",
2852
2673
  app_name,
2853
2674
  team_id,
2854
2675
  )
@@ -2864,43 +2685,23 @@ class M365(object):
2864
2685
  )
2865
2686
  request_header = self.request_header()
2866
2687
 
2867
- logger.info(
2868
- "Upgrade app -> %s (%s) of M365 team with ID -> %s; calling -> %s",
2688
+ logger.debug(
2689
+ "Upgrade app -> '%s' (%s) of M365 team with ID -> %s; calling -> %s",
2869
2690
  app_name,
2870
2691
  app_installation_id,
2871
2692
  team_id,
2872
2693
  request_url,
2873
2694
  )
2874
2695
 
2875
- retries = 0
2876
- while True:
2877
- response = requests.post(request_url, headers=request_header, timeout=60)
2878
- if response.ok:
2879
- return self.parse_request_response(response)
2880
- # Check if Session has expired - then re-authenticate and try once more
2881
- elif response.status_code == 401 and retries == 0:
2882
- logger.warning("Session has expired - try to re-authenticate...")
2883
- self.authenticate(revalidate=True)
2884
- request_header = self.request_header()
2885
- retries += 1
2886
- elif response.status_code in [502, 503, 504] and retries < 3:
2887
- logger.warning(
2888
- "M365 Graph API delivered server side error -> %s; retrying in %s seconds...",
2889
- response.status_code,
2890
- (retries + 1) * 60,
2891
- )
2892
- time.sleep((retries + 1) * 60)
2893
- retries += 1
2894
- else:
2895
- logger.error(
2896
- "Failed to upgrade app -> %s (%s) of M365 team with ID -> %s; status -> %s; error -> %s",
2897
- app_name,
2898
- app_installation_id,
2899
- team_id,
2900
- response.status_code,
2901
- response.text,
2902
- )
2903
- return None
2696
+ return self.do_request(
2697
+ url=request_url,
2698
+ method="POST",
2699
+ headers=request_header,
2700
+ timeout=REQUEST_TIMEOUT,
2701
+ failure_message="Failed to upgrade M365 Teams app -> '{}' ({}) of M365 team with ID -> {}".format(
2702
+ app_name, app_installation_id, team_id
2703
+ ),
2704
+ )
2904
2705
 
2905
2706
  # end method definition
2906
2707
 
@@ -2945,8 +2746,10 @@ class M365(object):
2945
2746
  None,
2946
2747
  )
2947
2748
  if not channel:
2948
- logger.erro(
2949
- "Cannot find Channel -> %s on M365 Team -> %s", channel_name, team_name
2749
+ logger.error(
2750
+ "Cannot find Channel -> '%s' on M365 Team -> '%s'",
2751
+ channel_name,
2752
+ team_name,
2950
2753
  )
2951
2754
  return None
2952
2755
  channel_id = channel["id"]
@@ -2974,8 +2777,8 @@ class M365(object):
2974
2777
  },
2975
2778
  }
2976
2779
 
2977
- logger.info(
2978
- "Add Tab -> %s with App ID -> %s to Channel -> %s of Microsoft 365 Team -> %s; calling -> %s",
2780
+ logger.debug(
2781
+ "Add Tab -> '%s' with App ID -> %s to Channel -> '%s' of Microsoft 365 Team -> '%s'; calling -> %s",
2979
2782
  tab_name,
2980
2783
  app_id,
2981
2784
  channel_name,
@@ -2983,39 +2786,16 @@ class M365(object):
2983
2786
  request_url,
2984
2787
  )
2985
2788
 
2986
- retries = 0
2987
- while True:
2988
- response = requests.post(
2989
- request_url, headers=request_header, json=tab_config, timeout=60
2990
- )
2991
- if response.ok:
2992
- return self.parse_request_response(response)
2993
- # Check if Session has expired - then re-authenticate and try once more
2994
- elif response.status_code == 401 and retries == 0:
2995
- logger.warning("Session has expired - try to re-authenticate...")
2996
- self.authenticate(revalidate=True)
2997
- request_header = self.request_header()
2998
- retries += 1
2999
- elif response.status_code in [502, 503, 504] and retries < 3:
3000
- logger.warning(
3001
- "M365 Graph API delivered server side error -> %s; retrying in %s seconds...",
3002
- response.status_code,
3003
- (retries + 1) * 60,
3004
- )
3005
- time.sleep((retries + 1) * 60)
3006
- retries += 1
3007
- else:
3008
- logger.error(
3009
- "Failed to add Tab for M365 Team -> %s (%s) and Channel -> %s (%s); status -> %s; error -> %s; tab config -> %s",
3010
- team_name,
3011
- team_id,
3012
- channel_name,
3013
- channel_id,
3014
- response.status_code,
3015
- response.text,
3016
- str(tab_config),
3017
- )
3018
- return None
2789
+ return self.do_request(
2790
+ url=request_url,
2791
+ method="POST",
2792
+ headers=request_header,
2793
+ json_data=tab_config,
2794
+ timeout=REQUEST_TIMEOUT,
2795
+ failure_message="Failed to add Tab for M365 Team -> '{}' ({}) and Channel -> '{}' ({})".format(
2796
+ team_name, team_id, channel_name, channel_id
2797
+ ),
2798
+ )
3019
2799
 
3020
2800
  # end method definition
3021
2801
 
@@ -3058,8 +2838,10 @@ class M365(object):
3058
2838
  None,
3059
2839
  )
3060
2840
  if not channel:
3061
- logger.erro(
3062
- "Cannot find Channel -> %s for M365 Team -> %s", channel_name, team_name
2841
+ logger.error(
2842
+ "Cannot find Channel -> '%s' for M365 Team -> '%s'",
2843
+ channel_name,
2844
+ team_name,
3063
2845
  )
3064
2846
  return None
3065
2847
  channel_id = channel["id"]
@@ -3075,8 +2857,8 @@ class M365(object):
3075
2857
  None,
3076
2858
  )
3077
2859
  if not tab:
3078
- logger.erro(
3079
- "Cannot find Tab -> %s on M365 Team -> %s (%s) and Channel -> %s (%s)",
2860
+ logger.error(
2861
+ "Cannot find Tab -> '%s' on M365 Team -> '%s' (%s) and Channel -> '%s' (%s)",
3080
2862
  tab_name,
3081
2863
  team_name,
3082
2864
  team_id,
@@ -3108,8 +2890,8 @@ class M365(object):
3108
2890
  },
3109
2891
  }
3110
2892
 
3111
- logger.info(
3112
- "Update Tab -> %s (%s) of Channel -> %s (%s) for Microsoft 365 Teams -> %s (%s) with configuration -> %s; calling -> %s",
2893
+ logger.debug(
2894
+ "Update Tab -> '%s' (%s) of Channel -> '%s' (%s) for Microsoft 365 Teams -> '%s' (%s) with configuration -> %s; calling -> %s",
3113
2895
  tab_name,
3114
2896
  tab_id,
3115
2897
  channel_name,
@@ -3120,40 +2902,16 @@ class M365(object):
3120
2902
  request_url,
3121
2903
  )
3122
2904
 
3123
- retries = 0
3124
- while True:
3125
- response = requests.patch(
3126
- request_url, headers=request_header, json=tab_config, timeout=60
3127
- )
3128
- if response.ok:
3129
- return self.parse_request_response(response)
3130
- # Check if Session has expired - then re-authenticate and try once more
3131
- elif response.status_code == 401 and retries == 0:
3132
- logger.warning("Session has expired - try to re-authenticate...")
3133
- self.authenticate(revalidate=True)
3134
- request_header = self.request_header()
3135
- retries += 1
3136
- elif response.status_code in [502, 503, 504] and retries < 3:
3137
- logger.warning(
3138
- "M365 Graph API delivered server side error -> %s; retrying in %s seconds...",
3139
- response.status_code,
3140
- (retries + 1) * 60,
3141
- )
3142
- time.sleep((retries + 1) * 60)
3143
- retries += 1
3144
- else:
3145
- logger.error(
3146
- "Failed to update Tab -> %s (%s) for M365 Team -> %s (%s) and Channel -> %s (%s); status -> %s; error -> %s",
3147
- tab_name,
3148
- tab_id,
3149
- team_name,
3150
- team_id,
3151
- channel_name,
3152
- channel_id,
3153
- response.status_code,
3154
- response.text,
3155
- )
3156
- return None
2905
+ return self.do_request(
2906
+ url=request_url,
2907
+ method="PATCH",
2908
+ headers=request_header,
2909
+ json_data=tab_config,
2910
+ timeout=REQUEST_TIMEOUT,
2911
+ failure_message="Failed to update Tab -> '{}' ({}) for M365 Team -> '{}' ({}) and Channel -> '{}' ({})".format(
2912
+ tab_name, tab_id, team_name, team_id, channel_name, channel_id
2913
+ ),
2914
+ )
3157
2915
 
3158
2916
  # end method definition
3159
2917
 
@@ -3189,8 +2947,10 @@ class M365(object):
3189
2947
  None,
3190
2948
  )
3191
2949
  if not channel:
3192
- logger.erro(
3193
- "Cannot find Channel -> %s for M365 Team -> %s", channel_name, team_name
2950
+ logger.error(
2951
+ "Cannot find Channel -> '%s' for M365 Team -> '%s'",
2952
+ channel_name,
2953
+ team_name,
3194
2954
  )
3195
2955
  return False
3196
2956
  channel_id = channel["id"]
@@ -3206,8 +2966,8 @@ class M365(object):
3206
2966
  item for item in response["value"] if item["displayName"] == tab_name
3207
2967
  ]
3208
2968
  if not tab_list:
3209
- logger.erro(
3210
- "Cannot find Tabs with name -> %s on M365 Team -> %s (%s) and Channel -> %s (%s)",
2969
+ logger.error(
2970
+ "Cannot find Tab -> '%s' on M365 Team -> '%s' (%s) and Channel -> '%s' (%s)",
3211
2971
  tab_name,
3212
2972
  team_name,
3213
2973
  team_id,
@@ -3231,8 +2991,8 @@ class M365(object):
3231
2991
 
3232
2992
  request_header = self.request_header()
3233
2993
 
3234
- logger.info(
3235
- "Delete Tab -> %s (%s) from Channel -> %s (%s) of Microsoft 365 Teams -> %s (%s); calling -> %s",
2994
+ logger.debug(
2995
+ "Delete Tab -> '%s' (%s) from Channel -> '%s' (%s) of Microsoft 365 Teams -> '%s' (%s); calling -> %s",
3236
2996
  tab_name,
3237
2997
  tab_id,
3238
2998
  channel_name,
@@ -3242,49 +3002,23 @@ class M365(object):
3242
3002
  request_url,
3243
3003
  )
3244
3004
 
3245
- retries = 0
3246
- while True:
3247
- response = requests.delete(
3248
- request_url, headers=request_header, timeout=60
3249
- )
3250
- if response.ok:
3251
- logger.info(
3252
- "Tab -> %s (%s) has been deleted from Channel -> %s (%s) of Microsoft 365 Teams -> %s (%s)",
3253
- tab_name,
3254
- tab_id,
3255
- channel_name,
3256
- channel_id,
3257
- team_name,
3258
- team_id,
3259
- )
3260
- break
3261
- # Check if Session has expired - then re-authenticate and try once more
3262
- elif response.status_code == 401 and retries == 0:
3263
- logger.warning("Session has expired - try to re-authenticate...")
3264
- self.authenticate(revalidate=True)
3265
- request_header = self.request_header()
3266
- retries += 1
3267
- elif response.status_code in [502, 503, 504] and retries < 3:
3268
- logger.warning(
3269
- "M365 Graph API delivered server side error -> %s; retrying in %s seconds...",
3270
- response.status_code,
3271
- (retries + 1) * 60,
3272
- )
3273
- time.sleep((retries + 1) * 60)
3274
- retries += 1
3275
- else:
3276
- logger.error(
3277
- "Failed to delete Tab -> %s (%s) for M365 Team -> %s (%s) and Channel -> %s (%s); status -> %s; error -> %s",
3278
- tab_name,
3279
- tab_id,
3280
- team_name,
3281
- team_id,
3282
- channel_name,
3283
- channel_id,
3284
- response.status_code,
3285
- response.text,
3286
- )
3287
- return False
3005
+ response = self.do_request(
3006
+ url=request_url,
3007
+ method="DELETE",
3008
+ headers=request_header,
3009
+ timeout=REQUEST_TIMEOUT,
3010
+ failure_message="Failed to delete Tab -> '{}' ({}) for M365 Team -> '{}' ({}) and Channel -> '{}' ({})".format(
3011
+ tab_name, tab_id, team_name, team_id, channel_name, channel_id
3012
+ ),
3013
+ parse_request_response=False,
3014
+ )
3015
+
3016
+ if response and response.ok:
3017
+ break
3018
+ else:
3019
+ return False
3020
+ # end for tab in tab_list
3021
+
3288
3022
  return True
3289
3023
 
3290
3024
  # end method definition
@@ -3334,22 +3068,25 @@ class M365(object):
3334
3068
  request_url = self.config()["securityUrl"] + "/sensitivityLabels"
3335
3069
  request_header = self.request_header()
3336
3070
 
3337
- logger.info(
3338
- "Create M365 sensitivity label -> %s; calling -> %s", name, request_url
3071
+ logger.debug(
3072
+ "Create M365 sensitivity label -> '%s'; calling -> %s", name, request_url
3339
3073
  )
3340
3074
 
3341
3075
  # Send the POST request to create the label
3342
3076
  response = requests.post(
3343
- request_url, headers=request_header, data=json.dumps(payload), timeout=60
3077
+ request_url,
3078
+ headers=request_header,
3079
+ data=json.dumps(payload),
3080
+ timeout=REQUEST_TIMEOUT,
3344
3081
  )
3345
3082
 
3346
3083
  # Check the response status code
3347
3084
  if response.status_code == 201:
3348
- logger.info("Label -> %s has been created successfully!", name)
3085
+ logger.debug("Label -> '%s' has been created successfully!", name)
3349
3086
  return response
3350
3087
  else:
3351
3088
  logger.error(
3352
- "Failed to create the M365 label -> %s! Response status code -> %s",
3089
+ "Failed to create the M365 label -> '%s'! Response status code -> %s",
3353
3090
  name,
3354
3091
  response.status_code,
3355
3092
  )
@@ -3377,43 +3114,23 @@ class M365(object):
3377
3114
  )
3378
3115
  request_header = self.request_header()
3379
3116
 
3380
- logger.info(
3381
- "Assign label -> %s to user -> %s; calling -> %s",
3117
+ logger.debug(
3118
+ "Assign label -> '%s' to user -> '%s'; calling -> %s",
3382
3119
  label_name,
3383
3120
  user_email,
3384
3121
  request_url,
3385
3122
  )
3386
3123
 
3387
- retries = 0
3388
- while True:
3389
- response = requests.post(
3390
- request_url, headers=request_header, json=body, timeout=60
3391
- )
3392
- if response.ok:
3393
- return self.parse_request_response(response)
3394
- # Check if Session has expired - then re-authenticate and try once more
3395
- elif response.status_code == 401 and retries == 0:
3396
- logger.warning("Session has expired - try to re-authenticate...")
3397
- self.authenticate(revalidate=True)
3398
- request_header = self.request_header()
3399
- retries += 1
3400
- elif response.status_code in [502, 503, 504] and retries < 3:
3401
- logger.warning(
3402
- "M365 Graph API delivered server side error -> %s; retrying in %s seconds...",
3403
- response.status_code,
3404
- (retries + 1) * 60,
3405
- )
3406
- time.sleep((retries + 1) * 60)
3407
- retries += 1
3408
- else:
3409
- logger.error(
3410
- "Failed to assign label -> %s to M365 user -> %s; status -> %s; error -> %s",
3411
- label_name,
3412
- user_email,
3413
- response.status_code,
3414
- response.text,
3415
- )
3416
- return None
3124
+ return self.do_request(
3125
+ url=request_url,
3126
+ method="POST",
3127
+ headers=request_header,
3128
+ json_data=body,
3129
+ timeout=REQUEST_TIMEOUT,
3130
+ failure_message="Failed to assign label -> '{}' to M365 user -> '{}'".format(
3131
+ label_name, user_email
3132
+ ),
3133
+ )
3417
3134
 
3418
3135
  # end method definition
3419
3136
 
@@ -3438,7 +3155,7 @@ class M365(object):
3438
3155
 
3439
3156
  # request_header = self.request_header()
3440
3157
 
3441
- logger.info("Install Outlook Add-in from %s (NOT IMPLEMENTED)", app_path)
3158
+ logger.debug("Install Outlook Add-in from -> '%s' (NOT IMPLEMENTED)", app_path)
3442
3159
 
3443
3160
  response = None
3444
3161
 
@@ -3464,39 +3181,21 @@ class M365(object):
3464
3181
  ] + "?$filter=displayName eq '{}'".format(app_registration_name)
3465
3182
  request_header = self.request_header()
3466
3183
 
3467
- logger.info(
3468
- "Get Azure App Registration -> %s; calling -> %s",
3184
+ logger.debug(
3185
+ "Get Azure App Registration -> '%s'; calling -> %s",
3469
3186
  app_registration_name,
3470
3187
  request_url,
3471
3188
  )
3472
3189
 
3473
- retries = 0
3474
- while True:
3475
- response = requests.get(request_url, headers=request_header, timeout=60)
3476
- if response.ok:
3477
- return self.parse_request_response(response)
3478
- # Check if Session has expired - then re-authenticate and try once more
3479
- elif response.status_code == 401 and retries == 0:
3480
- logger.warning("Session has expired - try to re-authenticate...")
3481
- self.authenticate(revalidate=True)
3482
- request_header = self.request_header()
3483
- retries += 1
3484
- elif response.status_code in [502, 503, 504] and retries < 3:
3485
- logger.warning(
3486
- "M365 Graph API delivered server side error -> %s; retrying in %s seconds...",
3487
- response.status_code,
3488
- (retries + 1) * 60,
3489
- )
3490
- time.sleep((retries + 1) * 60)
3491
- retries += 1
3492
- else:
3493
- logger.error(
3494
- "Cannot find Azure App Registration -> %s; status -> %s; error -> %s",
3495
- app_registration_name,
3496
- response.status_code,
3497
- response.text,
3498
- )
3499
- return None
3190
+ return self.do_request(
3191
+ url=request_url,
3192
+ method="GET",
3193
+ headers=request_header,
3194
+ timeout=REQUEST_TIMEOUT,
3195
+ failure_message="Cannot find Azure App Registration -> '{}'".format(
3196
+ app_registration_name
3197
+ ),
3198
+ )
3500
3199
 
3501
3200
  # end method definition
3502
3201
 
@@ -3567,38 +3266,16 @@ class M365(object):
3567
3266
  request_url = self.config()["applicationsUrl"]
3568
3267
  request_header = self.request_header()
3569
3268
 
3570
- retries = 0
3571
- while True:
3572
- response = requests.post(
3573
- request_url,
3574
- headers=request_header,
3575
- json=app_registration_data,
3576
- timeout=60,
3577
- )
3578
- if response.ok:
3579
- return self.parse_request_response(response)
3580
- # Check if Session has expired - then re-authenticate and try once more
3581
- elif response.status_code == 401 and retries == 0:
3582
- logger.warning("Session has expired - try to re-authenticate...")
3583
- self.authenticate(revalidate=True)
3584
- request_header = self.request_header()
3585
- retries += 1
3586
- elif response.status_code in [502, 503, 504] and retries < 3:
3587
- logger.warning(
3588
- "M365 Graph API delivered server side error -> %s; retrying in %s seconds...",
3589
- response.status_code,
3590
- (retries + 1) * 60,
3591
- )
3592
- time.sleep((retries + 1) * 60)
3593
- retries += 1
3594
- else:
3595
- logger.error(
3596
- "Cannot add App Registration -> %s; status -> %s; error -> %s",
3597
- app_registration_name,
3598
- response.status_code,
3599
- response.text,
3600
- )
3601
- return None
3269
+ return self.do_request(
3270
+ url=request_url,
3271
+ method="POST",
3272
+ headers=request_header,
3273
+ json_data=app_registration_data,
3274
+ timeout=REQUEST_TIMEOUT,
3275
+ failure_message="Cannot add App Registration -> '{}'".format(
3276
+ app_registration_name
3277
+ ),
3278
+ )
3602
3279
 
3603
3280
  # end method definition
3604
3281
 
@@ -3632,45 +3309,479 @@ class M365(object):
3632
3309
  request_url = self.config()["applicationsUrl"] + "/" + app_registration_id
3633
3310
  request_header = self.request_header()
3634
3311
 
3635
- logger.info(
3636
- "Update App Registration -> %s (%s); calling -> %s",
3312
+ logger.debug(
3313
+ "Update App Registration -> '%s' (%s); calling -> %s",
3637
3314
  app_registration_name,
3638
3315
  app_registration_id,
3639
3316
  request_url,
3640
3317
  )
3641
3318
 
3319
+ return self.do_request(
3320
+ url=request_url,
3321
+ method="PATCH",
3322
+ headers=request_header,
3323
+ json_data=app_registration_data,
3324
+ timeout=REQUEST_TIMEOUT,
3325
+ failure_message="Cannot update App Registration -> '{}' ({})".format(
3326
+ app_registration_name, app_registration_id
3327
+ ),
3328
+ )
3329
+
3330
+ # end method definition
3331
+
3332
+ def get_mail(
3333
+ self,
3334
+ user_id: str,
3335
+ sender: str,
3336
+ subject: str,
3337
+ num_emails: int | None = None,
3338
+ show_error: bool = False,
3339
+ ) -> dict | None:
3340
+ """Get email from inbox of a given user and a given sender (from)
3341
+ This requires Mail.Read Application permissions for the Azure App being used.
3342
+
3343
+ Args:
3344
+ user_id (str): M365 ID of the user
3345
+ sender (str): sender email address to filter for
3346
+ num_emails (int, optional): number of matching emails to retrieve
3347
+ show_error (bool): whether or not an error should be displayed if the
3348
+ user is not found.
3349
+ Returns:
3350
+ dict: Email or None of the request fails.
3351
+ """
3352
+
3353
+ # Attention: you can easily run in limitation of the MS Graph API. If selection + filtering
3354
+ # is too complex you can get this error: "The restriction or sort order is too complex for this operation."
3355
+ # that's why we first just do the ordering and then do the filtering on sender and subject
3356
+ # separately
3357
+ request_url = (
3358
+ self.config()["usersUrl"]
3359
+ + "/"
3360
+ + user_id
3361
+ # + "/messages?$filter=from/emailAddress/address eq '{}' and contains(subject, '{}')&$orderby=receivedDateTime desc".format(
3362
+ + "/messages?$orderby=receivedDateTime desc"
3363
+ )
3364
+ if num_emails:
3365
+ request_url += "&$top={}".format(num_emails)
3366
+
3367
+ request_header = self.request_header()
3368
+
3369
+ logger.debug(
3370
+ "Get mails for user -> %s from -> '%s' with subject -> '%s'; calling -> %s",
3371
+ user_id,
3372
+ sender,
3373
+ subject,
3374
+ request_url,
3375
+ )
3376
+
3377
+ response = self.do_request(
3378
+ url=request_url,
3379
+ method="GET",
3380
+ headers=request_header,
3381
+ timeout=REQUEST_TIMEOUT,
3382
+ failure_message="Cannot retrieve emails for user -> {}".format(user_id),
3383
+ show_error=show_error,
3384
+ )
3385
+
3386
+ if response and "value" in response:
3387
+ messages = response["value"]
3388
+
3389
+ # Filter the messages by sender and subject in code
3390
+ filtered_messages = [
3391
+ msg
3392
+ for msg in messages
3393
+ if msg.get("from", {}).get("emailAddress", {}).get("address") == sender
3394
+ and subject in msg.get("subject", "")
3395
+ ]
3396
+ response["value"] = filtered_messages
3397
+ return response
3398
+
3399
+ return None
3400
+
3401
+ # end method definition
3402
+
3403
+ def get_mail_body(self, user_id: str, email_id: str) -> str | None:
3404
+ """Get full email body for a given email ID
3405
+ This requires Mail.Read Application permissions for the Azure App being used.
3406
+
3407
+ Args:
3408
+ user_id (str): M365 ID of the user
3409
+ email_id (str): M365 ID of the email
3410
+ Returns:
3411
+ str | None: Email body or None of the request fails.
3412
+ """
3413
+
3414
+ request_url = (
3415
+ self.config()["usersUrl"]
3416
+ + "/"
3417
+ + user_id
3418
+ + "/messages/"
3419
+ + email_id
3420
+ + "/$value"
3421
+ )
3422
+
3423
+ request_header = self.request_header()
3424
+
3425
+ response = self.do_request(
3426
+ url=request_url,
3427
+ method="GET",
3428
+ headers=request_header,
3429
+ timeout=REQUEST_TIMEOUT,
3430
+ failure_message="Cannot get email body for user -> {} and email with ID -> {}".format(
3431
+ user_id,
3432
+ email_id,
3433
+ ),
3434
+ parse_request_response=False,
3435
+ )
3436
+
3437
+ if response and response.ok and response.content:
3438
+ return response.content.decode("utf-8")
3439
+
3440
+ return None
3441
+
3442
+ # end method definition
3443
+
3444
+ def extract_url_from_message_body(
3445
+ self,
3446
+ message_body: str,
3447
+ search_pattern: str,
3448
+ multi_line: bool = False,
3449
+ multi_line_end_marker: str = "%3D",
3450
+ line_end_marker: str = "=",
3451
+ replacements: list | None = None,
3452
+ ) -> str | None:
3453
+ """Parse the email body to extract (a potentially multi-line) URL from the body.
3454
+
3455
+ Args:
3456
+ message_body (str): Text of the Email body
3457
+ search_pattern (str): Pattern thatneeds to be in first line of the URL. This
3458
+ makes sure it is the right URL we are looking for.
3459
+ multi_line (bool, optional): Is the URL spread over multiple lines?. Defaults to False.
3460
+ multi_line_end_marker (str, optional): If it is a multi-line URL, what marks the end
3461
+ of the URL in the last line? Defaults to "%3D".
3462
+ line_end_marker (str, optional): What makrs the end of lines 1-(n-1)? Defaults to "=".
3463
+ Returns:
3464
+ str: URL text thathas been extracted.
3465
+ """
3466
+
3467
+ if not message_body:
3468
+ return None
3469
+
3470
+ # Split all the lines after a CRLF:
3471
+ lines = [line.strip() for line in message_body.split("\r\n")]
3472
+
3473
+ # Filter out the complete URL from the extracted URLs
3474
+ found = False
3475
+
3476
+ url = ""
3477
+
3478
+ for line in lines:
3479
+ if found:
3480
+ # Remove line end marker - many times a "="
3481
+ if line.endswith(line_end_marker):
3482
+ line = line[:-1]
3483
+ for replacement in replacements:
3484
+ line = line.replace(replacement["from"], replacement["to"])
3485
+ # We consider an empty line after we found the URL to indicate the end of the URL:
3486
+ if line == "":
3487
+ break
3488
+ url += line
3489
+ if multi_line and line.endswith(multi_line_end_marker):
3490
+ break
3491
+ if not search_pattern in line:
3492
+ continue
3493
+ # Fine https:// in the current line:
3494
+ index = line.find("https://")
3495
+ if index == -1:
3496
+ continue
3497
+ # If there's any text in front of https in that line cut it:
3498
+ line = line[index:]
3499
+ # Remove line end marker - many times a "="
3500
+ if line.endswith(line_end_marker):
3501
+ line = line[:-1]
3502
+ for replacement in replacements:
3503
+ line = line.replace(replacement["from"], replacement["to"])
3504
+ found = True
3505
+ url += line
3506
+ if not multi_line:
3507
+ break
3508
+
3509
+ return url
3510
+
3511
+ # end method definition
3512
+
3513
+ def delete_mail(self, user_id: str, email_id: str) -> dict | None:
3514
+ """Delete email from inbox of a given user and a given email ID.
3515
+ This requires Mail.ReadWrite Application permissions for the Azure App being used.
3516
+
3517
+ Args:
3518
+ user_id (str): M365 ID of the user
3519
+ email_id (str): M365 ID of the email
3520
+ Returns:
3521
+ dict: Email or None of the request fails.
3522
+ """
3523
+
3524
+ request_url = (
3525
+ self.config()["usersUrl"] + "/" + user_id + "/messages/" + email_id
3526
+ )
3527
+
3528
+ request_header = self.request_header()
3529
+
3530
+ return self.do_request(
3531
+ url=request_url,
3532
+ method="DELETE",
3533
+ headers=request_header,
3534
+ timeout=REQUEST_TIMEOUT,
3535
+ failure_message="Cannot delete email with ID -> {} from inbox of user -> {}".format(
3536
+ email_id, user_id
3537
+ ),
3538
+ )
3539
+
3540
+ # end method definition
3541
+
3542
+ def email_verification(
3543
+ self,
3544
+ user_email: str,
3545
+ sender: str,
3546
+ subject: str,
3547
+ url_search_pattern: str,
3548
+ line_end_marker: str = "=",
3549
+ multi_line: bool = True,
3550
+ multi_line_end_marker: str = "%3D",
3551
+ replacements: list | None = None,
3552
+ max_retries: int = 6,
3553
+ use_browser_automation: bool = False,
3554
+ password: str = "",
3555
+ password_field_id: str = "",
3556
+ password_confirmation_field_id: str = "",
3557
+ password_submit_xpath: str = "",
3558
+ terms_of_service_xpath: str = "",
3559
+ ) -> bool:
3560
+ """Process email verification
3561
+
3562
+ Args:
3563
+ user_email (str): Email address of user recieving the verification mail.
3564
+ sender (str): Email sender (address)
3565
+ subject (str): Email subject to look for (can be substring)
3566
+ url_search_pattern (str): String the URL needs to contain to identify it.
3567
+ multi_line_end_marker (str): If the URL spans multiple lines this is the "end" marker for the last line.
3568
+ replacements (list): if the URL needs some treatment these replacements can be applied.
3569
+ Result:
3570
+ bool: True = Success, False = Failure
3571
+ """
3572
+
3573
+ # Determine the M365 user for the current user by
3574
+ # the email address:
3575
+ m365_user = self.get_user(user_email=user_email)
3576
+ m365_user_id = self.get_result_value(m365_user, "id")
3577
+ if not m365_user_id:
3578
+ logger.warning("Cannot find M365 user -> %s", user_email)
3579
+ return False
3580
+
3581
+ if replacements is None:
3582
+ replacements = [{"from": "=3D", "to": "="}]
3583
+
3642
3584
  retries = 0
3643
- while True:
3644
- response = requests.patch(
3645
- request_url,
3646
- headers=request_header,
3647
- json=app_registration_data,
3648
- timeout=60,
3585
+ while retries < max_retries:
3586
+ response = self.get_mail(
3587
+ user_id=m365_user_id,
3588
+ sender=sender,
3589
+ subject=subject,
3590
+ show_error=False,
3649
3591
  )
3650
- if response.ok:
3651
- return self.parse_request_response(response)
3652
- # Check if Session has expired - then re-authenticate and try once more
3653
- elif response.status_code == 401 and retries == 0:
3654
- logger.warning("Session has expired - try to re-authenticate...")
3655
- self.authenticate(revalidate=True)
3656
- request_header = self.request_header()
3657
- retries += 1
3658
- elif response.status_code in [502, 503, 504] and retries < 3:
3659
- logger.warning(
3660
- "M365 Graph API delivered server side error -> %s; retrying in %s seconds...",
3661
- response.status_code,
3662
- (retries + 1) * 60,
3663
- )
3664
- time.sleep((retries + 1) * 60)
3665
- retries += 1
3592
+ if response and response["value"]:
3593
+ emails = response["value"]
3594
+ # potentially there may be multiple matching emails,
3595
+ # we want the most recent one (from today):
3596
+ latest_email = max(emails, key=lambda x: x["receivedDateTime"])
3597
+ # Extract just the date:
3598
+ latest_email_date = latest_email["receivedDateTime"].split("T")[0]
3599
+ # Get the current date (today):
3600
+ today_date = datetime.today().strftime("%Y-%m-%d")
3601
+ # We do a sanity check here: the verification mail should be from today,
3602
+ # otherwise we assume it is an old mail and we need to wait for the
3603
+ # new verification mail to yet arrive:
3604
+ if latest_email_date != today_date:
3605
+ logger.info(
3606
+ "Verification email not yet received (latest mail from -> %s). Waiting %s seconds...",
3607
+ latest_email_date,
3608
+ 10 * (retries + 1),
3609
+ )
3610
+ time.sleep(10 * (retries + 1))
3611
+ retries += 1
3612
+ continue
3613
+ email_id = latest_email["id"]
3614
+ # The full email body needs to be loaded with a separate REST call:
3615
+ body_text = self.get_mail_body(user_id=m365_user_id, email_id=email_id)
3616
+ # Extract the verification URL.
3617
+ if body_text:
3618
+ url = self.extract_url_from_message_body(
3619
+ message_body=body_text,
3620
+ search_pattern=url_search_pattern,
3621
+ line_end_marker=line_end_marker,
3622
+ multi_line=multi_line,
3623
+ multi_line_end_marker=multi_line_end_marker,
3624
+ replacements=replacements,
3625
+ )
3626
+ else:
3627
+ url = ""
3628
+ if not url:
3629
+ logger.warning("Cannot find verification link in the email body!")
3630
+ return False
3631
+ # Simulate a "click" on this URL:
3632
+ if use_browser_automation:
3633
+ # Core Share needs a full browser:
3634
+ browser_automation_object = BrowserAutomation(
3635
+ take_screenshots=True,
3636
+ automation_name="email-verification",
3637
+ )
3638
+ logger.info(
3639
+ "Open URL -> %s to verify account or email change (using browser automation)",
3640
+ url,
3641
+ )
3642
+ success = browser_automation_object.get_page(url)
3643
+ if success:
3644
+ user_interaction_required = False
3645
+ logger.info(
3646
+ "Successfully opened URL. Browser title is -> '%s'.",
3647
+ browser_automation_object.get_title(),
3648
+ )
3649
+ if password_field_id:
3650
+ password_field = browser_automation_object.find_elem(
3651
+ find_elem=password_field_id, show_error=False
3652
+ )
3653
+ if password_field:
3654
+ # The subsequent processing is only required if
3655
+ # the returned page requests a password change:
3656
+ user_interaction_required = True
3657
+ logger.info(
3658
+ "Found password field on returned page - it seems email verification requests password entry!"
3659
+ )
3660
+ result = browser_automation_object.find_elem_and_set(
3661
+ find_elem=password_field_id,
3662
+ elem_value=password,
3663
+ is_sensitive=True,
3664
+ )
3665
+ if not result:
3666
+ logger.error(
3667
+ "Failed to enter password in field -> '%s'",
3668
+ password_field_id,
3669
+ )
3670
+ success = False
3671
+ else:
3672
+ logger.info(
3673
+ "No user interaction required (no password change or terms of service acceptance)."
3674
+ )
3675
+ if user_interaction_required and password_confirmation_field_id:
3676
+ password_confirm_field = (
3677
+ browser_automation_object.find_elem(
3678
+ find_elem=password_confirmation_field_id,
3679
+ show_error=False,
3680
+ )
3681
+ )
3682
+ if password_confirm_field:
3683
+ logger.info(
3684
+ "Found password confirmation field on returned page - it seems email verification requests consecutive password entry!"
3685
+ )
3686
+ result = browser_automation_object.find_elem_and_set(
3687
+ find_elem=password_confirmation_field_id,
3688
+ elem_value=password,
3689
+ is_sensitive=True,
3690
+ )
3691
+ if not result:
3692
+ logger.error(
3693
+ "Failed to enter password in field -> '%s'",
3694
+ password_confirmation_field_id,
3695
+ )
3696
+ success = False
3697
+ if user_interaction_required and password_submit_xpath:
3698
+ password_submit_button = (
3699
+ browser_automation_object.find_elem(
3700
+ find_elem=password_submit_xpath,
3701
+ find_method="xpath",
3702
+ show_error=False,
3703
+ )
3704
+ )
3705
+ if password_submit_button:
3706
+ logger.info(
3707
+ "Submit password change dialog with button -> '%s' (found with XPath -> %s)",
3708
+ password_submit_button.text,
3709
+ password_submit_xpath,
3710
+ )
3711
+ result = browser_automation_object.find_elem_and_click(
3712
+ find_elem=password_submit_xpath, find_method="xpath"
3713
+ )
3714
+ if not result:
3715
+ logger.error(
3716
+ "Failed to press submit button -> %s",
3717
+ password_submit_xpath,
3718
+ )
3719
+ success = False
3720
+ # The Terms of service dialog has some weird animation
3721
+ # which require a short wait time. It seems it is required!
3722
+ time.sleep(1)
3723
+ terms_accept_button = browser_automation_object.find_elem(
3724
+ find_elem=terms_of_service_xpath,
3725
+ find_method="xpath",
3726
+ show_error=False,
3727
+ )
3728
+ if terms_accept_button:
3729
+ logger.info(
3730
+ "Accept terms of service with button -> '%s' (found with XPath -> %s)",
3731
+ terms_accept_button.text,
3732
+ terms_of_service_xpath,
3733
+ )
3734
+ result = browser_automation_object.find_elem_and_click(
3735
+ find_elem=terms_of_service_xpath,
3736
+ find_method="xpath",
3737
+ )
3738
+ if not result:
3739
+ logger.error(
3740
+ "Failed to accept terms of service with button -> '%s'",
3741
+ terms_accept_button.text,
3742
+ )
3743
+ success = False
3744
+ else:
3745
+ logger.info("No Terms of Service acceptance required.")
3746
+ # end if use_browser_automation
3747
+ else:
3748
+ # Salesforce (other than Core Share) is OK with the simple HTTP GET request:
3749
+ logger.info("Open URL -> %s to verify account or email change", url)
3750
+ response = self._http_object.http_request(url=url, method="GET")
3751
+ success = response and response.ok
3752
+
3753
+ if success:
3754
+ logger.info("Remove email from inbox of user -> %s...", user_email)
3755
+ response = self.delete_mail(user_id=m365_user_id, email_id=email_id)
3756
+ if not response:
3757
+ logger.warning(
3758
+ "Couldn't remove the mail from the inbox of user -> %s",
3759
+ user_email,
3760
+ )
3761
+ # We have success now and can break from the while loop
3762
+ return True
3763
+ else:
3764
+ logger.error(
3765
+ "Failed to process e-mail verification for user -> %s",
3766
+ user_email,
3767
+ )
3768
+ return False
3769
+ # end if response and response["value"]
3666
3770
  else:
3667
- logger.error(
3668
- "Cannot update App Registration -> %s (%s); status -> %s; error -> %s",
3669
- app_registration_name,
3670
- app_registration_id,
3671
- response.status_code,
3672
- response.text,
3771
+ logger.info(
3772
+ "Verification email not yet received (no mails with sender -> %s and subject -> '%s' found). Waiting %s seconds...",
3773
+ sender,
3774
+ subject,
3775
+ 10 * (retries + 1),
3673
3776
  )
3674
- return None
3777
+ time.sleep(10 * (retries + 1))
3778
+ retries += 1
3779
+ # end while
3780
+
3781
+ logger.warning(
3782
+ "Verification mail for user -> %s has not arrived in time.", user_email
3783
+ )
3784
+
3785
+ return False
3675
3786
 
3676
3787
  # end method definition