Coverage for website/mailinglists/gsuite.py: 87.86%

174 statements  

« prev     ^ index     » next       coverage.py v7.6.7, created at 2025-08-14 10:31 +0000

1import logging 

2from random import random 

3from time import sleep 

4 

5from django.conf import settings 

6from django.utils.datastructures import ImmutableList 

7 

8from googleapiclient.errors import HttpError 

9 

10from mailinglists.models import MailingList 

11from mailinglists.services import get_automatic_lists 

12from utils.google_api import get_directory_api, get_groups_settings_api 

13 

14logger = logging.getLogger(__name__) 

15 

16 

17class GSuiteSyncService: 

18 """Service for syncing groups and settings for Google Groups.""" 

19 

20 class GroupData: 

21 """Store data for GSuite groups to sync them.""" 

22 

23 def __init__( 

24 self, 

25 name, 

26 description="", 

27 moderated=False, 

28 aliases=ImmutableList([]), 

29 addresses=ImmutableList([]), 

30 active_name=None, 

31 ): 

32 """Create group data to sync with Gsuite. 

33 

34 :param name: Name of group 

35 :param description: Description of group 

36 :param aliases: Aliases of group 

37 :param addresses: Addresses in group 

38 """ 

39 super().__init__() 

40 self.moderated = moderated 

41 self.name = name 

42 self.active_name = active_name 

43 self.description = description 

44 self.aliases = aliases 

45 self.addresses = sorted(set(addresses)) 

46 

47 def __eq__(self, other): 

48 """Compare group data by comparing properties. 

49 

50 :param other: Group to compare with 

51 :return: True if groups are equal, otherwise False. 

52 """ 

53 if isinstance(other, self.__class__): 53 ↛ 55line 53 didn't jump to line 55 because the condition on line 53 was always true

54 return self.__dict__ == other.__dict__ 

55 return False 

56 

57 def __init__( 

58 self, 

59 groups_settings_api=None, 

60 directory_api=None, 

61 ): 

62 """Create GSuite Sync Service. 

63 

64 :param groups_settings_api: Group settings API object 

65 :param directory_api: Directory API object 

66 """ 

67 self._groups_settings_api = groups_settings_api or get_groups_settings_api() 

68 self._directory_api = directory_api or get_directory_api() 

69 

70 @staticmethod 

71 def _group_settings(moderated): 

72 """Get group settings dictionary. 

73 

74 :param moderated: Set the group to be moderated or not 

75 :return: The group settings dictionary 

76 """ 

77 return { 

78 "allowExternalMembers": "true", 

79 "allowWebPosting": "true", 

80 "archiveOnly": "false", 

81 "enableCollaborativeInbox": "true", 

82 "isArchived": "true", 

83 "membersCanPostAsTheGroup": "true", 

84 "messageModerationLevel": "MODERATE_ALL_MESSAGES" 

85 if moderated 

86 else "MODERATE_NONE", 

87 "replyTo": "REPLY_TO_SENDER", 

88 "whoCanAssistContent": "ALL_MEMBERS", 

89 "whoCanContactOwner": "ALL_MANAGERS_CAN_CONTACT", 

90 "whoCanDiscoverGroup": "ALL_MEMBERS_CAN_DISCOVER", 

91 "whoCanJoin": "INVITED_CAN_JOIN", 

92 "whoCanLeaveGroup": "NONE_CAN_LEAVE", 

93 "whoCanModerateContent": "OWNERS_AND_MANAGERS", 

94 "whoCanModerateMembers": "NONE", 

95 "whoCanPostMessage": "ANYONE_CAN_POST", 

96 "whoCanViewGroup": "ALL_MEMBERS_CAN_VIEW", 

97 "whoCanViewMembership": "ALL_MANAGERS_CAN_VIEW", 

98 } 

99 

100 def create_group(self, group): 

101 """Create a new group based on the provided data. 

102 

103 :param group: GroupData to create a group for 

104 """ 

105 try: 

