diff --git a/README.rst b/README.rst index 84f0a4c..d77cd6f 100644 --- a/README.rst +++ b/README.rst @@ -178,15 +178,18 @@ Configuration Auto-signup Populated data - username: ``@`` - - email: from LDAP's `mailRoutingAddress` field, or ``@clipper.ens.fr`` - - first_name, last_name from LDAP's `cn` field - - extra_data in SociallAccount instance, containing these field, plus `anne` and `promotion` from LDAP's `homeDirectory` field (available only on first connection) + - email: from LDAP's *mailRoutingAddress* field, or ``@clipper.ens.fr`` + - first_name, last_name from LDAP's *cn* field + - extra_data in SociallAccount instance, containing these field, plus *annee* and *promotion* parsed from LDAP's *homeDirectory* field (available only on first connection) Account deprecation At the beginning of each year (i.e. early November), to prevent clipper username conflicts, you should run ``$ python manage.py deprecate_clippers``. Every association clipper username <-> user will then be set on hold, and at the first subsequent connection, a verification of the account will be made (using LDAP), so that a known user keeps his account, but a newcomer won't inherit an archicube's. Customize You can customize the SocialAccountAdapter by inheriting ``allauth_ens.adapter.LongTermClipperAccountAdapter``. You might want to modify ``get_username(clipper, data)`` to change the default username format. This function is used to disambiguate in the account deprecation process. + +Initial migration + If you used allauth without LongTermClipperAccountAdapter, or another CAS interface to log in, you need to update the Users to the new username policy, and (in the second case) to create the SocialAccount instances to link CAS and Users. This can be done easily with ``$ python manage.py install_longterm``. ********* Demo Site diff --git a/allauth_ens/adapter.py b/allauth_ens/adapter.py index c08382f..b96f2de 100644 --- a/allauth_ens/adapter.py +++ b/allauth_ens/adapter.py @@ -3,9 +3,12 @@ import ldap from allauth.account.utils import user_email, user_field, user_username from allauth.account.models import EmailAddress -from allauth.socialaccount.adapter import DefaultSocialAccountAdapter, get_account_adapter +from allauth.socialaccount.adapter import DefaultSocialAccountAdapter, get_account_adapter, get_adapter from allauth.socialaccount.models import SocialAccount from django.conf import settings +from django.contrib.auth import get_user_model + +User = get_user_model() import six @@ -36,6 +39,30 @@ def _init_ldap(): l.set_option(ldap.OPT_TIMEOUT, 10) return l +def _extract_infos_from_ldap(infos, data={}): + # Name + data['name'] = infos.get('cn', [''])[0].decode("utf-8") + + # Parsing homeDirectory to get entrance year and departments + annee = '00' + promotion = 'Inconnue' + + if 'homeDirectory' in infos: + dirs = infos['homeDirectory'][0].split('/') + if dirs[1] == 'users': + annee = dirs[2] + dep = dirs[3] + dep = dict(DEPARTMENTS_LIST).get(dep.lower(), '') + promotion = u'%s %s' % (dep, annee) + data['annee'] = annee + data['promotion'] = promotion + + # Mail + pmail = infos.get('mailRoutingAddress', []) + if len(pmail) > 0 : + data['email'] = pmail[0] + return data + def get_ldap_infos(clipper): assert clipper.isalnum() data = {'email':'{}@clipper.ens.fr'.format(clipper.strip().lower())} @@ -50,30 +77,8 @@ def get_ldap_infos(clipper): str("homeDirectory") ]) if len(info) > 0: - infos = info[0][1] - - # Name - data['name'] = infos.get('cn', [''])[0].decode("utf-8") - - # Parsing homeDirectory to get entrance year and departments - annee = '00' - promotion = 'Inconnue' - - if 'homeDirectory' in infos: - dirs = infos['homeDirectory'][0].split('/') - if dirs[1] == 'users': - annee = dirs[2] - dep = dirs[3] - dep = dict(DEPARTMENTS_LIST).get(dep.lower(), '') - promotion = u'%s %s' % (dep, annee) - data['annee'] = annee - data['promotion'] = promotion - - # Mail - pmail = infos.get('mailRoutingAddress', []) - if len(pmail) > 0 : - data['email'] = pmail[0] - + data = _extract_infos_from_ldap(info[0][1], data) + except ldap.LDAPError: pass @@ -87,6 +92,9 @@ class LongTermClipperAccountAdapter(DefaultSocialAccountAdapter): """ def pre_social_login(self, request, sociallogin): + if sociallogin.account.provider != "clipper": + return Super(LongTermClipperAccountAdapter, self).pre_social_login(request, sociallogin) + clipper = sociallogin.account.uid try: a = SocialAccount.objects.get(provider='clipper_inactive', @@ -137,6 +145,8 @@ class LongTermClipperAccountAdapter(DefaultSocialAccountAdapter): return "{}@{}".format(clipper, data.get('annee', '00')) def save_user(self, request, sociallogin, form=None): + if sociallogin.account.provider != "clipper": + return Super(LongTermClipperAccountAdapter, self).save_user(request, sociallogin, form) user = sociallogin.user user.set_unusable_password() @@ -164,6 +174,11 @@ class LongTermClipperAccountAdapter(DefaultSocialAccountAdapter): return user def deprecate_clippers(): + """ + Marks all the SocialAccount with clipper as deprecated, by setting their + provider to 'clipper_inactive' + """ + clippers = SocialAccount.objects.filter(provider='clipper') c_uids = clippers.values_list('uid', flat=True) @@ -172,3 +187,49 @@ def deprecate_clippers(): # Deprecate accounts clippers.update(provider='clipper_inactive') + +def install_longterm_adapter(fake=False): + """ + Manages the transition from an older django_cas or an allauth_ens installation + without LongTermClipperAccountAdapter + """ + + accounts = {u.username: u for u in User.objects.all() if u.username.isalnum()} + l = _init_ldap() + ltc_adapter = get_adapter() + + info = l.search_s('dc=spi,dc=ens,dc=fr', + ldap.SCOPE_SUBTREE, + ("(|{})".format(''.join(("(uid=%s)" % (un,)) + for un in accounts.keys()))), + [str("uid"), + str("cn"), + str("mailRoutingAddress"), + str("homeDirectory") ]) + + logs = {"created": [], "updated": []} + cases = [] + + for userinfo in info: + infos = userinfo[1] + data = _extract_infos_from_ldap(infos) + clipper = infos["uid"][0] + user = accounts.get(clipper, None) + if user is None: + continue + user.username = ltc_adapter.get_username(clipper, data) + if fake: + cases.append(clipper) + else: + user.save() + cases.append(user.username) + if SocialAccount.objects.filter(provider='clipper', uid=clipper).exists(): + logs["updated"].append((clipper, user.username)) + continue + sa = SocialAccount(user=user, provider='clipper', uid=clipper, extra_data=data) + if not fake: + sa.save() + logs["created"].append((clipper, user.username)) + + logs["unmodified"] = User.objects.exclude(username__in=cases).values_list("username", flat=True) + return logs diff --git a/allauth_ens/management/commands/install_longterm.py b/allauth_ens/management/commands/install_longterm.py new file mode 100644 index 0000000..f733010 --- /dev/null +++ b/allauth_ens/management/commands/install_longterm.py @@ -0,0 +1,27 @@ +#coding: utf-8 +from django.core.management.base import BaseCommand, CommandError + +from allauth_ens.adapter import install_longterm_adapter + +class Command(BaseCommand): + help = 'Manages the transition from an older django_cas or an allauth_ens installation without LongTermClipperAccountAdapter' + + def add_arguments(self, parser): + parser.add_argument( + '--fake', + action='store_true', + default=False, + help='Does not save the models created/updated, only shows the list', + ) + pass + + def handle(self, *args, **options): + logs = install_longterm_adapter(options.get("fake", False)) + self.stdout.write("Social accounts created : %d" % len(logs["created"])) + self.stdout.write(" ".join(("%s -> %s" % s) for s in logs["created"])) + self.stdout.write("Social accounts displaced : %d" % len(logs["updated"])) + self.stdout.write(" ".join(("%s -> %s" % s) for s in logs["updated"])) + self.stdout.write("User accounts unmodified : %d" % len(logs["unmodified"])) + self.stdout.write(" ".join(logs["unmodified"])) + + self.stdout.write(self.style.SUCCESS(u'LongTermClipper migration successful')) diff --git a/allauth_ens/tests.py b/allauth_ens/tests.py index cafac82..ae5ba68 100644 --- a/allauth_ens/tests.py +++ b/allauth_ens/tests.py @@ -10,7 +10,7 @@ from mock import patch from fakeldap import MockLDAP from allauth_cas.test.testcases import CASTestCase, CASViewTestCase -from .adapter import deprecate_clippers +from .adapter import deprecate_clippers, install_longterm_adapter from allauth.socialaccount.models import SocialAccount _mock_ldap = MockLDAP() @@ -250,10 +250,11 @@ class LongTermClipperTests(CASTestCase): def test_multiple_deprecation(self): self._setup_ldap(12) - self._setup_ldap(15, "truc") r = self.client_cas_login(self.client, provider_id="clipper", username="test") self.client.logout() + + self._setup_ldap(15, "truc") r = self.client_cas_login(self.client, provider_id="clipper", username="truc") self.client.logout() @@ -275,3 +276,44 @@ class LongTermClipperTests(CASTestCase): # while "truc" remains self.assertEqual(sa0, sa2) self.assertEqual(sa1, sa0+1) + + def test_longterm_installer_from_allauth(self): + self._setup_ldap(12) + with self.settings(SOCIALACCOUNT_ADAPTER=\ + 'allauth.socialaccount.adapter.DefaultSocialAccountAdapter'): + r = self.client_cas_login(self.client, provider_id="clipper", + username='test') + user0 = r.context["user"] + nsa0 = SocialAccount.objects.count() + self.assertEqual(user0.username, "test") + self.client.logout() + + l = install_longterm_adapter() + + self.assertEqual(l["updated"], [("test", "test@12")]) + r = self.client_cas_login(self.client, provider_id="clipper", + username='test') + user1 = r.context["user"] + nsa1 = SocialAccount.objects.count() + self.assertEqual(user1.id, user0.id) + self.assertEqual(nsa1, nsa0) + self.assertEqual(user1.username, "test@12") + + def test_longterm_installer_from_djangocas(self): + with self.settings(SOCIALACCOUNT_ADAPTER=\ + 'allauth.socialaccount.adapter.DefaultSocialAccountAdapter'): + user0 = User.objects.create_user('test', 'test@clipper.ens.fr', 'test') + nsa0 = SocialAccount.objects.count() + + self._setup_ldap(12) + + l = install_longterm_adapter() + + self.assertEqual(l["created"], [("test", "test@12")]) + r = self.client_cas_login(self.client, provider_id="clipper", + username='test') + user1 = r.context["user"] + nsa1 = SocialAccount.objects.count() + self.assertEqual(user1.id, user0.id) + self.assertEqual(nsa1, nsa0+1) + self.assertEqual(user1.username, "test@12")