From c8674bf2ef50ea52f7e74c4b3f1943519150b982 Mon Sep 17 00:00:00 2001
From: Paul Marillonnet <pmarillonnet@entrouvert.com>
Date: Tue, 7 May 2019 10:02:35 +0200
Subject: [PATCH] WIP import users from csv file (#32833)

---
 src/authentic2/app_settings.py                |  1 +
 src/authentic2/data_transfer.py               | 33 +++++++++++++++
 src/authentic2/manager/forms.py               | 13 ++++++
 .../authentic2/manager/users_csv_import.html  | 34 +++++++++++++++
 src/authentic2/manager/urls.py                |  2 +
 src/authentic2/manager/user_views.py          | 41 ++++++++++++++++++-
 src/authentic2/manager/utils.py               | 11 +++++
 tests/users_csv_import_testfile.csv           |  3 ++
 8 files changed, 136 insertions(+), 2 deletions(-)
 create mode 100644 src/authentic2/manager/templates/authentic2/manager/users_csv_import.html
 create mode 100644 tests/users_csv_import_testfile.csv

diff --git a/src/authentic2/app_settings.py b/src/authentic2/app_settings.py
index d21323a0..bae735ce 100644
--- a/src/authentic2/app_settings.py
+++ b/src/authentic2/app_settings.py
@@ -209,6 +209,7 @@ default_settings = dict(
     A2_ACCOUNTS_URL=Setting(default=None, definition='IdP has no account page, redirect to this one.'),
     A2_CACHE_ENABLED=Setting(default=True, definition='Disable all cache decorators for testing purpose.'),
     A2_ACCEPT_EMAIL_AUTHENTICATION=Setting(default=True, definition='Enable authentication by email'),
+    A2_USERS_CSV_IMPORT_APP_ID_LABEL=Setting(default='app_id', definition='Label of the applicative identifier used when importing users from csv'),
 
 )
 
diff --git a/src/authentic2/data_transfer.py b/src/authentic2/data_transfer.py
index 6f5a5d77..21ea942d 100644
--- a/src/authentic2/data_transfer.py
+++ b/src/authentic2/data_transfer.py
@@ -1,9 +1,15 @@
+import csv
+
+from django.contrib.auth import get_user_model
 from django.contrib.contenttypes.models import ContentType
+from django.db import transaction
 
 from django_rbac.models import Operation
 from django_rbac.utils import (
     get_ou_model, get_role_model, get_role_parenting_model, get_permission_model)
 from authentic2.a2_rbac.models import RoleAttribute
+from authentic2 import app_settings
+from authentic2.models import UserExternalId
 
 
 def update_model(obj, d):
@@ -318,3 +324,30 @@ def import_site(json_d, import_context=None):
                     import_context.role_delete_orphans))
 
     return result
+
+
+def import_users_from_csv(csv_file, ou):
+    # TODO prevent illegal attribute-setting
+    # TODO do not built the complete list from csv, may be memory-consuming
+    # TODO detect the dialect
+    # TODO CLI
+    reader = csv.reader(csv_file) # XXX dialect
+    rows = list(reader)
+    app_id_label = app_settings.A2_USERS_CSV_IMPORT_APP_ID_LABEL
+
+    try:
+        rows[0].index(app_id_label)
+    except ValueError:
+        return -1, 'The csv file must contain an app_id column.'
+
+    with transaction.atomic():
+        User = get_user_model()
+        for i in range(1,len(rows)):
+            user_data = dict(zip(rows[0], rows[i]))
+            app_id = user_data.pop(app_id_label)
+            user = User.objects.create(**user_data)
+            user.ou = ou
+            user_external_id = UserExternalId.objects.create(
+                user=user, external_id=app_id, source='import')
+
+    return 0, 'Created %s users.' % (len(rows)-1)
diff --git a/src/authentic2/manager/forms.py b/src/authentic2/manager/forms.py
index 1708290f..c6d610e0 100644
--- a/src/authentic2/manager/forms.py
+++ b/src/authentic2/manager/forms.py
@@ -694,3 +694,16 @@ class UserChangeEmailForm(CssClass, FormWithRequest, forms.ModelForm):
 
     class Meta:
         fields = ()