106 self._directory_api.groups().insert( 

107 body={ 

108 "email": f"{group.name}@{settings.GSUITE_DOMAIN}", 

109 "name": group.name, 

110 "description": group.description, 

111 }, 

112 ).execute() 

113 # Wait for mailing list creation to complete Docs say we need to 

114 # wait a minute. 

115 n = 0 

116 while True: 

117 sleep(min(2**n + random(), 64)) 

118 try: 

119 self._groups_settings_api.groups().update( 

120 groupUniqueId=f"{group.name}@{settings.GSUITE_DOMAIN}", 

121 body=self._group_settings(group.moderated), 

122 ).execute() 

123 break 

124 except HttpError as e: 

125 if n > 6: 

126 raise e 

127 n += 1 

128 except HttpError: 

129 logger.exception( 

130 f"Could not successfully finish creating the list {group.name}:" 

131 ) 

132 return False 

133 

134 self._update_group_members(group) 

135 self._update_group_aliases(group) 

136 

137 return True 

138 

139 def update_group(self, active_name, group): 

140 """Update a group based on the provided name and data. 

141 

142 :param active_name: old group name 

143 :param group: new group data 

144 """ 

145 try: 

146 self._directory_api.groups().update( 

147 groupKey=f"{active_name}@{settings.GSUITE_DOMAIN}", 

148 body={ 

149 "email": f"{group.name}@{settings.GSUITE_DOMAIN}", 

150 "name": group.name, 

151 "description": group.description, 

152 }, 

153 ).execute() 

154 self._groups_settings_api.groups().update( 

155 groupUniqueId=f"{group.name}@{settings.GSUITE_DOMAIN}", 

156 body=self._group_settings(group.moderated), 

157 ).execute() 

158 logger.info(f"List {group.name} updated") 

159 except HttpError: 

160 logger.exception(f"Could not update list {group.name}") 

161 return 

162 

163 self._update_group_members(group) 

164 self._update_group_aliases(group) 

165 

166 MailingList.objects.filter(active_gsuite_name=active_name).update( 

167 active_gsuite_name=group.name 

168 ) 

169 

170 def _update_group_aliases(self, group): 

171 """Update the aliases of a group based on existing values. 

172 

173 :param group: group data 

174 """ 

175 try: 

176 aliases_response = ( 

177 self._directory_api.groups() 

178 .aliases() 

179 .list( 

180 groupKey=f"{group.name}@{settings.GSUITE_DOMAIN}", 

181 ) 

182 .execute() 

183 ) 

184 except HttpError: 

185 logger.exception( 

186 f"Could not obtain existing aliases for list {group.name}:" 

187 ) 

188 return 

189 

190 existing_aliases = [a["alias"] for a in aliases_response.get("aliases", [])] 

191 new_aliases = [f"{a}@{settings.GSUITE_DOMAIN}" for a in group.aliases] 

192 

193 remove_list = [x for x in existing_aliases if x not in new_aliases] 

194 insert_list = [x for x in new_aliases if x not in existing_aliases] 

195 

196 batch = self._directory_api.new_batch_http_request() 

197 for remove_alias in remove_list: 

198 batch.add( 

199 self._directory_api.groups() 

200 .aliases() 

201 .delete( 

202 groupKey=f"{group.name}@{settings.GSUITE_DOMAIN}", 

203 alias=remove_alias, 

204 ) 

205 ) 

206 

207 try: 

208 batch.execute() 

209 except HttpError: 

210 logger.exception(f"Could not remove an alias for list {group.name}") 

211 

212 batch = self._directory_api.new_batch_http_request() 

213 for insert_alias in insert_list: 

214 batch.add( 

215 self._directory_api.groups() 

216 .aliases() 

217 .insert( 

218 groupKey=f"{group.name}@{settings.GSUITE_DOMAIN}", 

219 body={"alias": insert_alias}, 

220 ) 

221 ) 

222 

223 try: 

224 batch.execute() 

225 except HttpError: 

226 logger.exception(f"Could not insert an alias for list {group.name}") 

227 

228 logger.info(f"List {group.name} aliases updated") 

