Commit 21d48ead authored by Luko van der Maas's avatar Luko van der Maas

Merge branch 'gsuite-user-sync' into 'master'

Sync active members with G Suite

Closes #952, #958, #876, and #945

See merge request !1396
parents e0e032dc 93cc83c2
......@@ -40,6 +40,14 @@ activemembers.backends module
:undoc-members:
:show-inheritance:
activemembers.emails module
---------------------------
.. automodule:: activemembers.emails
:members:
:undoc-members:
:show-inheritance:
activemembers.forms module
--------------------------
......@@ -48,6 +56,14 @@ activemembers.forms module
:undoc-members:
:show-inheritance:
activemembers.gsuite module
---------------------------
.. automodule:: activemembers.gsuite
:members:
:undoc-members:
:show-inheritance:
activemembers.models module
---------------------------
......@@ -64,6 +80,14 @@ activemembers.services module
:undoc-members:
:show-inheritance:
activemembers.signals module
----------------------------
.. automodule:: activemembers.signals
:members:
:undoc-members:
:show-inheritance:
activemembers.sitemaps module
-----------------------------
......
......@@ -7,3 +7,7 @@ class ActiveMembersConfig(AppConfig):
"""AppConfig for the activemembers package"""
name = 'activemembers'
verbose_name = _('Active members')
def ready(self):
"""Imports the signals when the app is ready"""
from . import signals # noqa: F401
from django.conf import settings
from django.template import loader
from django.utils import translation
from django.utils.translation import ugettext_lazy as _
def send_gsuite_welcome_message(member, email, password):
"""
Sends an email to notify a member of G Suite credentials
:param member: the member
:param email: G Suite primary email
:param password: randomly generated password
"""
with translation.override(member.profile.language):
email_body = loader.render_to_string(
'activemembers/email/gsuite_info.txt',
{
'full_name': member.get_full_name(),
'username': email,
'password': password,
'url': settings.BASE_URL
})
member.email_user(
_('Your new G Suite credentials'),
email_body)
def send_gsuite_suspended_message(member):
"""
Sends an email to notify a member of G Suite suspension
:param member: the member
"""
with translation.override(member.profile.language):
email_body = loader.render_to_string(
'activemembers/email/gsuite_suspend.txt',
{
'full_name': member.get_full_name(),
'url': settings.BASE_URL
})
member.email_user(_('G Suite account suspended'), email_body)
import hashlib
import logging
from base64 import b16encode
from django.utils.translation import (
ugettext_lazy as _, override as lang_override
)
from googleapiclient.errors import HttpError
from members.models import Member
from utils.google_api import get_directory_api
from django.conf import settings
logger = logging.getLogger(__name__)
class GSuiteUserService:
def __init__(self, directory_api=get_directory_api()):
super().__init__()
self.directory_api = directory_api
def create_user(self, member: Member):
"""
Create a new GSuite user based on the provided data
:param member: The member that gets an account
:return returns a tuple with the password and id of the created user
"""
plain_password = Member.objects.make_random_password(15)
digest_password = hashlib.sha1(plain_password.encode('utf-8')).digest()
encoded_password = b16encode(digest_password).decode("utf-8")
try:
response = self.directory_api.users().insert(
body={
'name': {
'familyName': member.last_name,
'givenName': member.first_name
},
'primaryEmail':
f'{member.username}@{settings.GSUITE_MEMBERS_DOMAIN}',
'password': encoded_password,
'hashFunction': 'SHA-1',
'changePasswordAtNextLogin': 'true',
'externalIds': [{
'value': f'{member.pk}',
'type': 'login_id'
}],
'includeInGlobalAddressList': 'false',
'orgUnitPath': '/',
},
).execute()
except HttpError as e:
if e.resp.status == 409:
return self.update_user(member, member.username)
raise e
return response['primaryEmail'], plain_password
def update_user(self, member: Member, username: str):
response = self.directory_api.users().patch(
body={
'suspended': 'false',
'primaryEmail':
f'{member.username}@{settings.GSUITE_MEMBERS_DOMAIN}',
},
userKey=f'{username}@{settings.GSUITE_MEMBERS_DOMAIN}'
).execute()
if username != member.username:
self.directory_api.users().aliases().delete(
userKey=f'{member.username}@{settings.GSUITE_MEMBERS_DOMAIN}',
alias=f'{username}@{settings.GSUITE_MEMBERS_DOMAIN}',
).execute()
with lang_override(member.profile.language):
password = _('known by the user')
return response['primaryEmail'], password
def suspend_user(self, username):
"""
Suspends the user in GSuite
:param username: username of the user
"""
self.directory_api.users().patch(
body={
'suspended': 'true',
},
userKey=f'{username}@{settings.GSUITE_MEMBERS_DOMAIN}'
).execute()
def delete_user(self, email):
"""
Deletes the user from GSuite
:param email: primary email of the user
"""
self.directory_api.users().delete(
userKey=email
).execute()
def get_suspended_users(self):
"""
Get all the suspended users
:return:
"""
response = self.directory_api.users().list(
domain=settings.GSUITE_MEMBERS_DOMAIN,
query='isSuspended=true'
).execute()
return response.get('users', [])
This diff was suppressed by a .gitattributes entry.
......@@ -7,8 +7,8 @@ msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2019-10-09 20:36+0200\n"
"PO-Revision-Date: 2019-10-09 20:38+0200\n"
"POT-Creation-Date: 2019-10-25 19:21+0200\n"
"PO-Revision-Date: 2019-10-25 19:28+0200\n"
"Last-Translator: Thom Wiggers <thom@thomwiggers.nl>\n"
"Language-Team: \n"
"Language: nl\n"
......@@ -98,10 +98,22 @@ msgstr "Exporteer de geselecteerde lidmaatschappen"
msgid "Active members"
msgstr "Actieve leden"
#: emails.py
msgid "Your new G Suite credentials"
msgstr "Je nieuwe G Suite inloggegevens"
#: emails.py
msgid "G Suite account suspended"
msgstr "G Suite account gedeactiveerd"
#: forms.py models.py
msgid "Member"
msgstr "Lid"
#: gsuite.py
msgid "known by the user"
msgstr "bekend bij de gebruiker"
#: models.py
msgid "Name"
msgstr "Naam"
......@@ -278,6 +290,84 @@ msgstr "Oude besturen"
msgid "There are no committees!"
msgstr "Er zijn geen commissies!"
#: templates/activemembers/email/gsuite_info.txt
#, python-format
msgid ""
"Dear %(full_name)s,\n"
"\n"
"Our records show that you have become an active member of Thalia. Awesome!\n"
"This means you now get access to Thalia's G Suite organisation.\n"
"\n"
"Your username is: %(username)s\n"
"Your password is: %(password)s\n"
"\n"
"You can find the login page here: https://accounts.google.com/.\n"
"More information about G Suite is available here: https://gsuite.members."
"thalia.nu/.\n"
"\n"
"With kind regards,\n"
"\n"
"The board of Study Association Thalia\n"
"\n"
"————\n"
"\n"
"This email was automatically generated."
msgstr ""
"Beste %(full_name)s,\n"
"\n"
"Volgens onze gegevens ben je actief lid bij Thalia geworden. Awesome!\n"
"Dit betekent dat je toegang krijgt tot de G Suite organisatie van Thalia.\n"
"\n"
"Je gebruikersnaam is: %(username)s\n"
"Je wachtwoord is: %(password)s\n"
"\n"
"De loginpagina kun je hier vinden: https://accounts.google.com/.\n"
"Meer informatie over G Suite is beschikbaar via https://gsuite.members."
"thalia.nu/.\n"
"\n"
"Met vriendelijke groet,\n"
"\n"
"Het bestuur der Studievereniging Thalia\n"
"\n"
"————\n"
"\n"
"De e-mail is automatisch gegenereerd."
#: templates/activemembers/email/gsuite_suspend.txt
#, python-format
msgid ""
"Dear %(full_name)s,\n"
"\n"
"Our records show that you are no longer an active member of Thalia.\n"
"This means your Thalia G Suite account has been suspended.\n"
"\n"
"The account will be removed soon, unless reactivated.\n"
"Please notify the board if you think this is a mistake.\n"
"\n"
"With kind regards,\n"
"\n"
"The board of Study Association Thalia\n"
"\n"
"————\n"
"\n"
"This email was automatically generated."
msgstr ""
"Beste %(full_name)s,\n"
"\n"
"Volgens onze gegevens ben je niet langer actief lid bij Thalia.\n"
"Dit betekent dat je Thalia G Suite account is gedeactiveerd.\n"
"\n"
"Je account wordt binnenkort verwijderd tenzij deze wordt geheractiveerd.\n"
"Stel het bestuur op de hoogte als je denkt dat dit niet klopt.\n"
"\n"
"Met vriendelijke groet,\n"
"\n"
"Het bestuur der Studievereniging Thalia\n"
"\n"
"————\n"
"\n"
"De e-mail is automatisch gegenereerd."
#: templates/activemembers/membergroup_detail.html
msgid "Members"
msgstr "Leden"
......
"""Mailing list syncing management command"""
import logging
from django.core.management.base import BaseCommand
from activemembers.gsuite import GSuiteUserService
logger = logging.getLogger(__name__)
sync_service = GSuiteUserService()
class Command(BaseCommand):
def handle(self, *args, **options):
"""Sync all accounts """
suspended_users = sync_service.get_suspended_users()
for user in suspended_users:
sync_service.delete_user(user['primaryEmail'])
"""Initialise G Suite users management command"""
import logging
from django.core.management.base import BaseCommand
from googleapiclient.errors import HttpError
from activemembers import emails
from activemembers.gsuite import GSuiteUserService
from members.models import Member
logger = logging.getLogger(__name__)
sync_service = GSuiteUserService()
class Command(BaseCommand):
def handle(self, *args, **options):
"""Sync all accounts """
for member in Member.active_members.all():
try:
email, password = sync_service.create_user(member)
emails.send_gsuite_welcome_message(member, email, password)
except HttpError as e:
logger.error(f'User {member.username} could not be created', e)
"""The signals defined by the activemembers package"""
import logging
from django.conf import settings
from django.contrib.auth import get_user_model
from django.db.models.signals import pre_save
from googleapiclient.errors import HttpError
from activemembers import emails
from activemembers.gsuite import GSuiteUserService
from members.models import Member
from utils.models.signals import suspendingreceiver
logger = logging.getLogger(__name__)
sync_service = GSuiteUserService()
@suspendingreceiver(pre_save, sender=get_user_model(),
dispatch_uid='activemembers_user_save')
@suspendingreceiver(pre_save, sender=Member,
dispatch_uid='activemembers_member_save')
def pre_member_save(instance, **kwargs):
if not settings.GSUITE_MEMBERS_AUTOSYNC:
return
existing_member = Member.objects.get(pk=instance.pk)
try:
if not existing_member.is_staff and instance.is_staff:
email, password = sync_service.create_user(instance)
emails.send_gsuite_welcome_message(instance, email, password)
elif existing_member.is_staff and not instance.is_staff:
sync_service.suspend_user(instance.username)
emails.send_gsuite_suspended_message(instance)
elif (existing_member.is_staff and instance.is_staff
and existing_member.username != instance.username):
sync_service.update_user(instance, existing_member.username)
except HttpError as e:
logger.error('Could not update G Suite account', e)
{% load i18n %}{% blocktrans %}Dear {{ full_name }},
Our records show that you have become an active member of Thalia. Awesome!
This means you now get access to Thalia's G Suite organisation.
Your username is: {{ username }}
Your password is: {{ password }}
You can find the login page here: https://accounts.google.com/.
More information about G Suite is available here: https://gsuite.members.thalia.nu/.
With kind regards,
The board of Study Association Thalia
————
This email was automatically generated.{% endblocktrans %}
{% load i18n %}{% blocktrans %}Dear {{ full_name }},
Our records show that you are no longer an active member of Thalia.
This means your Thalia G Suite account has been suspended.
The account will be removed soon, unless reactivated.
Please notify the board if you think this is a mistake.
With kind regards,
The board of Study Association Thalia
————
This email was automatically generated.{% endblocktrans %}
from django.contrib.auth.models import User
from django.core.exceptions import ValidationError
from django.db.utils import IntegrityError
from django.test import TestCase
from django.test import TestCase, override_settings
from django.utils import timezone
from activemembers.models import Committee, MemberGroupMembership, Board
......@@ -9,6 +9,7 @@ from mailinglists.models import MailingList
from members.models import Member
@override_settings(SUSPEND_SIGNALS=True)
class CommitteeMembersTest(TestCase):
fixtures = ['members.json', 'member_groups.json']
......@@ -90,6 +91,7 @@ class CommitteeMembersTest(TestCase):
self.assertFalse(self.m.is_active)
@override_settings(SUSPEND_SIGNALS=True)
class CommitteeMembersChairTest(TestCase):
fixtures = ['members.json', 'member_groups.json']
......@@ -130,6 +132,7 @@ class CommitteeMembersChairTest(TestCase):
self.m1.full_clean()
@override_settings(SUSPEND_SIGNALS=True)
class PermissionsBackendTest(TestCase):
fixtures = ['members.json', 'member_groups.json']
......
from django.contrib.auth.models import AnonymousUser, User
from django.contrib.sessions.middleware import SessionMiddleware
from django.test import TestCase, RequestFactory
from django.test import TestCase, RequestFactory, override_settings
from django.urls import reverse
from announcements.views import close_announcement
@override_settings(SUSPEND_SIGNALS=True)
class AnnouncementCloseTestCase(TestCase):
def setUp(self):
......
......@@ -3,7 +3,7 @@ import logging
from unittest.mock import Mock
from django.core.files import File
from django.test import Client, TestCase
from django.test import Client, TestCase, override_settings
from documents.models import Document
from members.models import Member
......@@ -16,6 +16,7 @@ def _close_filehandles(response):
closable.close()
@override_settings(SUSPEND_SIGNALS=True)
class GetDocumentTest(TestCase):
"""tests for the :func:`.get_document` view"""
......
......@@ -83,6 +83,7 @@ class DoNextModelAdminTest(TestCase):
@freeze_time('2017-01-01')
@override_settings(SUSPEND_SIGNALS=True)
class RegistrationInformationFieldInlineTest(TestCase):
fixtures = ['members.json', 'member_groups.json']
......@@ -158,6 +159,7 @@ class RegistrationInformationFieldInlineTest(TestCase):
@freeze_time('2017-01-01')
@override_settings(SUSPEND_SIGNALS=True)
class EventAdminTest(TestCase):
fixtures = ['members.json', 'member_groups.json']
......
import datetime
from django.test import TestCase
from django.test import TestCase, override_settings
from django.utils import timezone
from rest_framework.test import APIClient
......@@ -13,6 +13,7 @@ from events.models import (Event, Registration,
from members.models import Member
@override_settings(SUSPEND_SIGNALS=True)
class RegistrationApiTest(TestCase):
"""Tests for registration view"""
......
import datetime
import factory
from django.core.exceptions import ValidationError
from django.db.models import signals
from django.test import TestCase
from django.test import TestCase, override_settings
from django.utils import timezone
from activemembers.models import Committee
......@@ -12,13 +10,13 @@ from mailinglists.models import MailingList
from members.models import Member
@override_settings(SUSPEND_SIGNALS=True)
class EventTest(TestCase):
"""Tests events"""
fixtures = ['members.json']
@classmethod
@factory.django.mute_signals(signals.pre_save)
def setUpTestData(cls):
cls.mailinglist = MailingList.objects.create(
name="testmail"
......@@ -275,6 +273,7 @@ class EventTest(TestCase):
self.assertFalse(self.event.cancellation_allowed)
@override_settings(SUSPEND_SIGNALS=True)
class RegistrationTest(TestCase):
"""Tests event registrations"""
......
......@@ -3,7 +3,7 @@ from unittest import mock
from django.contrib.auth.models import AnonymousUser, Permission
from django.http import HttpRequest
from django.test import TestCase
from django.test import TestCase, override_settings
from django.utils import timezone
from freezegun import freeze_time
......@@ -15,6 +15,7 @@ from members.models import Member
@freeze_time('2017-01-01')
@override_settings(SUSPEND_SIGNALS=True)
class ServicesTest(TestCase):
fixtures = ['members.json', 'member_groups.json']
......
import datetime
import factory
from django.contrib.auth.models import Permission
from django.core import mail
from django.db.models import signals
from django.test import Client, TestCase
from django.test import Client, TestCase, override_settings
from django.utils import timezone
from activemembers.models import Committee, MemberGroupMembership
......@@ -17,6 +15,7 @@ from mailinglists.models import MailingList
from members.models import Member
@override_settings(SUSPEND_SIGNALS=True)
class AdminTest(TestCase):
"""Tests for admin views"""
......@@ -130,13 +129,13 @@ class AdminTest(TestCase):
self.assertIn('View event', str(response.content))
@override_settings(SUSPEND_SIGNALS=True)
class RegistrationTest(TestCase):
"""Tests for registration view"""
fixtures = ['members.json', 'member_groups.json']
@classmethod
@factory.django.mute_signals(signals.pre_save)