+
+
+class UsersCsvImportForm(forms.Form):
+    csv_file = forms.FileField(
+        label=_('CSV file'),
+        help_text=_('CSV file for the import.')
+    )
+    ou = forms.ChoiceField(
+        label=_('Organizational unit'),
+        help_text=_('The newly created users will be registered to the chosen OU.'),
+        choices=utils.get_ou_choices(),
+        initial=get_default_ou().uuid
+    )
diff --git a/src/authentic2/manager/templates/authentic2/manager/users_csv_import.html b/src/authentic2/manager/templates/authentic2/manager/users_csv_import.html
new file mode 100644
index 00000000..ac63d678
--- /dev/null
+++ b/src/authentic2/manager/templates/authentic2/manager/users_csv_import.html
@@ -0,0 +1,34 @@
+{% extends "authentic2/manager/form.html" %}
+{% load i18n %}
+
+{% block page-title %}
+  {{ block.super }} - {{ view.title }}
+{% endblock %}
+
+{% block breadcrumb %}
+  {{ block.super }}
+  <a href="..">{% trans "Users" %}</a>
+  <a href="">{{ view.title }}</a>
+{% endblock %}
+
+{% block content %}
+  {% trans "Comma-separated value file with named columns:" %}
+  <ul>
+    <li>{{ app_id_label }}</li>
+  {% for attr in attributes %}
+    <li>{{ attr.name }}</li>
+  {% endfor %}
+  </ul>
+
+  <form enctype="multipart/form-data" method="post">
+
+    {% csrf_token %}
+    {{ form.as_p }}
+    {% if form.instance and form.instance.id %}
+      <button class="submit-button">{% trans "Submit" %}</button>
+      <button class="cancel-button" name="cancel" formnovalidate>{% trans "Cancel" %}</button>
+    {% else %}
+      <button class="submit-button">{% trans "Create" %}</button>
+    {% endif %}
+  </form>
+{% endblock %}
diff --git a/src/authentic2/manager/urls.py b/src/authentic2/manager/urls.py
index c0646d5d..3175ed01 100644
--- a/src/authentic2/manager/urls.py
+++ b/src/authentic2/manager/urls.py
@@ -21,6 +21,8 @@ urlpatterns = required(
         url(r'^users/$', user_views.users, name='a2-manager-users'),
         url(r'^users/export/(?P<format>csv)/$',
             user_views.users_export, name='a2-manager-users-export'),
+        url(r'^users/import/$',
+            user_views.users_csv_import, name='a2-manager-users-import'),
         url(r'^users/add/$', user_views.user_add_default_ou,
             name='a2-manager-user-add-default-ou'),
         url(r'^users/(?P<ou_pk>\d+)/add/$', user_views.user_add,
diff --git a/src/authentic2/manager/user_views.py b/src/authentic2/manager/user_views.py
index 711b1ea6..e6ad1c65 100644
--- a/src/authentic2/manager/user_views.py
+++ b/src/authentic2/manager/user_views.py
@@ -15,7 +15,7 @@ from django.contrib.contenttypes.models import ContentType
 from django.contrib import messages
 from django.http import HttpResponseRedirect, QueryDict
 from django.views.generic.detail import SingleObjectMixin
-from django.views.generic import View
+from django.views.generic import View, FormView
 
 from import_export.fields import Field
 import tablib
@@ -33,7 +33,8 @@ from .views import BaseTableView, BaseAddView, \
     BaseSubTableView, HideOUColumnMixin, BaseDeleteView, BaseDetailView
 from .tables import UserTable, UserRolesTable, OuUserRolesTable
 from .forms import (UserSearchForm, UserAddForm, UserEditForm,
-    UserChangePasswordForm, ChooseUserRoleForm, UserRoleSearchForm, UserChangeEmailForm)
+    UserChangePasswordForm, ChooseUserRoleForm, UserRoleSearchForm, UserChangeEmailForm,
+    UsersCsvImportForm)
 from .resources import UserResource
 from .utils import get_ou_count
 from . import app_settings
@@ -603,3 +604,39 @@ class UserDeleteView(BaseDeleteView):
 
 
 user_delete = UserDeleteView.as_view()
+
+
+class UsersCsvImportView(FormView):
+    form_class = UsersCsvImportForm
+    template_name = 'authentic2/manager/users_csv_import.html'
+    title = _('CSV import')
+    success_url = '..'
+    permissions = ['custom_user.add_user']
+
+    def get_context_data(self, **kwargs):
+        from authentic2.models import Attribute
+
+        ctx = super(UsersCsvImportView, self).get_context_data()
+        ctx.update({
+            'attributes': Attribute.objects.all(),
+            'app_id_label': a2_app_settings.A2_USERS_CSV_IMPORT_APP_ID_LABEL
+        })
+        return ctx
+
+    def form_valid(self, form):
+        from authentic2.data_transfer import import_users_from_csv
+
+        ou_uuid = form.cleaned_data.get('ou')
+        ou = get_ou_model().objects.get(uuid=ou_uuid) or get_default_ou()
+        csv_file = form.cleaned_data.get('csv_file')
+
+        if csv_file:
+            status, message = import_users_from_csv(csv_file, ou)
+        else:
+            status, message = -1, 'Please provide a csv file.'
+
+        # XXX django message
+        return super(UsersCsvImportView, self).form_valid(form)
+
+
+users_csv_import = UsersCsvImportView.as_view()
diff --git a/src/authentic2/manager/utils.py b/src/authentic2/manager/utils.py
index 619b5e7c..c29d2144 100644
--- a/src/authentic2/manager/utils.py
+++ b/src/authentic2/manager/utils.py
@@ -26,3 +26,14 @@ def label_from_user(user):
 @GlobalCache(timeout=10)
 def get_ou_count():
     return get_ou_model().objects.count()
+
+
+@GlobalCache(timeout=10)
+def get_ou_choices():
+    Ou = get_ou_model()
+    return [(ou.uuid, ou.name) for ou in Ou.objects.all()]
+
+
+def example_csv():
+    # todo
+    pass
diff --git a/tests/users_csv_import_testfile.csv b/tests/users_csv_import_testfile.csv
new file mode 100644
index 00000000..1488bc23
--- /dev/null
+++ b/tests/users_csv_import_testfile.csv
@@ -0,0 +1,3 @@
+app_id,first_name,last_name
+123,Hervé,Dupont
+456,René,Durant
-- 
2.20.1