229 

230 def archive_group(self, name): 

231 """Archive the given mailing list. 

232 

233 :param name: Group name 

234 :return: True if the operation succeeded, False otherwise. 

235 """ 

236 try: 

237 self._groups_settings_api.groups().patch( 

238 groupUniqueId=f"{name}@{settings.GSUITE_DOMAIN}", 

239 body={"archiveOnly": "true", "whoCanPostMessage": "NONE_CAN_POST"}, 

240 ).execute() 

241 self._update_group_members(GSuiteSyncService.GroupData(name, addresses=[])) 

242 self._update_group_aliases(GSuiteSyncService.GroupData(name, aliases=[])) 

243 logger.info(f"List {name} archived") 

244 return True 

245 except HttpError: 

246 logger.exception(f"Could not archive list {name}") 

247 return False 

248 

249 def delete_group(self, name): 

250 """Delete the given mailing list. 

251 

252 :param name: Group name 

253 :return: True if the operation succeeded, False otherwise. 

254 """ 

255 try: 

256 self._directory_api.groups().delete( 

257 groupKey=f"{name}@{settings.GSUITE_DOMAIN}", 

258 ).execute() 

259 logger.info(f"List {name} deleted") 

260 return True 

261 except HttpError: 

262 logger.exception(f"Could not delete list {name}") 

263 return False 

264 

265 def _update_group_members(self, group): 

266 """Update the group members of the specified group based on the existing members. 

267 

268 :param group: group data 

269 """ 

270 try: 

271 members_response = ( 

272 self._directory_api.members() 

273 .list( 

274 groupKey=f"{group.name}@{settings.GSUITE_DOMAIN}", 

275 ) 

276 .execute() 

277 ) 

278 members_list = members_response.get("members", []) 

279 while "nextPageToken" in members_response: 

280 members_response = ( 

281 self._directory_api.members() 

282 .list( 

283 groupKey=f"{group.name}@{settings.GSUITE_DOMAIN}", 

284 pageToken=members_response["nextPageToken"], 

285 ) 

286 .execute() 

287 ) 

288 members_list += members_response.get("members", []) 

289 

290 existing_members = [ 

291 m["email"].lower() for m in members_list if m["role"] == "MEMBER" 

292 ] 

293 existing_managers = [ 

294 m["email"].lower() for m in members_list if m["role"] == "MANAGER" 

295 ] 

296 except HttpError: 

297 logger.exception(f"Could not obtain list member data for {group.name}") 

298 return # the list does not exist or something else is wrong 

299 new_members = [x.lower() for x in group.addresses] 

300 

301 remove_list = [x for x in existing_members if x not in new_members] 

302 insert_list = [ 

303 x 

304 for x in new_members 

305 if x not in existing_members and x not in existing_managers 

306 ] 

307 

308 batch = self._directory_api.new_batch_http_request() 

309 for remove_member in remove_list: 

310 batch.add( 

311 self._directory_api.members().delete( 

312 groupKey=f"{group.name}@{settings.GSUITE_DOMAIN}", 

313 memberKey=remove_member, 

314 ) 

315 ) 

316 

317 try: 

318 batch.execute() 

319 except HttpError: 

320 logger.exception(f"Could not remove a list member from {group.name}") 

321 

322 while insert_list: 

323 insert_batch = insert_list[:900] 

324 insert_list = insert_list[900:] 

325 batch = self._directory_api.new_batch_http_request() 

326 for insert_member in insert_batch: 

327 batch.add( 

328 self._directory_api.members().insert( 

329 groupKey=f"{group.name}@{settings.GSUITE_DOMAIN}", 

330 body={"email": insert_member, "role": "MEMBER"}, 

331 ) 

332 ) 

333 

334 try: 

335 batch.execute() 

336 except HttpError: 

337 logger.exception(f"Could not insert a list member in {group.name}") 

338 

339 logger.info(f"List {group.name} members updated") 

340 

341 @staticmethod 

342 def mailing_list_to_group(mailing_list): 

