pyxecm 1.3.0__py3-none-any.whl → 1.5__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
@@ -30,6 +30,7 @@ update_user: Update selected properties of an M365 user
30
30
  get_user_licenses: Get the assigned license SKUs of a user
31
31
  assign_license_to_user: Add an M365 license to a user (e.g. to use Office 365)
32
32
  get_user_photo: Get the photo of a M365 user
33
+ download_user_photo: Download the M365 user photo and save it to the local file system
33
34
  update_user_photo: Update a user with a profile photo (which must be in local file system)
34
35
 
35
36
  get_groups: Get list all all groups in M365 tenant
@@ -63,6 +64,7 @@ upload_teams_app: Upload a new app package to the catalog of MS Teams apps
63
64
  remove_teams_app: Remove MS Teams App for the app catalog
64
65
  assign_teams_app_to_user: Assign (add) a MS Teams app to a M365 user.
65
66
  upgrade_teams_app_of_user: Upgrade a MS teams app for a user.
67
+ remove_teams_app_from_user: Remove a M365 Teams app from a M365 user.
66
68
  assign_teams_app_to_team: Assign (add) a MS Teams app to a M365 team
67
69
  (so that it afterwards can be added as a Tab in a M365 Teams Channel)
68
70
  upgrade_teams_app_of_team: Upgrade a MS teams app for a specific team.
@@ -74,10 +76,21 @@ add_sensitivity_label: Assign a existing sensitivity label to a user.
74
76
  THIS IS CURRENTLY NOT WORKING!
75
77
  assign_sensitivity_label_to_user: Create a new sensitivity label in M365
76
78
  THIS IS CURRENTLY NOT WORKING!
79
+
80
+ upload_outlook_app: Upload the M365 Outlook Add-In as "Integrated" App to M365 Admin Center. (NOT WORKING)
81
+ get_app_registration: Find an Azure App Registration based on its name
82
+ add_app_registration: Add an Azure App Registration
83
+ update_app_registration: Update an Azure App Registration
84
+
85
+ get_mail: Get email from inbox of a given user and a given sender (from)
86
+ get_mail_body: Get full email body for a given email ID
87
+ extract_url_from_message_body: Parse the email body to extract (a potentially multi-line) URL from the body.
88
+ delete_mail: Delete email from inbox of a given user and a given email ID.
89
+ email_verification: Process email verification
77
90
  """
78
91
 
79
92
  __author__ = "Dr. Marc Diefenbruch"
80
- __copyright__ = "Copyright 2023, OpenText"
93
+ __copyright__ = "Copyright 2024, OpenText"
81
94
  __credits__ = ["Kai-Philip Gatzweiler"]
82
95
  __maintainer__ = "Dr. Marc Diefenbruch"
83
96
  __email__ = "mdiefenb@opentext.com"
@@ -90,9 +103,13 @@ import time
90
103
  import urllib.parse
91
104
  import zipfile
92
105
  from urllib.parse import quote
106
+ from datetime import datetime
93
107
 
94
108
  import requests
95
109
 
110
+ from pyxecm.helper.web import HTTP
111
+ from pyxecm.customizer.browser_automation import BrowserAutomation
112
+
96
113
  logger = logging.getLogger("pyxecm.customizer.m365")
97
114
 
98
115
  request_login_headers = {
@@ -100,6 +117,7 @@ request_login_headers = {
100
117
  "Accept": "application/json",
101
118
  }
102
119
 
120
+ REQUEST_TIMEOUT = 60
103
121
 
104
122
  class M365(object):
105
123
  """Used to automate stettings in Microsoft 365 via the Graph API."""
@@ -107,6 +125,7 @@ class M365(object):
107
125
  _config: dict
108
126
  _access_token = None
109
127
  _user_access_token = None
128
+ _http_object: HTTP | None = None
110
129
 
111
130
  def __init__(
112
131
  self,
@@ -116,6 +135,7 @@ class M365(object):
116
135
  domain: str,
117
136
  sku_id: str,
118
137
  teams_app_name: str,
138
+ teams_app_external_id: str,
119
139
  ):
120
140
  """Initialize the M365 object
121
141
 
@@ -126,6 +146,7 @@ class M365(object):
126
146
  domain (str): M365 domain
127
147
  sku_id (str): License SKU for M365 users
128
148
  teams_app_name (str): name of the Extended ECM app for MS Teams
149
+ teams_app_external_id (str): external ID of the Extended ECM app for MS Teams
129
150
  """
130
151
 
131
152
  m365_config = {}
@@ -137,6 +158,10 @@ class M365(object):
137
158
  m365_config["domain"] = domain
138
159
  m365_config["skuId"] = sku_id
139
160
  m365_config["teamsAppName"] = teams_app_name
161
+ m365_config["teamsAppExternalId"] = (
162
+ teams_app_external_id # this is the external App ID
163
+ )
164
+ m365_config["teamsAppInternalId"] = None # will be set later...
140
165
  m365_config[
141
166
  "authenticationUrl"
142
167
  ] = "https://login.microsoftonline.com/{}/oauth2/v2.0/token".format(tenant_id)
@@ -162,6 +187,7 @@ class M365(object):
162
187
  m365_config["applicationsUrl"] = m365_config["graphUrl"] + "applications"
163
188
 
164
189
  self._config = m365_config
190
+ self._http_object = HTTP()
165
191
 
166
192
  def config(self) -> dict:
167
193
  """Returns the configuration dictionary
@@ -396,12 +422,16 @@ class M365(object):
396
422
 
397
423
  # Already authenticated and session still valid?
398
424
  if self._access_token and not revalidate:
425
+ logger.debug(
426
+ "Session still valid - return existing access token -> %s",
427
+ str(self._access_token),
428
+ )
399
429
  return self._access_token
400
430
 
401
431
  request_url = self.config()["authenticationUrl"]
402
432
  request_header = request_login_headers
403
433
 
404
- logger.info("Requesting M365 Access Token from -> %s", request_url)
434
+ logger.debug("Requesting M365 Access Token from -> %s", request_url)
405
435
 
406
436
  authenticate_post_body = self.credentials()
407
437
  authenticate_response = None
@@ -411,7 +441,7 @@ class M365(object):
411
441
  request_url,
412
442
  data=authenticate_post_body,
413
443
  headers=request_header,
414
- timeout=60,
444
+ timeout=REQUEST_TIMEOUT,
415
445
  )
416
446
  except requests.exceptions.ConnectionError as exception:
417
447
  logger.warning(
@@ -437,6 +467,7 @@ class M365(object):
437
467
 
438
468
  # Store authentication access_token:
439
469
  self._access_token = access_token
470
+
440
471
  return self._access_token
441
472
 
442
473
  # end method definition
@@ -454,7 +485,7 @@ class M365(object):
454
485
  request_url = self.config()["authenticationUrl"]
455
486
  request_header = request_login_headers
456
487
 
457
- logger.info(
488
+ logger.debug(
458
489
  "Requesting M365 Access Token for user -> %s from -> %s",
459
490
  username,
460
491
  request_url,
@@ -468,7 +499,7 @@ class M365(object):
468
499
  request_url,
469
500
  data=authenticate_post_body,
470
501
  headers=request_header,
471
- timeout=60,
502
+ timeout=REQUEST_TIMEOUT,
472
503
  )
473
504
  except requests.exceptions.ConnectionError as exception:
474
505
  logger.warning(
@@ -495,6 +526,7 @@ class M365(object):
495
526
 
496
527
  # Store authentication access_token:
497
528
  self._user_access_token = access_token
529
+
498
530
  return self._user_access_token
499
531
 
500
532
  # end method definition
@@ -509,17 +541,19 @@ class M365(object):
509
541
  request_url = self.config()["usersUrl"]
510
542
  request_header = self.request_header()
511
543
 
512
- logger.info("Get list of all users; calling -> %s", request_url)
544
+ logger.debug("Get list of all users; calling -> %s", request_url)
513
545
 
514
546
  retries = 0
515
547
  while True:
516
- response = requests.get(request_url, headers=request_header, timeout=60)
548
+ response = requests.get(
549
+ request_url, headers=request_header, timeout=REQUEST_TIMEOUT
550
+ )
517
551
  if response.ok:
518
552
  return self.parse_request_response(response)
519
553
  # Check if Session has expired - then re-authenticate and try once more
520
554
  elif response.status_code == 401 and retries == 0:
521
- logger.warning("Session has expired - try to re-authenticate...")
522
- self.authenticate(True)
555
+ logger.debug("Session has expired - try to re-authenticate...")
556
+ self.authenticate(revalidate=True)
523
557
  request_header = self.request_header()
524
558
  retries += 1
525
559
  elif response.status_code in [502, 503, 504] and retries < 3:
@@ -567,20 +601,47 @@ class M365(object):
567
601
  }
568
602
  """
569
603
 
604
+ # Some sanity checks:
605
+ if not "@" in user_email or not "." in user_email:
606
+ logger.error("User email -> %s is not a valid email address", user_email)
607
+ return None
608
+
609
+ # if there's an alias in the E-Mail Adress we remove it as
610
+ # MS Graph seems to not support an alias to lookup a user object.
611
+ if "+" in user_email:
612
+ logger.info(
613
+ "Removing Alias from email address -> %s to determine M365 principal name...",
614
+ user_email,
615
+ )
616
+ # Find the index of the '+' character
617
+ alias_index = user_email.find("+")
618
+
619
+ # Find the index of the '@' character
620
+ domain_index = user_email.find("@")
621
+
622
+ # Construct the email address without the alias
623
+ user_email = user_email[:alias_index] + user_email[domain_index:]
624
+ logger.info(
625
+ "M365 user principal name -> %s",
626
+ user_email,
627
+ )
628
+
570
629
  request_url = self.config()["usersUrl"] + "/" + user_email
571
630
  request_header = self.request_header()
572
631
 
573
- logger.info("Get M365 user -> %s; calling -> %s", user_email, request_url)
632
+ logger.debug("Get M365 user -> %s; calling -> %s", user_email, request_url)
574
633
 
575
634
  retries = 0
576
635
  while True:
577
- response = requests.get(request_url, headers=request_header, timeout=60)
636
+ response = requests.get(
637
+ request_url, headers=request_header, timeout=REQUEST_TIMEOUT
638
+ )
578
639
  if response.ok:
579
640
  return self.parse_request_response(response)
580
641
  # Check if Session has expired - then re-authenticate and try once more
581
642
  elif response.status_code == 401 and retries == 0:
582
- logger.warning("Session has expired - try to re-authenticate...")
583
- self.authenticate(True)
643
+ logger.debug("Session has expired - try to re-authenticate...")
644
+ self.authenticate(revalidate=True)
584
645
  request_header = self.request_header()
585
646
  retries += 1
586
647
  elif response.status_code in [502, 503, 504] and retries < 3:
@@ -600,7 +661,7 @@ class M365(object):
600
661
  response.text,
601
662
  )
602
663
  else:
603
- logger.info("M365 User -> %s not found.", user_email)
664
+ logger.debug("M365 User -> %s not found.", user_email)
604
665
  return None
605
666
 
606
667
  # end method definition
@@ -651,7 +712,7 @@ class M365(object):
651
712
  request_url = self.config()["usersUrl"]
652
713
  request_header = self.request_header()
653
714
 
654
- logger.info("Adding M365 user -> %s; calling -> %s", email, request_url)
715
+ logger.debug("Adding M365 user -> %s; calling -> %s", email, request_url)
655
716
 
656
717
  retries = 0
657
718
  while True:
@@ -659,14 +720,14 @@ class M365(object):
659
720
  request_url,
660
721
  data=json.dumps(user_post_body),
661
722
  headers=request_header,
662
- timeout=60,
723
+ timeout=REQUEST_TIMEOUT,
663
724
  )
664
725
  if response.ok:
665
726
  return self.parse_request_response(response)
666
727
  # Check if Session has expired - then re-authenticate and try once more
667
728
  elif response.status_code == 401 and retries == 0:
668
- logger.warning("Session has expired - try to re-authenticate...")
669
- self.authenticate(True)
729
+ logger.debug("Session has expired - try to re-authenticate...")
730
+ self.authenticate(revalidate=True)
670
731
  request_header = self.request_header()
671
732
  retries += 1
672
733
  elif response.status_code in [502, 503, 504] and retries < 3:
@@ -692,6 +753,9 @@ class M365(object):
692
753
  """Update selected properties of an M365 user. Documentation
693
754
  on user properties is here: https://learn.microsoft.com/en-us/graph/api/user-update
694
755
 
756
+ Args:
757
+ user_id (str): ID of the user (can also be email). This is also the unique identifier
758
+ updated_settings (dict): new data to update the user with
695
759
  Returns:
696
760
  dict | None: Response of the M365 Graph API or None if the call fails.
697
761
  """
@@ -699,8 +763,8 @@ class M365(object):
699
763
  request_url = self.config()["usersUrl"] + "/" + user_id
700
764
  request_header = self.request_header()
701
765
 
702
- logger.info(
703
- "Updating M365 user -> %s with -> %s; calling -> %s",
766
+ logger.debug(
767
+ "Updating M365 user with ID -> %s with -> %s; calling -> %s",
704
768
  user_id,
705
769
  str(updated_settings),
706
770
  request_url,
@@ -712,14 +776,14 @@ class M365(object):
712
776
  request_url,
713
777
  json=updated_settings,
714
778
  headers=request_header,
715
- timeout=60,
779
+ timeout=REQUEST_TIMEOUT,
716
780
  )
717
781
  if response.ok:
718
782
  return self.parse_request_response(response)
719
783
  # Check if Session has expired - then re-authenticate and try once more
720
784
  elif response.status_code == 401 and retries == 0:
721
- logger.warning("Session has expired - try to re-authenticate...")
722
- self.authenticate(True)
785
+ logger.debug("Session has expired - try to re-authenticate...")
786
+ self.authenticate(revalidate=True)
723
787
  request_header = self.request_header()
724
788
  retries += 1
725
789
  elif response.status_code in [502, 503, 504] and retries < 3:
@@ -769,13 +833,15 @@ class M365(object):
769
833
 
770
834
  retries = 0
771
835
  while True:
772
- response = requests.get(request_url, headers=request_header, timeout=60)
836
+ response = requests.get(
837
+ request_url, headers=request_header, timeout=REQUEST_TIMEOUT
838
+ )
773
839
  if response.ok:
774
840
  return self.parse_request_response(response)
775
841
  # Check if Session has expired - then re-authenticate and try once more
776
842
  elif response.status_code == 401 and retries == 0:
777
- logger.warning("Session has expired - try to re-authenticate...")
778
- self.authenticate(True)
843
+ logger.debug("Session has expired - try to re-authenticate...")
844
+ self.authenticate(revalidate=True)
779
845
  request_header = self.request_header()
780
846
  retries += 1
781
847
  elif response.status_code in [502, 503, 504] and retries < 3:
@@ -824,7 +890,7 @@ class M365(object):
824
890
  "removeLicenses": [],
825
891
  }
826
892
 
827
- logger.info(
893
+ logger.debug(
828
894
  "Assign M365 license -> %s to M365 user -> %s; calling -> %s",
829
895
  sku_id,
830
896
  user_id,
@@ -834,14 +900,17 @@ class M365(object):
834
900
  retries = 0
835
901
  while True:
836
902
  response = requests.post(
837
- request_url, json=license_post_body, headers=request_header, timeout=60
903
+ request_url,
904
+ json=license_post_body,
905
+ headers=request_header,
906
+ timeout=REQUEST_TIMEOUT,
838
907
  )
839
908
  if response.ok:
840
909
  return self.parse_request_response(response)
841
910
  # Check if Session has expired - then re-authenticate and try once more
842
911
  elif response.status_code == 401 and retries == 0:
843
- logger.warning("Session has expired - try to re-authenticate...")
844
- self.authenticate(True)
912
+ logger.debug("Session has expired - try to re-authenticate...")
913
+ self.authenticate(revalidate=True)
845
914
  request_header = self.request_header()
846
915
  retries += 1
847
916
  elif response.status_code in [502, 503, 504] and retries < 3:
@@ -879,17 +948,19 @@ class M365(object):
879
948
  # Set image as content type:
880
949
  request_header = self.request_header("image/*")
881
950
 
882
- logger.info("Get photo of user -> %s; calling -> %s", user_id, request_url)
951
+ logger.debug("Get photo of user -> %s; calling -> %s", user_id, request_url)
883
952
 
884
953
  retries = 0
885
954
  while True:
886
- response = requests.get(request_url, headers=request_header, timeout=60)
955
+ response = requests.get(
956
+ request_url, headers=request_header, timeout=REQUEST_TIMEOUT
957
+ )
887
958
  if response.ok:
888
959
  return response.content # this is the actual image - not json!
889
960
  # Check if Session has expired - then re-authenticate and try once more
890
961
  elif response.status_code == 401 and retries == 0:
891
- logger.warning("Session has expired - try to re-authenticate...")
892
- self.authenticate(True)
962
+ logger.debug("Session has expired - try to re-authenticate...")
963
+ self.authenticate(revalidate=True)
893
964
  request_header = self.request_header()
894
965
  retries += 1
895
966
  elif response.status_code in [502, 503, 504] and retries < 3:
@@ -909,7 +980,88 @@ class M365(object):
909
980
  response.text,
910
981
  )
911
982
  else:
912
- logger.info("User -> %s does not yet have a photo.", user_id)
983
+ logger.debug("M365 User -> %s does not yet have a photo.", user_id)
984
+ return None
985
+
986
+ # end method definition
987
+
988
+ def download_user_photo(self, user_id: str, photo_path: str) -> str:
989
+ """Download the M365 user photo and save it to the local file system
990
+
991
+ Args:
992
+ user_id (str): M365 GUID of the user (can also be the M365 email of the user)
993
+ photo_path (str): Directory where the photo should be saved
994
+ Returns:
995
+ str: name of the photo file in the file system (with full path) or None if
996
+ the call of the REST API fails.
997
+ """
998
+
999
+ request_url = self.config()["usersUrl"] + "/" + user_id + "/photo/$value"
1000
+ request_header = self.request_header("application/json")
1001
+
1002
+ logger.debug(
1003
+ "Downloading photo for M365 user with ID -> %s; calling -> %s",
1004
+ user_id,
1005
+ request_url,
1006
+ )
1007
+
1008
+ retries = 0
1009
+ while True:
1010
+ response = requests.get(
1011
+ request_url,
1012
+ headers=request_header,
1013
+ timeout=REQUEST_TIMEOUT,
1014
+ stream=True,
1015
+ )
1016
+ if response.ok:
1017
+ content_type = response.headers.get("Content-Type", "image/png")
1018
+ if content_type == "image/jpeg":
1019
+ file_extension = "jpg"
1020
+ elif content_type == "image/png":
1021
+ file_extension = "png"
1022
+ else:
1023
+ file_extension = "img" # Default extension if type is unknown
1024
+ file_path = os.path.join(
1025
+ photo_path, "{}.{}".format(user_id, file_extension)
1026
+ )
1027
+
1028
+ try:
1029
+ with open(file_path, "wb") as file:
1030
+ for chunk in response.iter_content(chunk_size=8192):
1031
+ file.write(chunk)
1032
+ logger.info(
1033
+ "Photo for M365 user with ID -> %s saved to %s",
1034
+ user_id,
1035
+ file_path,
1036
+ )
1037
+ return file_path
1038
+ except OSError as exception:
1039
+ logger.error(
1040
+ "Error saving photo for user with ID -> %s; error -> %s",
1041
+ user_id,
1042
+ exception,
1043
+ )
1044
+ return None
1045
+ elif response.status_code == 401 and retries == 0:
1046
+ logger.debug("Session has expired - try to re-authenticate...")
1047
+ self.authenticate(revalidate=True)
1048
+ request_header = self.request_header("application/json")
1049
+ retries += 1
1050
+ elif response.status_code in [502, 503, 504] and retries < 3:
1051
+ logger.warning(
1052
+ "M365 Graph API delivered server side error -> %s; retrying in %s seconds...",
1053
+ response.status_code,
1054
+ (retries + 1) * 60,
1055
+ )
1056
+ time.sleep((retries + 1) * 60)
1057
+ retries += 1
1058
+ else:
1059
+ logger.error(
1060
+ "Failed to download photo for user with ID -> %s; status -> %s; error -> %s",
1061
+ user_id,
1062
+ response.status_code,
1063
+ response.text,
1064
+ )
913
1065
  return None
914
1066
 
915
1067
  # end method definition
@@ -946,8 +1098,8 @@ class M365(object):
946
1098
 
947
1099
  data = photo_data
948
1100
 
949
- logger.info(
950
- "Update M365 user -> %s with photo -> %s; calling -> %s",
1101
+ logger.debug(
1102
+ "Update M365 user with ID -> %s with photo -> %s; calling -> %s",
951
1103
  user_id,
952
1104
  photo_path,
953
1105
  request_url,
@@ -956,14 +1108,14 @@ class M365(object):
956
1108
  retries = 0
957
1109
  while True:
958
1110
  response = requests.put(
959
- request_url, headers=request_header, data=data, timeout=60
1111
+ request_url, headers=request_header, data=data, timeout=REQUEST_TIMEOUT
960
1112
  )
961
1113
  if response.ok:
962
1114
  return self.parse_request_response(response)
963
1115
  # Check if Session has expired - then re-authenticate and try once more
964
1116
  elif response.status_code == 401 and retries == 0:
965
- logger.warning("Session has expired - try to re-authenticate...")
966
- self.authenticate(True)
1117
+ logger.debug("Session has expired - try to re-authenticate...")
1118
+ self.authenticate(revalidate=True)
967
1119
  request_header = self.request_header()
968
1120
  retries += 1
969
1121
  elif response.status_code in [502, 503, 504] and retries < 3:
@@ -976,7 +1128,7 @@ class M365(object):
976
1128
  retries += 1
977
1129
  else:
978
1130
  logger.error(
979
- "Failed to update user -> %s with photo -> %s; status -> %s; error -> %s",
1131
+ "Failed to update user with ID -> %s with photo -> %s; status -> %s; error -> %s",
980
1132
  user_id,
981
1133
  photo_path,
982
1134
  response.status_code,
@@ -998,7 +1150,7 @@ class M365(object):
998
1150
  request_url = self.config()["groupsUrl"]
999
1151
  request_header = self.request_header()
1000
1152
 
1001
- logger.info("Get list of all M365 groups; calling -> %s", request_url)
1153
+ logger.debug("Get list of all M365 groups; calling -> %s", request_url)
1002
1154
 
1003
1155
  retries = 0
1004
1156
  while True:
@@ -1006,14 +1158,14 @@ class M365(object):
1006
1158
  request_url,
1007
1159
  headers=request_header,
1008
1160
  params={"$top": str(max_number)},
1009
- timeout=60,
1161
+ timeout=REQUEST_TIMEOUT,
1010
1162
  )
1011
1163
  if response.ok:
1012
1164
  return self.parse_request_response(response)
1013
1165
  # Check if Session has expired - then re-authenticate and try once more
1014
1166
  elif response.status_code == 401 and retries == 0:
1015
- logger.warning("Session has expired - try to re-authenticate...")
1016
- self.authenticate(True)
1167
+ logger.debug("Session has expired - try to re-authenticate...")
1168
+ self.authenticate(revalidate=True)
1017
1169
  request_header = self.request_header()
1018
1170
  retries += 1
1019
1171
  elif response.status_code in [502, 503, 504] and retries < 3:
@@ -1095,17 +1247,19 @@ class M365(object):
1095
1247
  request_url = self.config()["groupsUrl"] + "?" + encoded_query
1096
1248
  request_header = self.request_header()
1097
1249
 
1098
- logger.info("Get M365 group -> %s; calling -> %s", group_name, request_url)
1250
+ logger.debug("Get M365 group -> %s; calling -> %s", group_name, request_url)
1099
1251
 
1100
1252
  retries = 0
1101
1253
  while True:
1102
- response = requests.get(request_url, headers=request_header, timeout=60)
1254
+ response = requests.get(
1255
+ request_url, headers=request_header, timeout=REQUEST_TIMEOUT
1256
+ )
1103
1257
  if response.ok:
1104
1258
  return self.parse_request_response(response)
1105
1259
  # Check if Session has expired - then re-authenticate and try once more
1106
1260
  elif response.status_code == 401 and retries == 0:
1107
- logger.warning("Session has expired - try to re-authenticate...")
1108
- self.authenticate(True)
1261
+ logger.debug("Session has expired - try to re-authenticate...")
1262
+ self.authenticate(revalidate=True)
1109
1263
  request_header = self.request_header()
1110
1264
  retries += 1
1111
1265
  elif response.status_code in [502, 503, 504] and retries < 3:
@@ -1125,7 +1279,7 @@ class M365(object):
1125
1279
  response.text,
1126
1280
  )
1127
1281
  else:
1128
- logger.info("M365 Group -> %s not found.", group_name)
1282
+ logger.debug("M365 Group -> %s not found.", group_name)
1129
1283
  return None
1130
1284
 
1131
1285
  # end method definition
@@ -1191,7 +1345,7 @@ class M365(object):
1191
1345
  request_url = self.config()["groupsUrl"]
1192
1346
  request_header = self.request_header()
1193
1347
 
1194
- logger.info("Adding M365 group -> %s; calling -> %s", name, request_url)
1348
+ logger.debug("Adding M365 group -> %s; calling -> %s", name, request_url)
1195
1349
  logger.debug("M365 group attributes -> %s", group_post_body)
1196
1350
 
1197
1351
  retries = 0
@@ -1200,14 +1354,14 @@ class M365(object):
1200
1354
  request_url,
1201
1355
  data=json.dumps(group_post_body),
1202
1356
  headers=request_header,
1203
- timeout=60,
1357
+ timeout=REQUEST_TIMEOUT,
1204
1358
  )
1205
1359
  if response.ok:
1206
1360
  return self.parse_request_response(response)
1207
1361
  # Check if Session has expired - then re-authenticate and try once more
1208
1362
  elif response.status_code == 401 and retries == 0:
1209
- logger.warning("Session has expired - try to re-authenticate...")
1210
- self.authenticate(True)
1363
+ logger.debug("Session has expired - try to re-authenticate...")
1364
+ self.authenticate(revalidate=True)
1211
1365
  request_header = self.request_header()
1212
1366
  retries += 1
1213
1367
  elif response.status_code in [502, 503, 504] and retries < 3:
@@ -1255,7 +1409,7 @@ class M365(object):
1255
1409
  )
1256
1410
  request_header = self.request_header()
1257
1411
 
1258
- logger.info(
1412
+ logger.debug(
1259
1413
  "Get members of M365 group -> %s (%s); calling -> %s",
1260
1414
  group_name,
1261
1415
  group_id,
@@ -1264,13 +1418,15 @@ class M365(object):
1264
1418
 
1265
1419
  retries = 0
1266
1420
  while True:
1267
- response = requests.get(request_url, headers=request_header, timeout=60)
1421
+ response = requests.get(
1422
+ request_url, headers=request_header, timeout=REQUEST_TIMEOUT
1423
+ )
1268
1424
  if response.ok:
1269
1425
  return self.parse_request_response(response)
1270
1426
  # Check if Session has expired - then re-authenticate and try once more
1271
1427
  elif response.status_code == 401 and retries == 0:
1272
- logger.warning("Session has expired - try to re-authenticate...")
1273
- self.authenticate(True)
1428
+ logger.debug("Session has expired - try to re-authenticate...")
1429
+ self.authenticate(revalidate=True)
1274
1430
  request_header = self.request_header()
1275
1431
  retries += 1
1276
1432
  elif response.status_code in [502, 503, 504] and retries < 3:
@@ -1310,7 +1466,7 @@ class M365(object):
1310
1466
  "@odata.id": self.config()["directoryObjects"] + "/" + member_id
1311
1467
  }
1312
1468
 
1313
- logger.info(
1469
+ logger.debug(
1314
1470
  "Adding member -> %s to group -> %s; calling -> %s",
1315
1471
  member_id,
1316
1472
  group_id,
@@ -1323,15 +1479,15 @@ class M365(object):
1323
1479
  request_url,
1324
1480
  headers=request_header,
1325
1481
  data=json.dumps(group_member_post_body),
1326
- timeout=60,
1482
+ timeout=REQUEST_TIMEOUT,
1327
1483
  )
1328
1484
  if response.ok:
1329
1485
  return self.parse_request_response(response)
1330
1486
 
1331
1487
  # Check if Session has expired - then re-authenticate and try once more
1332
1488
  if response.status_code == 401 and retries == 0:
1333
- logger.warning("Session has expired - try to re-authenticate...")
1334
- self.authenticate(True)
1489
+ logger.debug("Session has expired - try to re-authenticate...")
1490
+ self.authenticate(revalidate=True)
1335
1491
  request_header = self.request_header()
1336
1492
  retries += 1
1337
1493
  elif response.status_code in [502, 503, 504] and retries < 3:
@@ -1373,7 +1529,7 @@ class M365(object):
1373
1529
  )
1374
1530
  request_header = self.request_header()
1375
1531
 
1376
- logger.info(
1532
+ logger.debug(
1377
1533
  "Check if user -> %s is in group -> %s; calling -> %s",
1378
1534
  member_id,
1379
1535
  group_id,
@@ -1382,7 +1538,9 @@ class M365(object):
1382
1538
 
1383
1539
  retries = 0
1384
1540
  while True:
1385
- response = requests.get(request_url, headers=request_header, timeout=60)
1541
+ response = requests.get(
1542
+ request_url, headers=request_header, timeout=REQUEST_TIMEOUT
1543
+ )
1386
1544
  if response.ok:
1387
1545
  response = self.parse_request_response(response)
1388
1546
  if not "value" in response or len(response["value"]) == 0:
@@ -1390,8 +1548,8 @@ class M365(object):
1390
1548
  return True
1391
1549
  # Check if Session has expired - then re-authenticate and try once more
1392
1550
  elif response.status_code == 401 and retries == 0:
1393
- logger.warning("Session has expired - try to re-authenticate...")
1394
- self.authenticate(True)
1551
+ logger.debug("Session has expired - try to re-authenticate...")
1552
+ self.authenticate(revalidate=True)
1395
1553
  request_header = self.request_header()
1396
1554
  retries += 1
1397
1555
  elif response.status_code in [502, 503, 504] and retries < 3:
@@ -1443,7 +1601,7 @@ class M365(object):
1443
1601
  )
1444
1602
  request_header = self.request_header()
1445
1603
 
1446
- logger.info(
1604
+ logger.debug(
1447
1605
  "Get owners of M365 group -> %s (%s); calling -> %s",
1448
1606
  group_name,
1449
1607
  group_id,
@@ -1452,13 +1610,15 @@ class M365(object):
1452
1610
 
1453
1611
  retries = 0
1454
1612
  while True:
1455
- response = requests.get(request_url, headers=request_header, timeout=60)
1613
+ response = requests.get(
1614
+ request_url, headers=request_header, timeout=REQUEST_TIMEOUT
1615
+ )
1456
1616
  if response.ok:
1457
1617
  return self.parse_request_response(response)
1458
1618
  # Check if Session has expired - then re-authenticate and try once more
1459
1619
  elif response.status_code == 401 and retries == 0:
1460
- logger.warning("Session has expired - try to re-authenticate...")
1461
- self.authenticate(True)
1620
+ logger.debug("Session has expired - try to re-authenticate...")
1621
+ self.authenticate(revalidate=True)
1462
1622
  request_header = self.request_header()
1463
1623
  retries += 1
1464
1624
  elif response.status_code in [502, 503, 504] and retries < 3:
@@ -1498,7 +1658,7 @@ class M365(object):
1498
1658
  "@odata.id": self.config()["directoryObjects"] + "/" + owner_id
1499
1659
  }
1500
1660
 
1501
- logger.info(
1661
+ logger.debug(
1502
1662
  "Adding owner -> %s to M365 group -> %s; calling -> %s",
1503
1663
  owner_id,
1504
1664
  group_id,
@@ -1511,14 +1671,14 @@ class M365(object):
1511
1671
  request_url,
1512
1672
  headers=request_header,
1513
1673
  data=json.dumps(group_member_post_body),
1514
- timeout=60,
1674
+ timeout=REQUEST_TIMEOUT,
1515
1675
  )
1516
1676
  if response.ok:
1517
1677
  return self.parse_request_response(response)
1518
1678
  # Check if Session has expired - then re-authenticate and try once more
1519
1679
  elif response.status_code == 401 and retries == 0:
1520
- logger.warning("Session has expired - try to re-authenticate...")
1521
- self.authenticate(True)
1680
+ logger.debug("Session has expired - try to re-authenticate...")
1681
+ self.authenticate(revalidate=True)
1522
1682
  request_header = self.request_header()
1523
1683
  retries += 1
1524
1684
  elif response.status_code in [502, 503, 504] and retries < 3:
@@ -1552,7 +1712,9 @@ class M365(object):
1552
1712
  request_url = (
1553
1713
  self.config()["directoryUrl"] + "/deletedItems/microsoft.graph.group"
1554
1714
  )
1555
- response = requests.get(request_url, headers=request_header, timeout=60)
1715
+ response = requests.get(
1716
+ request_url, headers=request_header, timeout=REQUEST_TIMEOUT
1717
+ )
1556
1718
  deleted_groups = self.parse_request_response(response)
1557
1719
 
1558
1720
  for group in deleted_groups["value"]:
@@ -1562,7 +1724,9 @@ class M365(object):
1562
1724
  request_url = (
1563
1725
  self.config()["directoryUrl"] + "/deletedItems/microsoft.graph.user"
1564
1726
  )
1565
- response = requests.get(request_url, headers=request_header, timeout=60)
1727
+ response = requests.get(
1728
+ request_url, headers=request_header, timeout=REQUEST_TIMEOUT
1729
+ )
1566
1730
  deleted_users = self.parse_request_response(response)
1567
1731
 
1568
1732
  for user in deleted_users["value"]:
@@ -1585,17 +1749,19 @@ class M365(object):
1585
1749
  request_url = self.config()["directoryUrl"] + "/deletedItems/" + item_id
1586
1750
  request_header = self.request_header()
1587
1751
 
1588
- logger.info("Purging deleted item -> %s; calling -> %s", item_id, request_url)
1752
+ logger.debug("Purging deleted item -> %s; calling -> %s", item_id, request_url)
1589
1753
 
1590
1754
  retries = 0
1591
1755
  while True:
1592
- response = requests.delete(request_url, headers=request_header, timeout=60)
1756
+ response = requests.delete(
1757
+ request_url, headers=request_header, timeout=REQUEST_TIMEOUT
1758
+ )
1593
1759
  if response.ok:
1594
1760
  return self.parse_request_response(response)
1595
1761
  # Check if Session has expired - then re-authenticate and try once more
1596
1762
  elif response.status_code == 401 and retries == 0:
1597
- logger.warning("Session has expired - try to re-authenticate...")
1598
- self.authenticate(True)
1763
+ logger.debug("Session has expired - try to re-authenticate...")
1764
+ self.authenticate(revalidate=True)
1599
1765
  request_header = self.request_header()
1600
1766
  retries += 1
1601
1767
  elif response.status_code in [502, 503, 504] and retries < 3:
@@ -1638,7 +1804,7 @@ class M365(object):
1638
1804
  request_url = self.config()["groupsUrl"] + "/" + group_id + "/team"
1639
1805
  request_header = self.request_header()
1640
1806
 
1641
- logger.info(
1807
+ logger.debug(
1642
1808
  "Check if M365 Group -> %s has a M365 Team connected; calling -> %s",
1643
1809
  group_name,
1644
1810
  request_url,
@@ -1646,17 +1812,19 @@ class M365(object):
1646
1812
 
1647
1813
  retries = 0
1648
1814
  while True:
1649
- response = requests.get(request_url, headers=request_header, timeout=60)
1815
+ response = requests.get(
1816
+ request_url, headers=request_header, timeout=REQUEST_TIMEOUT
1817
+ )
1650
1818
 
1651
1819
  if response.status_code == 200: # Group has a Team assigned!
1652
- logger.info("Group -> %s has a M365 Team connected.", group_name)
1820
+ logger.debug("Group -> %s has a M365 Team connected.", group_name)
1653
1821
  return True
1654
1822
  elif response.status_code == 404: # Group does not have a Team assigned!
1655
- logger.info("Group -> %s has no M365 Team connected.", group_name)
1823
+ logger.debug("Group -> %s has no M365 Team connected.", group_name)
1656
1824
  return False
1657
1825
  elif response.status_code == 401 and retries == 0:
1658
- logger.warning("Session has expired - try to re-authenticate...")
1659
- self.authenticate(True)
1826
+ logger.debug("Session has expired - try to re-authenticate...")
1827
+ self.authenticate(revalidate=True)
1660
1828
  request_header = self.request_header()
1661
1829
  retries += 1
1662
1830
  elif response.status_code in [502, 503, 504] and retries < 3:
@@ -1728,21 +1896,23 @@ class M365(object):
1728
1896
 
1729
1897
  request_header = self.request_header()
1730
1898
 
1731
- logger.info(
1732
- "Lookup Microsoft 365 Teams with name -> %s; calling -> %s",
1899
+ logger.debug(
1900
+ "Lookup Microsoft 365 Teams with name -> '%s'; calling -> %s",
1733
1901
  name,
1734
1902
  request_url,
1735
1903
  )
1736
1904
 
1737
1905
  retries = 0
1738
1906
  while True:
1739
- response = requests.get(request_url, headers=request_header, timeout=60)
1907
+ response = requests.get(
1908
+ request_url, headers=request_header, timeout=REQUEST_TIMEOUT
1909
+ )
1740
1910
  if response.ok:
1741
1911
  return self.parse_request_response(response)
1742
1912
  # Check if Session has expired - then re-authenticate and try once more
1743
1913
  elif response.status_code == 401 and retries == 0:
1744
- logger.warning("Session has expired - try to re-authenticate...")
1745
- self.authenticate(True)
1914
+ logger.debug("Session has expired - try to re-authenticate...")
1915
+ self.authenticate(revalidate=True)
1746
1916
  request_header = self.request_header()
1747
1917
  retries += 1
1748
1918
  elif response.status_code in [502, 503, 504] and retries < 3:
@@ -1779,7 +1949,7 @@ class M365(object):
1779
1949
  group_id = self.get_result_value(response, "id", 0)
1780
1950
  if not group_id:
1781
1951
  logger.error(
1782
- "M365 Group -> %s not found. It is required for creating a corresponding M365 Team.",
1952
+ "M365 Group -> '%s' not found. It is required for creating a corresponding M365 Team.",
1783
1953
  name,
1784
1954
  )
1785
1955
  return None
@@ -1787,7 +1957,7 @@ class M365(object):
1787
1957
  response = self.get_group_owners(name)
1788
1958
  if response is None or not "value" in response or not response["value"]:
1789
1959
  logger.warning(
1790
- "M365 Group -> %s has no owners. This is required for creating a corresponding M365 Team.",
1960
+ "M365 Group -> '%s' has no owners. This is required for creating a corresponding M365 Team.",
1791
1961
  name,
1792
1962
  )
1793
1963
  return None
@@ -1802,7 +1972,7 @@ class M365(object):
1802
1972
  request_url = self.config()["teamsUrl"]
1803
1973
  request_header = self.request_header()
1804
1974
 
1805
- logger.info("Adding M365 Team -> %s; calling -> %s", name, request_url)
1975
+ logger.debug("Adding M365 Team -> %s; calling -> %s", name, request_url)
1806
1976
  logger.debug("M365 Team attributes -> %s", team_post_body)
1807
1977
 
1808
1978
  retries = 0
@@ -1811,14 +1981,14 @@ class M365(object):
1811
1981
  request_url,
1812
1982
  data=json.dumps(team_post_body),
1813
1983
  headers=request_header,
1814
- timeout=60,
1984
+ timeout=REQUEST_TIMEOUT,
1815
1985
  )
1816
1986
  if response.ok:
1817
1987
  return self.parse_request_response(response)
1818
1988
  # Check if Session has expired - then re-authenticate and try once more
1819
1989
  elif response.status_code == 401 and retries == 0:
1820
- logger.warning("Session has expired - try to re-authenticate...")
1821
- self.authenticate(True)
1990
+ logger.debug("Session has expired - try to re-authenticate...")
1991
+ self.authenticate(revalidate=True)
1822
1992
  request_header = self.request_header()
1823
1993
  retries += 1
1824
1994
  elif response.status_code in [502, 503, 504] and retries < 3:
@@ -1831,7 +2001,7 @@ class M365(object):
1831
2001
  retries += 1
1832
2002
  else:
1833
2003
  logger.error(
1834
- "Failed to add M365 Team -> %s; status -> %s; error -> %s",
2004
+ "Failed to add M365 Team -> '%s'; status -> %s; error -> %s",
1835
2005
  name,
1836
2006
  response.status_code,
1837
2007
  response.text,
@@ -1853,7 +2023,7 @@ class M365(object):
1853
2023
 
1854
2024
  request_header = self.request_header()
1855
2025
 
1856
- logger.info(
2026
+ logger.debug(
1857
2027
  "Delete Microsoft 365 Teams with ID -> %s; calling -> %s",
1858
2028
  team_id,
1859
2029
  request_url,
@@ -1861,13 +2031,15 @@ class M365(object):
1861
2031
 
1862
2032
  retries = 0
1863
2033
  while True:
1864
- response = requests.delete(request_url, headers=request_header, timeout=60)
2034
+ response = requests.delete(
2035
+ request_url, headers=request_header, timeout=REQUEST_TIMEOUT
2036
+ )
1865
2037
  if response.ok:
1866
2038
  return self.parse_request_response(response)
1867
2039
  # Check if Session has expired - then re-authenticate and try once more
1868
2040
  elif response.status_code == 401 and retries == 0:
1869
- logger.warning("Session has expired - try to re-authenticate...")
1870
- self.authenticate(True)
2041
+ logger.debug("Session has expired - try to re-authenticate...")
2042
+ self.authenticate(revalidate=True)
1871
2043
  request_header = self.request_header()
1872
2044
  retries += 1
1873
2045
  elif response.status_code in [502, 503, 504] and retries < 3:
@@ -1905,22 +2077,24 @@ class M365(object):
1905
2077
 
1906
2078
  request_header = self.request_header()
1907
2079
 
1908
- logger.info(
1909
- "Delete all Microsoft 365 Teams with name -> %s; calling -> %s",
2080
+ logger.debug(
2081
+ "Delete all Microsoft 365 Teams with name -> '%s'; calling -> %s",
1910
2082
  name,
1911
2083
  request_url,
1912
2084
  )
1913
2085
 
1914
2086
  retries = 0
1915
2087
  while True:
1916
- response = requests.get(request_url, headers=request_header, timeout=60)
2088
+ response = requests.get(
2089
+ request_url, headers=request_header, timeout=REQUEST_TIMEOUT
2090
+ )
1917
2091
  if response.ok:
1918
2092
  existing_teams = self.parse_request_response(response)
1919
2093
  break
1920
2094
  # Check if Session has expired - then re-authenticate and try once more
1921
2095
  elif response.status_code == 401 and retries == 0:
1922
- logger.warning("Session has expired - try to re-authenticate...")
1923
- self.authenticate(True)
2096
+ logger.debug("Session has expired - try to re-authenticate...")
2097
+ self.authenticate(revalidate=True)
1924
2098
  request_header = self.request_header()
1925
2099
  retries += 1
1926
2100
  elif response.status_code in [502, 503, 504] and retries < 3:
@@ -1956,16 +2130,16 @@ class M365(object):
1956
2130
  counter += 1
1957
2131
 
1958
2132
  logger.info(
1959
- "%s M365 Teams with name -> %s have been deleted.",
2133
+ "%s M365 Teams with name -> '%s' have been deleted.",
1960
2134
  str(counter),
1961
2135
  name,
1962
2136
  )
1963
2137
  return True
1964
2138
  else:
1965
- logger.info("No M365 Teams with name -> %s found.", name)
2139
+ logger.info("No M365 Teams with name -> '%s' found.", name)
1966
2140
  return False
1967
2141
  else:
1968
- logger.error("Failed to retrieve M365 Teams with name -> %s", name)
2142
+ logger.error("Failed to retrieve M365 Teams with name -> '%s'", name)
1969
2143
  return False
1970
2144
 
1971
2145
  # end method definition
@@ -1989,19 +2163,20 @@ class M365(object):
1989
2163
  if not "value" in response or not response["value"]:
1990
2164
  return False
1991
2165
  groups = response["value"]
2166
+
1992
2167
  logger.info(
1993
2168
  "Found -> %s existing M365 groups. Checking which ones should be deleted...",
1994
2169
  len(groups),
1995
2170
  )
1996
2171
 
1997
- # Process all groups and check if the< should be
2172
+ # Process all groups and check if they should be
1998
2173
  # deleted:
1999
2174
  for group in groups:
2000
2175
  group_name = group["displayName"]
2001
2176
  # Check if group is in exception list:
2002
2177
  if group_name in exception_list:
2003
2178
  logger.info(
2004
- "M365 Group name -> %s is on the exception list. Skipping...",
2179
+ "M365 Group name -> '%s' is on the exception list. Skipping...",
2005
2180
  group_name,
2006
2181
  )
2007
2182
  continue
@@ -2010,7 +2185,7 @@ class M365(object):
2010
2185
  result = re.search(pattern, group_name)
2011
2186
  if result:
2012
2187
  logger.info(
2013
- "M365 Group name -> %s is matching pattern -> %s. Delete it now...",
2188
+ "M365 Group name -> '%s' is matching pattern -> %s. Delete it now...",
2014
2189
  group_name,
2015
2190
  pattern,
2016
2191
  )
@@ -2018,7 +2193,7 @@ class M365(object):
2018
2193
  break
2019
2194
  else:
2020
2195
  logger.info(
2021
- "M365 Group name -> %s is not matching any delete pattern. Skipping...",
2196
+ "M365 Group name -> '%s' is not matching any delete pattern. Skipping...",
2022
2197
  group_name,
2023
2198
  )
2024
2199
  return True
@@ -2062,21 +2237,23 @@ class M365(object):
2062
2237
 
2063
2238
  request_header = self.request_header()
2064
2239
 
2065
- logger.info(
2066
- "Retrieve channels of Microsoft 365 Team -> %s; calling -> %s",
2240
+ logger.debug(
2241
+ "Retrieve channels of Microsoft 365 Team -> '%s'; calling -> %s",
2067
2242
  name,
2068
2243
  request_url,
2069
2244
  )
2070
2245
 
2071
2246
  retries = 0
2072
2247
  while True:
2073
- response = requests.get(request_url, headers=request_header, timeout=60)
2248
+ response = requests.get(
2249
+ request_url, headers=request_header, timeout=REQUEST_TIMEOUT
2250
+ )
2074
2251
  if response.ok:
2075
2252
  return self.parse_request_response(response)
2076
2253
  # Check if Session has expired - then re-authenticate and try once more
2077
2254
  elif response.status_code == 401 and retries == 0:
2078
- logger.warning("Session has expired - try to re-authenticate...")
2079
- self.authenticate(True)
2255
+ logger.debug("Session has expired - try to re-authenticate...")
2256
+ self.authenticate(revalidate=True)
2080
2257
  request_header = self.request_header()
2081
2258
  retries += 1
2082
2259
  elif response.status_code in [502, 503, 504] and retries < 3:
@@ -2145,7 +2322,7 @@ class M365(object):
2145
2322
  None,
2146
2323
  )
2147
2324
  if not channel:
2148
- logger.erro(
2325
+ logger.error(
2149
2326
  "Cannot find Channel -> %s on M365 Team -> %s", channel_name, team_name
2150
2327
  )
2151
2328
  return None
@@ -2162,7 +2339,7 @@ class M365(object):
2162
2339
 
2163
2340
  request_header = self.request_header()
2164
2341
 
2165
- logger.info(
2342
+ logger.debug(
2166
2343
  "Retrieve Tabs of Microsoft 365 Teams -> %s and Channel -> %s; calling -> %s",
2167
2344
  team_name,
2168
2345
  channel_name,
@@ -2171,13 +2348,15 @@ class M365(object):
2171
2348
 
2172
2349
  retries = 0
2173
2350
  while True:
2174
- response = requests.get(request_url, headers=request_header, timeout=60)
2351
+ response = requests.get(
2352
+ request_url, headers=request_header, timeout=REQUEST_TIMEOUT
2353
+ )
2175
2354
  if response.ok:
2176
2355
  return self.parse_request_response(response)
2177
2356
  # Check if Session has expired - then re-authenticate and try once more
2178
2357
  elif response.status_code == 401 and retries == 0:
2179
- logger.warning("Session has expired - try to re-authenticate...")
2180
- self.authenticate(True)
2358
+ logger.debug("Session has expired - try to re-authenticate...")
2359
+ self.authenticate(revalidate=True)
2181
2360
  request_header = self.request_header()
2182
2361
  retries += 1
2183
2362
  elif response.status_code in [502, 503, 504] and retries < 3:
@@ -2251,25 +2430,27 @@ class M365(object):
2251
2430
  request_url = self.config()["teamsAppsUrl"] + "?" + encoded_query
2252
2431
 
2253
2432
  if filter_expression:
2254
- logger.info(
2433
+ logger.debug(
2255
2434
  "Get list of MS Teams Apps using filter -> %s; calling -> %s",
2256
2435
  filter_expression,
2257
2436
  request_url,
2258
2437
  )
2259
2438
  else:
2260
- logger.info("Get list of all MS Teams Apps; calling -> %s", request_url)
2439
+ logger.debug("Get list of all MS Teams Apps; calling -> %s", request_url)
2261
2440
 
2262
2441
  request_header = self.request_header()
2263
2442
 
2264
2443
  retries = 0
2265
2444
  while True:
2266
- response = requests.get(request_url, headers=request_header, timeout=60)
2445
+ response = requests.get(
2446
+ request_url, headers=request_header, timeout=REQUEST_TIMEOUT
2447
+ )
2267
2448
  if response.ok:
2268
2449
  return self.parse_request_response(response)
2269
2450
  # Check if Session has expired - then re-authenticate and try once more
2270
2451
  elif response.status_code == 401 and retries == 0:
2271
- logger.warning("Session has expired - try to re-authenticate...")
2272
- self.authenticate(True)
2452
+ logger.debug("Session has expired - try to re-authenticate...")
2453
+ self.authenticate(revalidate=True)
2273
2454
  request_header = self.request_header()
2274
2455
  retries += 1
2275
2456
  elif response.status_code in [502, 503, 504] and retries < 3:
@@ -2291,35 +2472,59 @@ class M365(object):
2291
2472
  # end method definition
2292
2473
 
2293
2474
  def get_teams_app(self, app_id: str) -> dict | None:
2294
- """Get a specific MS Teams app in catalog based on the known app ID
2475
+ """Get a specific MS Teams app in catalog based on the known (internal) app ID
2295
2476
 
2296
2477
  Args:
2297
- app_id (str): ID of the app
2478
+ app_id (str): ID of the app (this is NOT the external ID but the internal ID)
2298
2479
  Returns:
2299
2480
  dict: response of the MS Graph API call or None if the call fails.
2481
+
2482
+ Examle response:
2483
+ {
2484
+ '@odata.context': 'https://graph.microsoft.com/v1.0/$metadata#appCatalogs/teamsApps(appDefinitions())/$entity',
2485
+ 'id': 'ccabe3fb-316f-40e0-a486-1659682cb8cd',
2486
+ 'externalId': 'dd4af790-d8ff-47a0-87ad-486318272c7a',
2487
+ 'displayName': 'Extended ECM',
2488
+ 'distributionMethod': 'organization',
2489
+ 'appDefinitions@odata.context': "https://graph.microsoft.com/v1.0/$metadata#appCatalogs/teamsApps('ccabe3fb-316f-40e0-a486-1659682cb8cd')/appDefinitions",
2490
+ 'appDefinitions': [
2491
+ {
2492
+ 'id': 'Y2NhYmUzZmItMzE2Zi00MGUwLWE0ODYtMTY1OTY4MmNiOGNkIyMyNC4yLjAjI1B1Ymxpc2hlZA==',
2493
+ 'teamsAppId': 'ccabe3fb-316f-40e0-a486-1659682cb8cd',
2494
+ 'displayName': 'Extended ECM',
2495
+ 'version': '24.2.0',
2496
+ 'publishingState': 'published',
2497
+ 'shortDescription': 'Add a tab for an Extended ECM business workspace.',
2498
+ 'description': 'View and interact with OpenText Extended ECM business workspaces',
2499
+ 'lastModifiedDateTime': None,
2500
+ 'createdBy': None,
2501
+ 'authorization': {...}
2502
+ }
2503
+ ]
2504
+ }
2300
2505
  """
2301
2506
 
2302
2507
  query = {"$expand": "AppDefinitions"}
2303
2508
  encoded_query = urllib.parse.urlencode(query, doseq=True)
2304
2509
  request_url = self.config()["teamsAppsUrl"] + "/" + app_id + "?" + encoded_query
2305
2510
 
2306
- # request_url = self.config()["teamsAppsUrl"] + "/" + app_id
2307
-
2308
- logger.info(
2309
- "Get MS Teams App with ID -> %s; calling -> %s", app_id, request_url
2511
+ logger.debug(
2512
+ "Get M365 Teams App with ID -> %s; calling -> %s", app_id, request_url
2310
2513
  )
2311
2514
 
2312
2515
  request_header = self.request_header()
2313
2516
 
2314
2517
  retries = 0
2315
2518
  while True:
2316
- response = requests.get(request_url, headers=request_header, timeout=60)
2519
+ response = requests.get(
2520
+ request_url, headers=request_header, timeout=REQUEST_TIMEOUT
2521
+ )
2317
2522
  if response.ok:
2318
2523
  return self.parse_request_response(response)
2319
2524
  # Check if Session has expired - then re-authenticate and try once more
2320
2525
  elif response.status_code == 401 and retries == 0:
2321
- logger.warning("Session has expired - try to re-authenticate...")
2322
- self.authenticate(True)
2526
+ logger.debug("Session has expired - try to re-authenticate...")
2527
+ self.authenticate(revalidate=True)
2323
2528
  request_header = self.request_header()
2324
2529
  retries += 1
2325
2530
  elif response.status_code in [502, 503, 504] and retries < 3:
@@ -2364,7 +2569,8 @@ class M365(object):
2364
2569
  + "/teamwork/installedApps?"
2365
2570
  + encoded_query
2366
2571
  )
2367
- logger.info(
2572
+
2573
+ logger.debug(
2368
2574
  "Get list of M365 Teams Apps for user -> %s using query -> %s; calling -> %s",
2369
2575
  user_id,
2370
2576
  query,
@@ -2375,13 +2581,15 @@ class M365(object):
2375
2581
 
2376
2582
  retries = 0
2377
2583
  while True:
2378
- response = requests.get(request_url, headers=request_header, timeout=60)
2584
+ response = requests.get(
2585
+ request_url, headers=request_header, timeout=REQUEST_TIMEOUT
2586
+ )
2379
2587
  if response.ok:
2380
2588
  return self.parse_request_response(response)
2381
2589
  # Check if Session has expired - then re-authenticate and try once more
2382
2590
  elif response.status_code == 401 and retries == 0:
2383
- logger.warning("Session has expired - try to re-authenticate...")
2384
- self.authenticate(True)
2591
+ logger.debug("Session has expired - try to re-authenticate...")
2592
+ self.authenticate(revalidate=True)
2385
2593
  request_header = self.request_header()
2386
2594
  retries += 1
2387
2595
  elif response.status_code in [502, 503, 504] and retries < 3:
@@ -2427,7 +2635,8 @@ class M365(object):
2427
2635
  + "/installedApps?"
2428
2636
  + encoded_query
2429
2637
  )
2430
- logger.info(
2638
+
2639
+ logger.debug(
2431
2640
  "Get list of M365 Teams Apps for M365 Team -> %s using query -> %s; calling -> %s",
2432
2641
  team_id,
2433
2642
  query,
@@ -2438,13 +2647,15 @@ class M365(object):
2438
2647
 
2439
2648
  retries = 0
2440
2649
  while True:
2441
- response = requests.get(request_url, headers=request_header, timeout=60)
2650
+ response = requests.get(
2651
+ request_url, headers=request_header, timeout=REQUEST_TIMEOUT
2652
+ )
2442
2653
  if response.ok:
2443
2654
  return self.parse_request_response(response)
2444
2655
  # Check if Session has expired - then re-authenticate and try once more
2445
2656
  elif response.status_code == 401 and retries == 0:
2446
- logger.warning("Session has expired - try to re-authenticate...")
2447
- self.authenticate(True)
2657
+ logger.debug("Session has expired - try to re-authenticate...")
2658
+ self.authenticate(revalidate=True)
2448
2659
  request_header = self.request_header()
2449
2660
  retries += 1
2450
2661
  elif response.status_code in [502, 503, 504] and retries < 3:
@@ -2495,6 +2706,7 @@ class M365(object):
2495
2706
  but requires a token of a user authenticated with username + password.
2496
2707
  See https://learn.microsoft.com/en-us/graph/api/teamsapp-publish
2497
2708
  (permissions table on that page)
2709
+ For updates see: https://learn.microsoft.com/en-us/graph/api/teamsapp-update?view=graph-rest-1.0&tabs=http
2498
2710
 
2499
2711
  Args:
2500
2712
  app_path (str): file path (with directory) to the app package to upload
@@ -2505,6 +2717,34 @@ class M365(object):
2505
2717
  after installation (which is tenant specific)
2506
2718
  Returns:
2507
2719
  dict: Response of the MS GRAPH API REST call or None if the request fails
2720
+
2721
+ The responses are different depending if it is an install or upgrade!!
2722
+
2723
+ Example return for upgrades ("teamsAppId" is the "internal" ID of the app):
2724
+ {
2725
+ '@odata.context': "https://graph.microsoft.com/v1.0/$metadata#appCatalogs/teamsApps('3f749cca-8cb0-4925-9fa0-ba7aca2014af')/appDefinitions/$entity",
2726
+ 'id': 'M2Y3NDljY2EtOGNiMC00OTI1LTlmYTAtYmE3YWNhMjAxNGFmIyMyNC4yLjAjI1B1Ymxpc2hlZA==',
2727
+ 'teamsAppId': '3f749cca-8cb0-4925-9fa0-ba7aca2014af',
2728
+ 'displayName': 'IDEA-TE - Extended ECM 24.2.0',
2729
+ 'version': '24.2.0',
2730
+ 'publishingState': 'published',
2731
+ 'shortDescription': 'Add a tab for an Extended ECM business workspace.',
2732
+ 'description': 'View and interact with OpenText Extended ECM business workspaces',
2733
+ 'lastModifiedDateTime': None,
2734
+ 'createdBy': None,
2735
+ 'authorization': {
2736
+ 'requiredPermissionSet': {...}
2737
+ }
2738
+ }
2739
+
2740
+ Example return for new installations ("id" is the "internal" ID of the app):
2741
+ {
2742
+ '@odata.context': 'https://graph.microsoft.com/v1.0/$metadata#appCatalogs/teamsApps/$entity',
2743
+ 'id': '6c672afd-37fc-46c6-8365-d499aba3808b',
2744
+ 'externalId': 'dd4af790-d8ff-47a0-87ad-486318272c7a',
2745
+ 'displayName': 'OpenText Extended ECM',
2746
+ 'distributionMethod': 'organization'
2747
+ }
2508
2748
  """
2509
2749
 
2510
2750
  if update_existing_app and not app_catalog_id:
@@ -2514,12 +2754,12 @@ class M365(object):
2514
2754
  return None
2515
2755
 
2516
2756
  if not os.path.exists(app_path):
2517
- logger.error("M365 Teams app file -> {} does not exist!")
2757
+ logger.error("M365 Teams app file -> %s does not exist!", app_path)
2518
2758
  return None
2519
2759
 
2520
2760
  # Ensure that the app file is a zip file
2521
2761
  if not app_path.endswith(".zip"):
2522
- logger.error("M365 Teams app file -> {} must be a zip file!")
2762
+ logger.error("M365 Teams app file -> %s must be a zip file!", app_path)
2523
2763
  return None
2524
2764
 
2525
2765
  request_url = self.config()["teamsAppsUrl"]
@@ -2527,12 +2767,11 @@ class M365(object):
2527
2767
  # the specific endpoint:
2528
2768
  if update_existing_app:
2529
2769
  request_url += "/" + app_catalog_id + "/appDefinitions"
2770
+
2530
2771
  # Here we need the credentials of an authenticated user!
2531
2772
  # (not the application credentials (client_id, client_secret))
2532
2773
  request_header = self.request_header_user("application/zip")
2533
2774
 
2534
- # upload_files = {'file': open(app_path, 'rb')}
2535
-
2536
2775
  with open(app_path, "rb") as f:
2537
2776
  app_data = f.read()
2538
2777
 
@@ -2540,12 +2779,13 @@ class M365(object):
2540
2779
  # Ensure that the app file contains a manifest.json file
2541
2780
  if "manifest.json" not in z.namelist():
2542
2781
  logger.error(
2543
- "M365 Teams app file -> {} does not contain a manifest.json file!"
2782
+ "M365 Teams app file -> '%s' does not contain a manifest.json file!",
2783
+ app_path,
2544
2784
  )
2545
2785
  return None
2546
2786
 
2547
- logger.info(
2548
- "Upload M365 Teams app -> %s to the MS Teams catalog; calling -> %s",
2787
+ logger.debug(
2788
+ "Upload M365 Teams app -> '%s' to the MS Teams catalog; calling -> %s",
2549
2789
  app_path,
2550
2790
  request_url,
2551
2791
  )
@@ -2553,15 +2793,18 @@ class M365(object):
2553
2793
  retries = 0
2554
2794
  while True:
2555
2795
  response = requests.post(
2556
- request_url, headers=request_header, data=app_data, timeout=60
2796
+ request_url,
2797
+ headers=request_header,
2798
+ data=app_data,
2799
+ timeout=REQUEST_TIMEOUT,
2557
2800
  )
2558
2801
  if response.ok:
2559
2802
  return self.parse_request_response(response)
2560
2803
 
2561
2804
  # Check if Session has expired - then re-authenticate and try once more
2562
2805
  if response.status_code == 401 and retries == 0:
2563
- logger.warning("Session has expired - try to re-authenticate...")
2564
- self.authenticate(True)
2806
+ logger.debug("Session has expired - try to re-authenticate...")
2807
+ self.authenticate(revalidate=True)
2565
2808
  request_header = self.request_header()
2566
2809
  retries += 1
2567
2810
  elif response.status_code in [502, 503, 504] and retries < 3:
@@ -2575,7 +2818,7 @@ class M365(object):
2575
2818
  else:
2576
2819
  if update_existing_app:
2577
2820
  logger.warning(
2578
- "Failed to update existing M365 Teams app -> %s (may be because it is not a new version); status -> %s; error -> %s",
2821
+ "Failed to update existing M365 Teams app -> '%s' (may be because it is not a new version); status -> %s; error -> %s",
2579
2822
  app_path,
2580
2823
  response.status_code,
2581
2824
  response.text,
@@ -2583,7 +2826,7 @@ class M365(object):
2583
2826
 
2584
2827
  else:
2585
2828
  logger.error(
2586
- "Failed to upload new M365 Teams app -> %s; status -> %s; error -> %s",
2829
+ "Failed to upload new M365 Teams app -> '%s'; status -> %s; error -> %s",
2587
2830
  app_path,
2588
2831
  response.status_code,
2589
2832
  response.text,
@@ -2605,11 +2848,13 @@ class M365(object):
2605
2848
  request_header = self.request_header_user()
2606
2849
 
2607
2850
  # Make the DELETE request to remove the app from the app catalog
2608
- response = requests.delete(request_url, headers=request_header, timeout=60)
2851
+ response = requests.delete(
2852
+ request_url, headers=request_header, timeout=REQUEST_TIMEOUT
2853
+ )
2609
2854
 
2610
2855
  # Check the status code of the response
2611
2856
  if response.status_code == 204:
2612
- logger.info(
2857
+ logger.debug(
2613
2858
  "The M365 Teams app with ID -> %s has been successfully removed from the app catalog.",
2614
2859
  app_id,
2615
2860
  )
@@ -2622,35 +2867,160 @@ class M365(object):
2622
2867
 
2623
2868
  # end method definition
2624
2869
 
2625
- def assign_teams_app_to_user(self, user_id: str, app_name: str) -> dict | None:
2870
+ def assign_teams_app_to_user(
2871
+ self,
2872
+ user_id: str,
2873
+ app_name: str = "",
2874
+ app_internal_id: str = "",
2875
+ show_error: bool = False,
2876
+ ) -> dict | None:
2626
2877
  """Assigns (adds) a M365 Teams app to a M365 user.
2627
2878
 
2879
+ See: https://learn.microsoft.com/en-us/graph/api/userteamwork-post-installedapps?view=graph-rest-1.0&tabs=http
2880
+
2628
2881
  Args:
2629
2882
  user_id (str): M365 GUID of the user (can also be the M365 email of the user)
2630
- app_name (str): exact name of the app
2883
+ app_name (str, optional): exact name of the app. Not needed if app_internal_id is provided
2884
+ app_internal_id (str, optional): internal ID of the app. If not provided it will be derived from app_name
2885
+ show_error (bool): whether or not an error should be displayed if the
2886
+ user is not found.
2631
2887
  Returns:
2632
2888
  dict: response of the MS Graph API call or None if the call fails.
2633
2889
  """
2634
2890
 
2635
- response = self.get_teams_apps(f"contains(displayName, '{app_name}')")
2636
- app_id = self.get_result_value(response, "id", 0)
2637
- if not app_id:
2638
- logger.error("M365 Teams App -> %s not found!", app_name)
2891
+ if not app_internal_id and not app_name:
2892
+ logger.error(
2893
+ "Either the internal App ID or the App name need to be provided!"
2894
+ )
2639
2895
  return None
2640
2896
 
2897
+ if not app_internal_id:
2898
+ response = self.get_teams_apps(
2899
+ filter_expression="contains(displayName, '{}')".format(app_name)
2900
+ )
2901
+ app_internal_id = self.get_result_value(
2902
+ response=response, key="id", index=0
2903
+ )
2904
+ if not app_internal_id:
2905
+ logger.error(
2906
+ "M365 Teams App -> '%s' not found! Cannot assign App to user -> %s.",
2907
+ app_name,
2908
+ user_id,
2909
+ )
2910
+ return None
2911
+
2641
2912
  request_url = (
2642
2913
  self.config()["usersUrl"] + "/" + user_id + "/teamwork/installedApps"
2643
2914
  )
2644
2915
  request_header = self.request_header()
2645
2916
 
2646
2917
  post_body = {
2647
- "teamsApp@odata.bind": self.config()["teamsAppsUrl"] + "/" + app_id
2918
+ "teamsApp@odata.bind": self.config()["teamsAppsUrl"] + "/" + app_internal_id
2648
2919
  }
2649
2920
 
2650
- logger.info(
2651
- "Assign M365 Teams app -> %s (%s) to M365 user -> %s; calling -> %s",
2921
+ logger.debug(
2922
+ "Assign M365 Teams app -> '%s' (%s) to M365 user -> %s; calling -> %s",
2652
2923
  app_name,
2653
- app_id,
2924
+ app_internal_id,
2925
+ user_id,
2926
+ request_url,
2927
+ )
2928
+
2929
+ retries = 0
2930
+ while True:
2931
+ response = requests.post(
2932
+ request_url,
2933
+ json=post_body,
2934
+ headers=request_header,
2935
+ timeout=REQUEST_TIMEOUT,
2936
+ )
2937
+ if response.ok:
2938
+ return self.parse_request_response(response)
2939
+ # Check if Session has expired - then re-authenticate and try once more
2940
+ elif response.status_code == 401 and retries == 0:
2941
+ logger.debug("Session has expired - try to re-authenticate...")
2942
+ self.authenticate(revalidate=True)
2943
+ request_header = self.request_header()
2944
+ retries += 1
2945
+ elif response.status_code in [502, 503, 504] and retries < 3:
2946
+ logger.warning(
2947
+ "M365 Graph API delivered server side error -> %s; retrying in %s seconds...",
2948
+ response.status_code,
2949
+ (retries + 1) * 60,
2950
+ )
2951
+ time.sleep((retries + 1) * 60)
2952
+ retries += 1
2953
+ else:
2954
+ if show_error:
2955
+ logger.error(
2956
+ "Failed to assign M365 Teams app -> '%s' (%s) to M365 user -> %s; status -> %s; error -> %s",
2957
+ app_name,
2958
+ app_internal_id,
2959
+ user_id,
2960
+ response.status_code,
2961
+ response.text,
2962
+ )
2963
+ else:
2964
+ logger.warning(
2965
+ "Failed to assign M365 Teams app -> '%s' (%s) to M365 user -> %s (could be because the app is assigned organization-wide); status -> %s; warning -> %s",
2966
+ app_name,
2967
+ app_internal_id,
2968
+ user_id,
2969
+ response.status_code,
2970
+ response.text,
2971
+ )
2972
+ return None
2973
+
2974
+ # end method definition
2975
+
2976
+ def upgrade_teams_app_of_user(
2977
+ self, user_id: str, app_name: str, app_installation_id: str | None = None
2978
+ ) -> dict | None:
2979
+ """Upgrade a MS teams app for a user. The call will fail if the user does not
2980
+ already have the app assigned. So this needs to be checked before
2981
+ calling this method.
2982
+ See: https://learn.microsoft.com/en-us/graph/api/userteamwork-teamsappinstallation-upgrade?view=graph-rest-1.0&tabs=http
2983
+
2984
+ Args:
2985
+ user_id (str): M365 GUID of the user (can also be the M365 email of the user)
2986
+ app_name (str): exact name of the app
2987
+ app_installation_id (str): ID of the app installation for the user. This is neither the internal nor
2988
+ external app ID. It is specific for each user and app.
2989
+ Returns:
2990
+ dict: response of the MS Graph API call or None if the call fails.
2991
+ """
2992
+
2993
+ if not app_installation_id:
2994
+ response = self.get_teams_apps_of_user(
2995
+ user_id=user_id,
2996
+ filter_expression="contains(teamsAppDefinition/displayName, '{}')".format(
2997
+ app_name
2998
+ ),
2999
+ )
3000
+ # Retrieve the installation specific App ID - this is different from thew App catalalog ID!!
3001
+ app_installation_id = self.get_result_value(response, "id", 0)
3002
+ if not app_installation_id:
3003
+ logger.error(
3004
+ "M365 Teams app -> '%s' not found for user with ID -> %s. Cannot upgrade app for this user!",
3005
+ app_name,
3006
+ user_id,
3007
+ )
3008
+ return None
3009
+
3010
+ request_url = (
3011
+ self.config()["usersUrl"]
3012
+ + "/"
3013
+ + user_id
3014
+ + "/teamwork/installedApps/"
3015
+ + app_installation_id
3016
+ + "/upgrade"
3017
+ )
3018
+ request_header = self.request_header()
3019
+
3020
+ logger.debug(
3021
+ "Upgrade M365 Teams app -> '%s' (%s) of M365 user with ID -> %s; calling -> %s",
3022
+ app_name,
3023
+ app_installation_id,
2654
3024
  user_id,
2655
3025
  request_url,
2656
3026
  )
@@ -2658,14 +3028,14 @@ class M365(object):
2658
3028
  retries = 0
2659
3029
  while True:
2660
3030
  response = requests.post(
2661
- request_url, json=post_body, headers=request_header, timeout=60
3031
+ request_url, headers=request_header, timeout=REQUEST_TIMEOUT
2662
3032
  )
2663
3033
  if response.ok:
2664
3034
  return self.parse_request_response(response)
2665
3035
  # Check if Session has expired - then re-authenticate and try once more
2666
3036
  elif response.status_code == 401 and retries == 0:
2667
- logger.warning("Session has expired - try to re-authenticate...")
2668
- self.authenticate(True)
3037
+ logger.debug("Session has expired - try to re-authenticate...")
3038
+ self.authenticate(revalidate=True)
2669
3039
  request_header = self.request_header()
2670
3040
  retries += 1
2671
3041
  elif response.status_code in [502, 503, 504] and retries < 3:
@@ -2678,9 +3048,9 @@ class M365(object):
2678
3048
  retries += 1
2679
3049
  else:
2680
3050
  logger.error(
2681
- "Failed to assign M365 Teams app -> %s (%s) to M365 user -> %s; status -> %s; error -> %s",
3051
+ "Failed to upgrade M365 Teams app -> '%s' (%s) of M365 user -> %s; status -> %s; error -> %s",
2682
3052
  app_name,
2683
- app_id,
3053
+ app_installation_id,
2684
3054
  user_id,
2685
3055
  response.status_code,
2686
3056
  response.text,
@@ -2689,10 +3059,12 @@ class M365(object):
2689
3059
 
2690
3060
  # end method definition
2691
3061
 
2692
- def upgrade_teams_app_of_user(self, user_id: str, app_name: str) -> dict | None:
2693
- """Upgrade a MS teams app for a user. The call will fail if the user does not
2694
- already have the app assigned. So this needs to be checked before
2695
- calling this method.
3062
+ def remove_teams_app_from_user(
3063
+ self, user_id: str, app_name: str, app_installation_id: str | None = None
3064
+ ) -> dict | None:
3065
+ """Remove a M365 Teams app from a M365 user.
3066
+
3067
+ See: https://learn.microsoft.com/en-us/graph/api/userteamwork-delete-installedapps?view=graph-rest-1.0&tabs=http
2696
3068
 
2697
3069
  Args:
2698
3070
  user_id (str): M365 GUID of the user (can also be the M365 email of the user)
@@ -2701,14 +3073,18 @@ class M365(object):
2701
3073
  dict: response of the MS Graph API call or None if the call fails.
2702
3074
  """
2703
3075
 
2704
- response = self.get_teams_apps_of_user(
2705
- user_id, "contains(teamsAppDefinition/displayName, '{}')".format(app_name)
2706
- )
2707
- # Retrieve the installation specific App ID - this is different from thew App catalalog ID!!
2708
- app_installation_id = self.get_result_value(response, "id", 0)
3076
+ if not app_installation_id:
3077
+ response = self.get_teams_apps_of_user(
3078
+ user_id=user_id,
3079
+ filter_expression="contains(teamsAppDefinition/displayName, '{}')".format(
3080
+ app_name
3081
+ ),
3082
+ )
3083
+ # Retrieve the installation specific App ID - this is different from thew App catalalog ID!!
3084
+ app_installation_id = self.get_result_value(response, "id", 0)
2709
3085
  if not app_installation_id:
2710
3086
  logger.error(
2711
- "M365 Teams app -> %s not found for user with ID -> %s. Cannot upgrade app for this user!",
3087
+ "M365 Teams app -> '%s' not found for user with ID -> %s. Cannot remove app from this user!",
2712
3088
  app_name,
2713
3089
  user_id,
2714
3090
  )
@@ -2720,12 +3096,11 @@ class M365(object):
2720
3096
  + user_id
2721
3097
  + "/teamwork/installedApps/"
2722
3098
  + app_installation_id
2723
- + "/upgrade"
2724
3099
  )
2725
3100
  request_header = self.request_header()
2726
3101
 
2727
- logger.info(
2728
- "Upgrade M365 Teams app -> %s (%s) of M365 user with ID -> %s; calling -> %s",
3102
+ logger.debug(
3103
+ "Remove M365 Teams app -> '%s' (%s) from M365 user with ID -> %s; calling -> %s",
2729
3104
  app_name,
2730
3105
  app_installation_id,
2731
3106
  user_id,
@@ -2734,13 +3109,15 @@ class M365(object):
2734
3109
 
2735
3110
  retries = 0
2736
3111
  while True:
2737
- response = requests.post(request_url, headers=request_header, timeout=60)
3112
+ response = requests.delete(
3113
+ request_url, headers=request_header, timeout=REQUEST_TIMEOUT
3114
+ )
2738
3115
  if response.ok:
2739
3116
  return self.parse_request_response(response)
2740
3117
  # Check if Session has expired - then re-authenticate and try once more
2741
3118
  elif response.status_code == 401 and retries == 0:
2742
- logger.warning("Session has expired - try to re-authenticate...")
2743
- self.authenticate(True)
3119
+ logger.debug("Session has expired - try to re-authenticate...")
3120
+ self.authenticate(revalidate=True)
2744
3121
  request_header = self.request_header()
2745
3122
  retries += 1
2746
3123
  elif response.status_code in [502, 503, 504] and retries < 3:
@@ -2753,7 +3130,7 @@ class M365(object):
2753
3130
  retries += 1
2754
3131
  else:
2755
3132
  logger.error(
2756
- "Failed to upgrade M365 Teams app -> %s (%s) of M365 user -> %s; status -> %s; error -> %s",
3133
+ "Failed to remove M365 Teams app -> '%s' (%s) from M365 user -> %s; status -> %s; error -> %s",
2757
3134
  app_name,
2758
3135
  app_installation_id,
2759
3136
  user_id,
@@ -2783,8 +3160,9 @@ class M365(object):
2783
3160
  "teamsApp@odata.bind": self.config()["teamsAppsUrl"] + "/" + app_id
2784
3161
  }
2785
3162
 
2786
- logger.info(
2787
- "Assign M365 Teams app -> %s to M365 Team -> %s; calling -> %s",
3163
+ logger.debug(
3164
+ "Assign M365 Teams app -> '%s' (%s) to M365 Team -> %s; calling -> %s",
3165
+ self.config()["teamsAppName"],
2788
3166
  app_id,
2789
3167
  team_id,
2790
3168
  request_url,
@@ -2793,14 +3171,17 @@ class M365(object):
2793
3171
  retries = 0
2794
3172
  while True:
2795
3173
  response = requests.post(
2796
- request_url, json=post_body, headers=request_header, timeout=60
3174
+ request_url,
3175
+ json=post_body,
3176
+ headers=request_header,
3177
+ timeout=REQUEST_TIMEOUT,
2797
3178
  )
2798
3179
  if response.ok:
2799
3180
  return self.parse_request_response(response)
2800
3181
  # Check if Session has expired - then re-authenticate and try once more
2801
3182
  elif response.status_code == 401 and retries == 0:
2802
- logger.warning("Session has expired - try to re-authenticate...")
2803
- self.authenticate(True)
3183
+ logger.debug("Session has expired - try to re-authenticate...")
3184
+ self.authenticate(revalidate=True)
2804
3185
  request_header = self.request_header()
2805
3186
  retries += 1
2806
3187
  elif response.status_code in [502, 503, 504] and retries < 3:
@@ -2813,7 +3194,8 @@ class M365(object):
2813
3194
  retries += 1
2814
3195
  else:
2815
3196
  logger.error(
2816
- "Failed to assign M365 Teams app -> %s to M365 Team -> %s; status -> %s; error -> %s",
3197
+ "Failed to assign M365 Teams app -> '%s' (%s) to M365 Team -> %s; status -> %s; error -> %s",
3198
+ self.config()["teamsAppName"],
2817
3199
  app_id,
2818
3200
  team_id,
2819
3201
  response.status_code,
@@ -2843,7 +3225,7 @@ class M365(object):
2843
3225
  app_installation_id = self.get_result_value(response, "id", 0)
2844
3226
  if not app_installation_id:
2845
3227
  logger.error(
2846
- "M365 Teams app -> %s not found for M365 Team with ID -> %s. Cannot upgrade app for this team!",
3228
+ "M365 Teams app -> '%s' not found for M365 Team with ID -> %s. Cannot upgrade app for this team!",
2847
3229
  app_name,
2848
3230
  team_id,
2849
3231
  )
@@ -2859,8 +3241,8 @@ class M365(object):
2859
3241
  )
2860
3242
  request_header = self.request_header()
2861
3243
 
2862
- logger.info(
2863
- "Upgrade app -> %s (%s) of M365 team with ID -> %s; calling -> %s",
3244
+ logger.debug(
3245
+ "Upgrade app -> '%s' (%s) of M365 team with ID -> %s; calling -> %s",
2864
3246
  app_name,
2865
3247
  app_installation_id,
2866
3248
  team_id,
@@ -2869,13 +3251,15 @@ class M365(object):
2869
3251
 
2870
3252
  retries = 0
2871
3253
  while True:
2872
- response = requests.post(request_url, headers=request_header, timeout=60)
3254
+ response = requests.post(
3255
+ request_url, headers=request_header, timeout=REQUEST_TIMEOUT
3256
+ )
2873
3257
  if response.ok:
2874
3258
  return self.parse_request_response(response)
2875
3259
  # Check if Session has expired - then re-authenticate and try once more
2876
3260
  elif response.status_code == 401 and retries == 0:
2877
- logger.warning("Session has expired - try to re-authenticate...")
2878
- self.authenticate(True)
3261
+ logger.debug("Session has expired - try to re-authenticate...")
3262
+ self.authenticate(revalidate=True)
2879
3263
  request_header = self.request_header()
2880
3264
  retries += 1
2881
3265
  elif response.status_code in [502, 503, 504] and retries < 3:
@@ -2888,7 +3272,7 @@ class M365(object):
2888
3272
  retries += 1
2889
3273
  else:
2890
3274
  logger.error(
2891
- "Failed to upgrade app -> %s (%s) of M365 team with ID -> %s; status -> %s; error -> %s",
3275
+ "Failed to upgrade M365 Teams app -> '%s' (%s) of M365 team with ID -> %s; status -> %s; error -> %s",
2892
3276
  app_name,
2893
3277
  app_installation_id,
2894
3278
  team_id,
@@ -2940,8 +3324,10 @@ class M365(object):
2940
3324
  None,
2941
3325
  )
2942
3326
  if not channel:
2943
- logger.erro(
2944
- "Cannot find Channel -> %s on M365 Team -> %s", channel_name, team_name
3327
+ logger.error(
3328
+ "Cannot find Channel -> '%s' on M365 Team -> '%s'",
3329
+ channel_name,
3330
+ team_name,
2945
3331
  )
2946
3332
  return None
2947
3333
  channel_id = channel["id"]
@@ -2969,8 +3355,8 @@ class M365(object):
2969
3355
  },
2970
3356
  }
2971
3357
 
2972
- logger.info(
2973
- "Add Tab -> %s with App ID -> %s to Channel -> %s of Microsoft 365 Team -> %s; calling -> %s",
3358
+ logger.debug(
3359
+ "Add Tab -> '%s' with App ID -> %s to Channel -> '%s' of Microsoft 365 Team -> '%s'; calling -> %s",
2974
3360
  tab_name,
2975
3361
  app_id,
2976
3362
  channel_name,
@@ -2981,14 +3367,17 @@ class M365(object):
2981
3367
  retries = 0
2982
3368
  while True:
2983
3369
  response = requests.post(
2984
- request_url, headers=request_header, json=tab_config, timeout=60
3370
+ request_url,
3371
+ headers=request_header,
3372
+ json=tab_config,
3373
+ timeout=REQUEST_TIMEOUT,
2985
3374
  )
2986
3375
  if response.ok:
2987
3376
  return self.parse_request_response(response)
2988
3377
  # Check if Session has expired - then re-authenticate and try once more
2989
3378
  elif response.status_code == 401 and retries == 0:
2990
- logger.warning("Session has expired - try to re-authenticate...")
2991
- self.authenticate(True)
3379
+ logger.debug("Session has expired - try to re-authenticate...")
3380
+ self.authenticate(revalidate=True)
2992
3381
  request_header = self.request_header()
2993
3382
  retries += 1
2994
3383
  elif response.status_code in [502, 503, 504] and retries < 3:
@@ -3001,7 +3390,7 @@ class M365(object):
3001
3390
  retries += 1
3002
3391
  else:
3003
3392
  logger.error(
3004
- "Failed to add Tab for M365 Team -> %s (%s) and Channel -> %s (%s); status -> %s; error -> %s; tab config -> %s",
3393
+ "Failed to add Tab for M365 Team -> '%s' (%s) and Channel -> '%s' (%s); status -> %s; error -> %s; tab config -> %s",
3005
3394
  team_name,
3006
3395
  team_id,
3007
3396
  channel_name,
@@ -3053,8 +3442,10 @@ class M365(object):
3053
3442
  None,
3054
3443
  )
3055
3444
  if not channel:
3056
- logger.erro(
3057
- "Cannot find Channel -> %s for M365 Team -> %s", channel_name, team_name
3445
+ logger.error(
3446
+ "Cannot find Channel -> '%s' for M365 Team -> '%s'",
3447
+ channel_name,
3448
+ team_name,
3058
3449
  )
3059
3450
  return None
3060
3451
  channel_id = channel["id"]
@@ -3070,8 +3461,8 @@ class M365(object):
3070
3461
  None,
3071
3462
  )
3072
3463
  if not tab:
3073
- logger.erro(
3074
- "Cannot find Tab -> %s on M365 Team -> %s (%s) and Channel -> %s (%s)",
3464
+ logger.error(
3465
+ "Cannot find Tab -> '%s' on M365 Team -> '%s' (%s) and Channel -> '%s' (%s)",
3075
3466
  tab_name,
3076
3467
  team_name,
3077
3468
  team_id,
@@ -3103,8 +3494,8 @@ class M365(object):
3103
3494
  },
3104
3495
  }
3105
3496
 
3106
- logger.info(
3107
- "Update Tab -> %s (%s) of Channel -> %s (%s) for Microsoft 365 Teams -> %s (%s) with configuration -> %s; calling -> %s",
3497
+ logger.debug(
3498
+ "Update Tab -> '%s' (%s) of Channel -> '%s' (%s) for Microsoft 365 Teams -> '%s' (%s) with configuration -> %s; calling -> %s",
3108
3499
  tab_name,
3109
3500
  tab_id,
3110
3501
  channel_name,
@@ -3118,14 +3509,17 @@ class M365(object):
3118
3509
  retries = 0
3119
3510
  while True:
3120
3511
  response = requests.patch(
3121
- request_url, headers=request_header, json=tab_config, timeout=60
3512
+ request_url,
3513
+ headers=request_header,
3514
+ json=tab_config,
3515
+ timeout=REQUEST_TIMEOUT,
3122
3516
  )
3123
3517
  if response.ok:
3124
3518
  return self.parse_request_response(response)
3125
3519
  # Check if Session has expired - then re-authenticate and try once more
3126
3520
  elif response.status_code == 401 and retries == 0:
3127
- logger.warning("Session has expired - try to re-authenticate...")
3128
- self.authenticate(True)
3521
+ logger.debug("Session has expired - try to re-authenticate...")
3522
+ self.authenticate(revalidate=True)
3129
3523
  request_header = self.request_header()
3130
3524
  retries += 1
3131
3525
  elif response.status_code in [502, 503, 504] and retries < 3:
@@ -3138,7 +3532,7 @@ class M365(object):
3138
3532
  retries += 1
3139
3533
  else:
3140
3534
  logger.error(
3141
- "Failed to update Tab -> %s (%s) for M365 Team -> %s (%s) and Channel -> %s (%s); status -> %s; error -> %s",
3535
+ "Failed to update Tab -> '%s' (%s) for M365 Team -> '%s' (%s) and Channel -> '%s' (%s); status -> %s; error -> %s",
3142
3536
  tab_name,
3143
3537
  tab_id,
3144
3538
  team_name,
@@ -3184,8 +3578,10 @@ class M365(object):
3184
3578
  None,
3185
3579
  )
3186
3580
  if not channel:
3187
- logger.erro(
3188
- "Cannot find Channel -> %s for M365 Team -> %s", channel_name, team_name
3581
+ logger.error(
3582
+ "Cannot find Channel -> '%s' for M365 Team -> '%s'",
3583
+ channel_name,
3584
+ team_name,
3189
3585
  )
3190
3586
  return False
3191
3587
  channel_id = channel["id"]
@@ -3201,8 +3597,8 @@ class M365(object):
3201
3597
  item for item in response["value"] if item["displayName"] == tab_name
3202
3598
  ]
3203
3599
  if not tab_list:
3204
- logger.erro(
3205
- "Cannot find Tabs with name -> %s on M365 Team -> %s (%s) and Channel -> %s (%s)",
3600
+ logger.error(
3601
+ "Cannot find Tab -> '%s' on M365 Team -> '%s' (%s) and Channel -> '%s' (%s)",
3206
3602
  tab_name,
3207
3603
  team_name,
3208
3604
  team_id,
@@ -3226,8 +3622,8 @@ class M365(object):
3226
3622
 
3227
3623
  request_header = self.request_header()
3228
3624
 
3229
- logger.info(
3230
- "Delete Tab -> %s (%s) from Channel -> %s (%s) of Microsoft 365 Teams -> %s (%s); calling -> %s",
3625
+ logger.debug(
3626
+ "Delete Tab -> '%s' (%s) from Channel -> '%s' (%s) of Microsoft 365 Teams -> '%s' (%s); calling -> %s",
3231
3627
  tab_name,
3232
3628
  tab_id,
3233
3629
  channel_name,
@@ -3240,11 +3636,11 @@ class M365(object):
3240
3636
  retries = 0
3241
3637
  while True:
3242
3638
  response = requests.delete(
3243
- request_url, headers=request_header, timeout=60
3639
+ request_url, headers=request_header, timeout=REQUEST_TIMEOUT
3244
3640
  )
3245
3641
  if response.ok:
3246
- logger.info(
3247
- "Tab -> %s (%s) has been deleted from Channel -> %s (%s) of Microsoft 365 Teams -> %s (%s)",
3642
+ logger.debug(
3643
+ "Tab -> '%s' (%s) has been deleted from Channel -> '%s' (%s) of Microsoft 365 Teams -> '%s' (%s)",
3248
3644
  tab_name,
3249
3645
  tab_id,
3250
3646
  channel_name,
@@ -3255,8 +3651,8 @@ class M365(object):
3255
3651
  break
3256
3652
  # Check if Session has expired - then re-authenticate and try once more
3257
3653
  elif response.status_code == 401 and retries == 0:
3258
- logger.warning("Session has expired - try to re-authenticate...")
3259
- self.authenticate(True)
3654
+ logger.debug("Session has expired - try to re-authenticate...")
3655
+ self.authenticate(revalidate=True)
3260
3656
  request_header = self.request_header()
3261
3657
  retries += 1
3262
3658
  elif response.status_code in [502, 503, 504] and retries < 3:
@@ -3269,7 +3665,7 @@ class M365(object):
3269
3665
  retries += 1
3270
3666
  else:
3271
3667
  logger.error(
3272
- "Failed to delete Tab -> %s (%s) for M365 Team -> %s (%s) and Channel -> %s (%s); status -> %s; error -> %s",
3668
+ "Failed to delete Tab -> '%s' (%s) for M365 Team -> '%s' (%s) and Channel -> '%s' (%s); status -> %s; error -> %s",
3273
3669
  tab_name,
3274
3670
  tab_id,
3275
3671
  team_name,
@@ -3329,22 +3725,25 @@ class M365(object):
3329
3725
  request_url = self.config()["securityUrl"] + "/sensitivityLabels"
3330
3726
  request_header = self.request_header()
3331
3727
 
3332
- logger.info(
3333
- "Create M365 sensitivity label -> %s; calling -> %s", name, request_url
3728
+ logger.debug(
3729
+ "Create M365 sensitivity label -> '%s'; calling -> %s", name, request_url
3334
3730
  )
3335
3731
 
3336
3732
  # Send the POST request to create the label
3337
3733
  response = requests.post(
3338
- request_url, headers=request_header, data=json.dumps(payload), timeout=60
3734
+ request_url,
3735
+ headers=request_header,
3736
+ data=json.dumps(payload),
3737
+ timeout=REQUEST_TIMEOUT,
3339
3738
  )
3340
3739
 
3341
3740
  # Check the response status code
3342
3741
  if response.status_code == 201:
3343
- logger.info("Label -> %s has been created successfully!", name)
3742
+ logger.debug("Label -> '%s' has been created successfully!", name)
3344
3743
  return response
3345
3744
  else:
3346
3745
  logger.error(
3347
- "Failed to create the M365 label -> %s! Response status code -> %s",
3746
+ "Failed to create the M365 label -> '%s'! Response status code -> %s",
3348
3747
  name,
3349
3748
  response.status_code,
3350
3749
  )
@@ -3372,8 +3771,8 @@ class M365(object):
3372
3771
  )
3373
3772
  request_header = self.request_header()
3374
3773
 
3375
- logger.info(
3376
- "Assign label -> %s to user -> %s; calling -> %s",
3774
+ logger.debug(
3775
+ "Assign label -> '%s' to user -> '%s'; calling -> %s",
3377
3776
  label_name,
3378
3777
  user_email,
3379
3778
  request_url,
@@ -3382,14 +3781,14 @@ class M365(object):
3382
3781
  retries = 0
3383
3782
  while True:
3384
3783
  response = requests.post(
3385
- request_url, headers=request_header, json=body, timeout=60
3784
+ request_url, headers=request_header, json=body, timeout=REQUEST_TIMEOUT
3386
3785
  )
3387
3786
  if response.ok:
3388
3787
  return self.parse_request_response(response)
3389
3788
  # Check if Session has expired - then re-authenticate and try once more
3390
3789
  elif response.status_code == 401 and retries == 0:
3391
- logger.warning("Session has expired - try to re-authenticate...")
3392
- self.authenticate(True)
3790
+ logger.debug("Session has expired - try to re-authenticate...")
3791
+ self.authenticate(revalidate=True)
3393
3792
  request_header = self.request_header()
3394
3793
  retries += 1
3395
3794
  elif response.status_code in [502, 503, 504] and retries < 3:
@@ -3402,7 +3801,7 @@ class M365(object):
3402
3801
  retries += 1
3403
3802
  else:
3404
3803
  logger.error(
3405
- "Failed to assign label -> %s to M365 user -> %s; status -> %s; error -> %s",
3804
+ "Failed to assign label -> '%s' to M365 user -> '%s'; status -> %s; error -> %s",
3406
3805
  label_name,
3407
3806
  user_email,
3408
3807
  response.status_code,
@@ -3433,7 +3832,7 @@ class M365(object):
3433
3832
 
3434
3833
  # request_header = self.request_header()
3435
3834
 
3436
- logger.info("Install Outlook Add-in from %s (NOT IMPLEMENTED)", app_path)
3835
+ logger.debug("Install Outlook Add-in from '%s' (NOT IMPLEMENTED)", app_path)
3437
3836
 
3438
3837
  response = None
3439
3838
 
@@ -3459,21 +3858,23 @@ class M365(object):
3459
3858
  ] + "?$filter=displayName eq '{}'".format(app_registration_name)
3460
3859
  request_header = self.request_header()
3461
3860
 
3462
- logger.info(
3463
- "Get Azure App Registration -> %s; calling -> %s",
3861
+ logger.debug(
3862
+ "Get Azure App Registration -> '%s'; calling -> %s",
3464
3863
  app_registration_name,
3465
3864
  request_url,
3466
3865
  )
3467
3866
 
3468
3867
  retries = 0
3469
3868
  while True:
3470
- response = requests.get(request_url, headers=request_header, timeout=60)
3869
+ response = requests.get(
3870
+ request_url, headers=request_header, timeout=REQUEST_TIMEOUT
3871
+ )
3471
3872
  if response.ok:
3472
3873
  return self.parse_request_response(response)
3473
3874
  # Check if Session has expired - then re-authenticate and try once more
3474
3875
  elif response.status_code == 401 and retries == 0:
3475
- logger.warning("Session has expired - try to re-authenticate...")
3476
- self.authenticate(True)
3876
+ logger.debug("Session has expired - try to re-authenticate...")
3877
+ self.authenticate(revalidate=True)
3477
3878
  request_header = self.request_header()
3478
3879
  retries += 1
3479
3880
  elif response.status_code in [502, 503, 504] and retries < 3:
@@ -3486,7 +3887,7 @@ class M365(object):
3486
3887
  retries += 1
3487
3888
  else:
3488
3889
  logger.error(
3489
- "Cannot find Azure App Registration -> %s; status -> %s; error -> %s",
3890
+ "Cannot find Azure App Registration -> '%s'; status -> %s; error -> %s",
3490
3891
  app_registration_name,
3491
3892
  response.status_code,
3492
3893
  response.text,
@@ -3568,14 +3969,14 @@ class M365(object):
3568
3969
  request_url,
3569
3970
  headers=request_header,
3570
3971
  json=app_registration_data,
3571
- timeout=60,
3972
+ timeout=REQUEST_TIMEOUT,
3572
3973
  )
3573
3974
  if response.ok:
3574
3975
  return self.parse_request_response(response)
3575
3976
  # Check if Session has expired - then re-authenticate and try once more
3576
3977
  elif response.status_code == 401 and retries == 0:
3577
- logger.warning("Session has expired - try to re-authenticate...")
3578
- self.authenticate(True)
3978
+ logger.debug("Session has expired - try to re-authenticate...")
3979
+ self.authenticate(revalidate=True)
3579
3980
  request_header = self.request_header()
3580
3981
  retries += 1
3581
3982
  elif response.status_code in [502, 503, 504] and retries < 3:
@@ -3588,7 +3989,7 @@ class M365(object):
3588
3989
  retries += 1
3589
3990
  else:
3590
3991
  logger.error(
3591
- "Cannot add App Registration -> %s; status -> %s; error -> %s",
3992
+ "Cannot add App Registration -> '%s'; status -> %s; error -> %s",
3592
3993
  app_registration_name,
3593
3994
  response.status_code,
3594
3995
  response.text,
@@ -3627,8 +4028,8 @@ class M365(object):
3627
4028
  request_url = self.config()["applicationsUrl"] + "/" + app_registration_id
3628
4029
  request_header = self.request_header()
3629
4030
 
3630
- logger.info(
3631
- "Update App Registration -> %s (%s); calling -> %s",
4031
+ logger.debug(
4032
+ "Update App Registration -> '%s' (%s); calling -> %s",
3632
4033
  app_registration_name,
3633
4034
  app_registration_id,
3634
4035
  request_url,
@@ -3640,14 +4041,14 @@ class M365(object):
3640
4041
  request_url,
3641
4042
  headers=request_header,
3642
4043
  json=app_registration_data,
3643
- timeout=60,
4044
+ timeout=REQUEST_TIMEOUT,
3644
4045
  )
3645
4046
  if response.ok:
3646
4047
  return self.parse_request_response(response)
3647
4048
  # Check if Session has expired - then re-authenticate and try once more
3648
4049
  elif response.status_code == 401 and retries == 0:
3649
- logger.warning("Session has expired - try to re-authenticate...")
3650
- self.authenticate(True)
4050
+ logger.debug("Session has expired - try to re-authenticate...")
4051
+ self.authenticate(revalidate=True)
3651
4052
  request_header = self.request_header()
3652
4053
  retries += 1
3653
4054
  elif response.status_code in [502, 503, 504] and retries < 3:
@@ -3660,7 +4061,7 @@ class M365(object):
3660
4061
  retries += 1
3661
4062
  else:
3662
4063
  logger.error(
3663
- "Cannot update App Registration -> %s (%s); status -> %s; error -> %s",
4064
+ "Cannot update App Registration -> '%s' (%s); status -> %s; error -> %s",
3664
4065
  app_registration_name,
3665
4066
  app_registration_id,
3666
4067
  response.status_code,
@@ -3669,3 +4070,522 @@ class M365(object):
3669
4070
  return None
3670
4071
 
3671
4072
  # end method definition
4073
+
4074
+ def get_mail(
4075
+ self,
4076
+ user_id: str,
4077
+ sender: str,
4078
+ subject: str,
4079
+ num_emails: int | None = None,
4080
+ show_error: bool = False,
4081
+ ) -> dict | None:
4082
+ """Get email from inbox of a given user and a given sender (from)
4083
+ This requires Mail.Read Application permissions for the Azure App being used.
4084
+
4085
+ Args:
4086
+ user_id (str): M365 ID of the user
4087
+ sender (str): sender email address to filter for
4088
+ num_emails (int, optional): number of matching emails to retrieve
4089
+ show_error (bool): whether or not an error should be displayed if the
4090
+ user is not found.
4091
+ Returns:
4092
+ dict: Email or None of the request fails.
4093
+ """
4094
+
4095
+ # Attention: you can easily run in limitation of the MS Graph API. If selection + filtering
4096
+ # is too complex you can get this error: "The restriction or sort order is too complex for this operation."
4097
+ # that's why we first just do the ordering and then do the filtering on sender and subject
4098
+ # separately
4099
+ request_url = (
4100
+ self.config()["usersUrl"]
4101
+ + "/"
4102
+ + user_id
4103
+ # + "/messages?$filter=from/emailAddress/address eq '{}' and contains(subject, '{}')&$orderby=receivedDateTime desc".format(
4104
+ + "/messages?$orderby=receivedDateTime desc"
4105
+ )
4106
+ if num_emails:
4107
+ request_url += "&$top={}".format(num_emails)
4108
+
4109
+ request_header = self.request_header()
4110
+
4111
+ logger.debug(
4112
+ "Retrieve mails for user -> %s from -> '%s' with subject -> '%s'; calling -> %s",
4113
+ user_id,
4114
+ sender,
4115
+ subject,
4116
+ request_url,
4117
+ )
4118
+
4119
+ retries = 0
4120
+ while True:
4121
+ response = requests.get(
4122
+ request_url, headers=request_header, timeout=REQUEST_TIMEOUT
4123
+ )
4124
+ if response.ok:
4125
+ response = self.parse_request_response(response)
4126
+ messages = response["value"] if response else []
4127
+
4128
+ # Filter the messages by sender and subject in code
4129
+ filtered_messages = [
4130
+ msg
4131
+ for msg in messages
4132
+ if msg.get("from", {}).get("emailAddress", {}).get("address")
4133
+ == sender
4134
+ and subject in msg.get("subject", "")
4135
+ ]
4136
+ response["value"] = filtered_messages
4137
+ return response
4138
+
4139
+ # Check if Session has expired - then re-authenticate and try once more
4140
+ elif response.status_code == 401 and retries == 0:
4141
+ logger.debug("Session has expired - try to re-authenticate...")
4142
+ self.authenticate(revalidate=True)
4143
+ request_header = self.request_header()
4144
+ retries += 1
4145
+ elif response.status_code in [502, 503, 504] and retries < 3:
4146
+ logger.warning(
4147
+ "M365 Graph API delivered server side error -> %s; retrying in %s seconds...",
4148
+ response.status_code,
4149
+ (retries + 1) * 60,
4150
+ )
4151
+ time.sleep((retries + 1) * 60)
4152
+ retries += 1
4153
+ else:
4154
+ if show_error:
4155
+ logger.error(
4156
+ "Cannot retrieve emails for user -> %s; status -> %s; error -> %s",
4157
+ user_id,
4158
+ response.status_code,
4159
+ response.text,
4160
+ )
4161
+ else:
4162
+ logger.warning(
4163
+ "Cannot retrieve emails for user -> %s; status -> %s; warning -> %s",
4164
+ user_id,
4165
+ response.status_code,
4166
+ response.text,
4167
+ )
4168
+ return None
4169
+
4170
+ # end method definition
4171
+
4172
+ def get_mail_body(self, user_id: str, email_id: str) -> str:
4173
+ """Get full email body for a given email ID
4174
+ This requires Mail.Read Application permissions for the Azure App being used.
4175
+
4176
+ Args:
4177
+ user_id (str): M365 ID of the user
4178
+ email_id (str): M365 ID of the email
4179
+ Returns:
4180
+ str: Email body or None of the request fails.
4181
+ """
4182
+
4183
+ request_url = (
4184
+ self.config()["usersUrl"]
4185
+ + "/"
4186
+ + user_id
4187
+ + "/messages/"
4188
+ + email_id
4189
+ + "/$value"
4190
+ )
4191
+
4192
+ request_header = self.request_header()
4193
+
4194
+ retries = 0
4195
+ while True:
4196
+ response = requests.get(
4197
+ request_url, headers=request_header, timeout=REQUEST_TIMEOUT
4198
+ )
4199
+ if response.ok:
4200
+ return response.content.decode("utf-8")
4201
+ # Check if Session has expired - then re-authenticate and try once more
4202
+ elif response.status_code == 401 and retries == 0:
4203
+ logger.debug("Session has expired - try to re-authenticate...")
4204
+ self.authenticate(revalidate=True)
4205
+ request_header = self.request_header()
4206
+ retries += 1
4207
+ elif response.status_code in [502, 503, 504] and retries < 3:
4208
+ logger.warning(
4209
+ "M365 Graph API delivered server side error -> %s; retrying in %s seconds...",
4210
+ response.status_code,
4211
+ (retries + 1) * 60,
4212
+ )
4213
+ time.sleep((retries + 1) * 60)
4214
+ retries += 1
4215
+ else:
4216
+ logger.error(
4217
+ "Cannot retrieve emails body for user -> %s and email -> %s; status -> %s; error -> %s",
4218
+ user_id,
4219
+ email_id,
4220
+ response.status_code,
4221
+ response.text,
4222
+ )
4223
+ return None
4224
+
4225
+ # end method definition
4226
+
4227
+ def extract_url_from_message_body(
4228
+ self,
4229
+ message_body: str,
4230
+ search_pattern: str,
4231
+ multi_line: bool = False,
4232
+ multi_line_end_marker: str = "%3D",
4233
+ line_end_marker: str = "=",
4234
+ replacements: list | None = None,
4235
+ ) -> str | None:
4236
+ """Parse the email body to extract (a potentially multi-line) URL from the body.
4237
+
4238
+ Args:
4239
+ message_body (str): Text of the Email body
4240
+ search_pattern (str): Pattern thatneeds to be in first line of the URL. This
4241
+ makes sure it is the right URL we are looking for.
4242
+ multi_line (bool, optional): Is the URL spread over multiple lines?. Defaults to False.
4243
+ multi_line_end_marker (str, optional): If it is a multi-line URL, what marks the end
4244
+ of the URL in the last line? Defaults to "%3D".
4245
+ line_end_marker (str, optional): What makrs the end of lines 1-(n-1)? Defaults to "=".
4246
+ Returns:
4247
+ str: URL text thathas been extracted.
4248
+ """
4249
+
4250
+ if not message_body:
4251
+ return None
4252
+
4253
+ # Split all the lines after a CRLF:
4254
+ lines = [line.strip() for line in message_body.split("\r\n")]
4255
+
4256
+ # Filter out the complete URL from the extracted URLs
4257
+ found = False
4258
+
4259
+ url = ""
4260
+
4261
+ for line in lines:
4262
+ if found:
4263
+ # Remove line end marker - many times a "="
4264
+ if line.endswith(line_end_marker):
4265
+ line = line[:-1]
4266
+ for replacement in replacements:
4267
+ line = line.replace(replacement["from"], replacement["to"])
4268
+ # We consider an empty line after we found the URL to indicate the end of the URL:
4269
+ if line == "":
4270
+ break
4271
+ url += line
4272
+ if multi_line and line.endswith(multi_line_end_marker):
4273
+ break
4274
+ if not search_pattern in line:
4275
+ continue
4276
+ # Fine https:// in the current line:
4277
+ index = line.find("https://")
4278
+ if index == -1:
4279
+ continue
4280
+ # If there's any text in front of https in that line cut it:
4281
+ line = line[index:]
4282
+ # Remove line end marker - many times a "="
4283
+ if line.endswith(line_end_marker):
4284
+ line = line[:-1]
4285
+ for replacement in replacements:
4286
+ line = line.replace(replacement["from"], replacement["to"])
4287
+ found = True
4288
+ url += line
4289
+ if not multi_line:
4290
+ break
4291
+
4292
+ return url
4293
+
4294
+ # end method definition
4295
+
4296
+ def delete_mail(self, user_id: str, email_id: str) -> dict | None:
4297
+ """Delete email from inbox of a given user and a given email ID.
4298
+ This requires Mail.ReadWrite Application permissions for the Azure App being used.
4299
+
4300
+ Args:
4301
+ user_id (str): M365 ID of the user
4302
+ email_id (str): M365 ID of the email
4303
+ Returns:
4304
+ dict: Email or None of the request fails.
4305
+ """
4306
+
4307
+ request_url = (
4308
+ self.config()["usersUrl"] + "/" + user_id + "/messages/" + email_id
4309
+ )
4310
+
4311
+ request_header = self.request_header()
4312
+
4313
+ retries = 0
4314
+ while True:
4315
+ response = requests.delete(
4316
+ request_url, headers=request_header, timeout=REQUEST_TIMEOUT
4317
+ )
4318
+ if response.ok:
4319
+ return self.parse_request_response(response)
4320
+ # Check if Session has expired - then re-authenticate and try once more
4321
+ elif response.status_code == 401 and retries == 0:
4322
+ logger.debug("Session has expired - try to re-authenticate...")
4323
+ self.authenticate(revalidate=True)
4324
+ request_header = self.request_header()
4325
+ retries += 1
4326
+ elif response.status_code in [502, 503, 504] and retries < 3:
4327
+ logger.warning(
4328
+ "M365 Graph API delivered server side error -> %s; retrying in %s seconds...",
4329
+ response.status_code,
4330
+ (retries + 1) * 60,
4331
+ )
4332
+ time.sleep((retries + 1) * 60)
4333
+ retries += 1
4334
+ else:
4335
+ logger.error(
4336
+ "Cannot delete email -> %s from inbox of user -> %s; status -> %s; error -> %s",
4337
+ email_id,
4338
+ user_id,
4339
+ response.status_code,
4340
+ response.text,
4341
+ )
4342
+ return None
4343
+
4344
+ # end method definition
4345
+
4346
+ def email_verification(
4347
+ self,
4348
+ user_email: str,
4349
+ sender: str,
4350
+ subject: str,
4351
+ url_search_pattern: str,
4352
+ line_end_marker: str = "=",
4353
+ multi_line: bool = True,
4354
+ multi_line_end_marker: str = "%3D",
4355
+ replacements: list | None = None,
4356
+ max_retries: int = 6,
4357
+ use_browser_automation: bool = False,
4358
+ password: str = "",
4359
+ password_field_id: str = "",
4360
+ password_confirmation_field_id: str = "",
4361
+ password_submit_xpath: str = "",
4362
+ terms_of_service_xpath: str = "",
4363
+ ) -> bool:
4364
+ """Process email verification
4365
+
4366
+ Args:
4367
+ user_email (str): Email address of user recieving the verification mail.
4368
+ sender (str): Email sender (address)
4369
+ subject (str): Email subject to look for (can be substring)
4370
+ url_search_pattern (str): String the URL needs to contain to identify it.
4371
+ multi_line_end_marker (str): If the URL spans multiple lines this is the "end" marker for the last line.
4372
+ replacements (list): if the URL needs some treatment these replacements can be applied.
4373
+ Result:
4374
+ bool: True = Success, False = Failure
4375
+ """
4376
+
4377
+ # Determine the M365 user for the current user by
4378
+ # the email address:
4379
+ m365_user = self.get_user(user_email=user_email)
4380
+ m365_user_id = self.get_result_value(m365_user, "id")
4381
+ if not m365_user_id:
4382
+ logger.warning("Cannot find M365 user -> %s", user_email)
4383
+ return False
4384
+
4385
+ if replacements is None:
4386
+ replacements = [{"from": "=3D", "to": "="}]
4387
+
4388
+ retries = 0
4389
+ while retries < max_retries:
4390
+ response = self.get_mail(
4391
+ user_id=m365_user_id,
4392
+ sender=sender,
4393
+ subject=subject,
4394
+ show_error=False,
4395
+ )
4396
+ if response and response["value"]:
4397
+ emails = response["value"]
4398
+ # potentially there may be multiple matching emails,
4399
+ # we want the most recent one (from today):
4400
+ latest_email = max(emails, key=lambda x: x["receivedDateTime"])
4401
+ # Extract just the date:
4402
+ latest_email_date = latest_email["receivedDateTime"].split("T")[0]
4403
+ # Get the current date (today):
4404
+ today_date = datetime.today().strftime("%Y-%m-%d")
4405
+ # We do a sanity check here: the verification mail should be from today,
4406
+ # otherwise we assume it is an old mail and we need to wait for the
4407
+ # new verification mail to yet arrive:
4408
+ if latest_email_date != today_date:
4409
+ logger.info(
4410
+ "Verification email not yet received (latest mail from -> %s). Waiting %s seconds...",
4411
+ latest_email_date,
4412
+ 10 * (retries + 1),
4413
+ )
4414
+ time.sleep(10 * (retries + 1))
4415
+ retries += 1
4416
+ continue
4417
+ email_id = latest_email["id"]
4418
+ # The full email body needs to be loaded with a separate REST call:
4419
+ body_text = self.get_mail_body(user_id=m365_user_id, email_id=email_id)
4420
+ # Extract the verification URL.
4421
+ if body_text:
4422
+ url = self.extract_url_from_message_body(
4423
+ message_body=body_text,
4424
+ search_pattern=url_search_pattern,
4425
+ line_end_marker=line_end_marker,
4426
+ multi_line=multi_line,
4427
+ multi_line_end_marker=multi_line_end_marker,
4428
+ replacements=replacements,
4429
+ )
4430
+ else:
4431
+ url = ""
4432
+ if not url:
4433
+ logger.warning("Cannot find verification link in the email body!")
4434
+ return False
4435
+ # Simulate a "click" on this URL:
4436
+ if use_browser_automation:
4437
+ # Core Share needs a full browser:
4438
+ browser_automation_object = BrowserAutomation(
4439
+ take_screenshots=True,
4440
+ automation_name="email-verification",
4441
+ )
4442
+ logger.info(
4443
+ "Open URL -> %s to verify account or email change (using browser automation)",
4444
+ url,
4445
+ )
4446
+ success = browser_automation_object.get_page(url)
4447
+ if success:
4448
+ user_interaction_required = False
4449
+ logger.info(
4450
+ "Successfully opened URL. Browser title is -> '%s'.",
4451
+ browser_automation_object.get_title(),
4452
+ )
4453
+ if password_field_id:
4454
+ password_field = browser_automation_object.find_elem(
4455
+ find_elem=password_field_id, show_error=False
4456
+ )
4457
+ if password_field:
4458
+ # The subsequent processing is only required if
4459
+ # the returned page requests a password change:
4460
+ user_interaction_required = True
4461
+ logger.info(
4462
+ "Found password field on returned page - it seems email verification requests password entry!"
4463
+ )
4464
+ result = browser_automation_object.find_elem_and_set(
4465
+ find_elem=password_field_id,
4466
+ elem_value=password,
4467
+ is_sensitive=True,
4468
+ )
4469
+ if not result:
4470
+ logger.error(
4471
+ "Failed to enter password in field -> '%s'",
4472
+ password_field_id,
4473
+ )
4474
+ success = False
4475
+ else:
4476
+ logger.info(
4477
+ "No user interaction required (no password change or terms of service acceptance)."
4478
+ )
4479
+ if user_interaction_required and password_confirmation_field_id:
4480
+ password_confirm_field = (
4481
+ browser_automation_object.find_elem(
4482
+ find_elem=password_confirmation_field_id,
4483
+ show_error=False,
4484
+ )
4485
+ )
4486
+ if password_confirm_field:
4487
+ logger.info(
4488
+ "Found password confirmation field on returned page - it seems email verification requests consecutive password entry!"
4489
+ )
4490
+ result = browser_automation_object.find_elem_and_set(
4491
+ find_elem=password_confirmation_field_id,
4492
+ elem_value=password,
4493
+ is_sensitive=True,
4494
+ )
4495
+ if not result:
4496
+ logger.error(
4497
+ "Failed to enter password in field -> '%s'",
4498
+ password_confirmation_field_id,
4499
+ )
4500
+ success = False
4501
+ if user_interaction_required and password_submit_xpath:
4502
+ password_submit_button = (
4503
+ browser_automation_object.find_elem(
4504
+ find_elem=password_submit_xpath,
4505
+ find_method="xpath",
4506
+ show_error=False,
4507
+ )
4508
+ )
4509
+ if password_submit_button:
4510
+ logger.info(
4511
+ "Submit password change dialog with button -> '%s' (found with XPath -> %s)",
4512
+ password_submit_button.text,
4513
+ password_submit_xpath,
4514
+ )
4515
+ result = browser_automation_object.find_elem_and_click(
4516
+ find_elem=password_submit_xpath, find_method="xpath"
4517
+ )
4518
+ if not result:
4519
+ logger.error(
4520
+ "Failed to press submit button -> %s",
4521
+ password_submit_xpath,
4522
+ )
4523
+ success = False
4524
+ # TODO is this sleep required? The Terms of service dialog has some weird animation
4525
+ # which may require this. It seems it is rtequired!
4526
+ time.sleep(1)
4527
+ terms_accept_button = browser_automation_object.find_elem(
4528
+ find_elem=terms_of_service_xpath,
4529
+ find_method="xpath",
4530
+ show_error=False,
4531
+ )
4532
+ if terms_accept_button:
4533
+ logger.info(
4534
+ "Accept terms of service with button -> '%s' (found with XPath -> %s)",
4535
+ terms_accept_button.text,
4536
+ terms_of_service_xpath,
4537
+ )
4538
+ result = browser_automation_object.find_elem_and_click(
4539
+ find_elem=terms_of_service_xpath,
4540
+ find_method="xpath",
4541
+ )
4542
+ if not result:
4543
+ logger.error(
4544
+ "Failed to accept terms of service with button -> '%s'",
4545
+ terms_accept_button.text,
4546
+ )
4547
+ success = False
4548
+ else:
4549
+ logger.info("No Terms of Service acceptance required.")
4550
+ # end if use_browser_automation
4551
+ else:
4552
+ # Salesforce (other than Core Share) is OK with the simple HTTP GET request:
4553
+ logger.info("Open URL -> %s to verify account or email change", url)
4554
+ response = self._http_object.http_request(url=url, method="GET")
4555
+ success = response and response.ok
4556
+
4557
+ if success:
4558
+ logger.info("Remove email from inbox of user -> %s...", user_email)
4559
+ response = self.delete_mail(user_id=m365_user_id, email_id=email_id)
4560
+ if not response:
4561
+ logger.warning(
4562
+ "Couldn't remove the mail from the inbox of user -> %s",
4563
+ user_email,
4564
+ )
4565
+ # We have success now and can break from the while loop
4566
+ return True
4567
+ else:
4568
+ logger.error(
4569
+ "Failed to process e-mail verification for user -> %s",
4570
+ user_email,
4571
+ )
4572
+ return False
4573
+ # end if response and response["value"]
4574
+ else:
4575
+ logger.info(
4576
+ "Verification email not yet received (no mails with sender -> %s and subject -> '%s' found). Waiting %s seconds...",
4577
+ sender,
4578
+ subject,
4579
+ 10 * (retries + 1),
4580
+ )
4581
+ time.sleep(10 * (retries + 1))
4582
+ retries += 1
4583
+ # end while
4584
+
4585
+ logger.warning(
4586
+ "Verification mail for user -> %s has not arrived in time.", user_email
4587
+ )
4588
+
4589
+ return False
4590
+
4591
+ # end method definition