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
« 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
5from django.conf import settings
6from django.utils.datastructures import ImmutableList
8from googleapiclient.errors import HttpError
10from mailinglists.models import MailingList
11from mailinglists.services import get_automatic_lists
12from utils.google_api import get_directory_api, get_groups_settings_api
14logger = logging.getLogger(__name__)
17class GSuiteSyncService:
18 """Service for syncing groups and settings for Google Groups."""
20 class GroupData:
21 """Store data for GSuite groups to sync them."""
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.
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))
47 def __eq__(self, other):
48 """Compare group data by comparing properties.
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
57 def __init__(
58 self,
59 groups_settings_api=None,
60 directory_api=None,
61 ):
62 """Create GSuite Sync Service.
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()
70 @staticmethod
71 def _group_settings(moderated):
72 """Get group settings dictionary.
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 }
100 def create_group(self, group):
101 """Create a new group based on the provided data.
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
134 self._update_group_members(group)
135 self._update_group_aliases(group)
137 return True
139 def update_group(self, active_name, group):
140 """Update a group based on the provided name and data.
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
163 self._update_group_members(group)
164 self._update_group_aliases(group)
166 MailingList.objects.filter(active_gsuite_name=active_name).update(
167 active_gsuite_name=group.name
168 )
170 def _update_group_aliases(self, group):
171 """Update the aliases of a group based on existing values.
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
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]
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]
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 )
207 try:
208 batch.execute()
209 except HttpError:
210 logger.exception(f"Could not remove an alias for list {group.name}")
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 )
223 try:
224 batch.execute()
225 except HttpError:
226 logger.exception(f"Could not insert an alias for list {group.name}")
228 logger.info(f"List {group.name} aliases updated")
230 def archive_group(self, name):
231 """Archive the given mailing list.
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
249 def delete_group(self, name):
250 """Delete the given mailing list.
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
265 def _update_group_members(self, group):
266 """Update the group members of the specified group based on the existing members.
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", [])
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]
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 ]
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 )
317 try:
318 batch.execute()
319 except HttpError:
320 logger.exception(f"Could not remove a list member from {group.name}")
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 )
334 try:
335 batch.execute()
336 except HttpError:
337 logger.exception(f"Could not insert a list member in {group.name}")
339 logger.info(f"List {group.name} members updated")
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 )
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 )
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 ]
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.
380 :param lists: optional parameter to determine which lists to sync
381 """
382 if lists is None:
383 lists = self._get_default_lists()
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
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 ]
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]
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 )
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)
445 logger.info("Synchronisation ended.")