343 """Convert a mailing list model to everything we need for GSuite.""" 

344 return GSuiteSyncService.GroupData( 

345 name=mailing_list.name, 

346 moderated=mailing_list.moderated, 

347 description=mailing_list.description, 

348 aliases=( 

349 [x.alias for x in mailing_list.aliases.all()] 

350 if mailing_list.pk is not None 

351 else [] 

352 ), 

353 addresses=( 

354 list(mailing_list.all_addresses()) 

355 if mailing_list.pk is not None 

356 else [] 

357 ), 

358 active_name=mailing_list.active_gsuite_name, 

359 ) 

360 

361 @staticmethod 

362 def _automatic_to_group(automatic_list): 

363 """Convert an automatic mailinglist to a GSuite Group data obj.""" 

364 return GSuiteSyncService.GroupData( 

365 moderated=automatic_list["moderated"], 

366 name=automatic_list["name"], 

367 description=automatic_list["description"], 

368 aliases=automatic_list.get("aliases", []), 

369 addresses=automatic_list["addresses"], 

370 ) 

371 

372 def _get_default_lists(self): 

373 return [self.mailing_list_to_group(ml) for ml in MailingList.objects.all()] + [ 

374 self._automatic_to_group(ml) for ml in get_automatic_lists() 

375 ] 

376 

377 def sync_mailing_lists(self, lists: list[GroupData] | None = None): 

378 """Sync mailing lists with GSuite. Lists are only deleted if all lists are synced and thus no lists are passed to this function. 

379 

380 :param lists: optional parameter to determine which lists to sync 

381 """ 

382 if lists is None: 

383 lists = self._get_default_lists() 

384 

385 try: 

386 groups_response = ( 

387 self._directory_api.groups() 

388 .list(domain=settings.GSUITE_DOMAIN) 

389 .execute() 

390 ) 

391 groups_list = groups_response.get("groups", []) 

392 while "nextPageToken" in groups_response: 

393 groups_response = ( 

394 self._directory_api.groups() 

395 .list( 

396 domain=settings.GSUITE_DOMAIN, 

397 pageToken=groups_response["nextPageToken"], 

398 ) 

399 .execute() 

400 ) 

401 groups_list += groups_response.get("groups", []) 

402 existing_groups = [ 

403 g["name"] for g in groups_list if int(g["directMembersCount"]) > 0 

404 ] 

405 archived_groups = [ 

406 g["name"] for g in groups_list if g["directMembersCount"] == "0" 

407 ] 

408 except HttpError: 

409 logger.exception("Could not get the existing groups") 

410 return # there are no groups or something went wrong 

411 

412 new_groups = [ 

413 g.active_name if g.active_name else g.name 

414 for g in lists 

415 if len(g.addresses) > 0 

416 ] 

417 

418 archive_list = [x for x in existing_groups if x not in new_groups] 

419 insert_list = [x for x in new_groups if x not in existing_groups] 

420 

421 for mailinglist in lists: 

422 if ( 

423 mailinglist.name in insert_list 

424 and mailinglist.name not in archived_groups 

425 ): 

426 logger.debug(f"Starting create group of {mailinglist.name}") 

427 if self.create_group(mailinglist): 427 ↛ 421line 427 didn't jump to line 421 because the condition on line 427 was always true

428 MailingList.objects.filter(name=mailinglist.name).update( 

429 active_gsuite_name=mailinglist.name 

430 ) 

431 elif len(mailinglist.addresses) > 0: 

432 logger.debug(f"Starting update group of {mailinglist.name}") 

433 self.update_group( 

434 mailinglist.active_name 

435 if mailinglist.active_name 

436 else mailinglist.name, 

437 mailinglist, 

438 ) 

439 

440 for list_name in archive_list: 

441 if list_name in existing_groups: 441 ↛ 440line 441 didn't jump to line 440 because the condition on line 441 was always true

442 logger.debug(f"Starting archive group of {list_name}") 

443 self.archive_group(list_name) 

444 

445 logger.info("Synchronisation ended.")