Projet

Général

Profil

0004-manager-add-user-import-views-32833.patch

Benjamin Dauvergne, 21 juin 2019 18:11

Télécharger (42,5 ko)

Voir les différences:

Subject: [PATCH 4/4] manager: add user import views (#32833)

 debian/control                                |   3 +-
 setup.py                                      |   1 +
 src/authentic2/manager/forms.py               |  74 ++++++
 .../authentic2/manager/css/user_import.css    |  51 ++++
 .../authentic2/manager/user_import.html       |  67 +++++
 .../manager/user_import_report.html           | 134 ++++++++++
 .../authentic2/manager/user_imports.html      |  47 ++++
 .../templates/authentic2/manager/users.html   |   9 +
 src/authentic2/manager/urls.py                |   8 +
 src/authentic2/manager/user_import.py         | 228 ++++++++++++++++++
 src/authentic2/manager/user_views.py          | 142 ++++++++++-
 src/authentic2/manager/views.py               |   9 +-
 tests/test_manager_user_import.py             |  93 +++++++
 tests/test_user_manager.py                    | 101 ++++++++
 14 files changed, 959 insertions(+), 8 deletions(-)
 create mode 100644 src/authentic2/manager/static/authentic2/manager/css/user_import.css
 create mode 100644 src/authentic2/manager/templates/authentic2/manager/user_import.html
 create mode 100644 src/authentic2/manager/templates/authentic2/manager/user_import_report.html
 create mode 100644 src/authentic2/manager/templates/authentic2/manager/user_imports.html
 create mode 100644 src/authentic2/manager/user_import.py
 create mode 100644 tests/test_manager_user_import.py
debian/control
31 31
    python-pil,
32 32
    python-tablib,
33 33
    python-chardet,
34
    python-attr
34
    python-attr,
35
    python-atomicwrites
35 36
Breaks: python-authentic2-auth-fc (<< 0.26)
36 37
Replaces: python-authentic2-auth-fc (<< 0.26)
37 38
Provides: ${python:Provides}, python-authentic2-auth-fc
setup.py
142 142
          'tablib',
143 143
          'chardet',
144 144
          'attrs',
145
          'atomicwrites',
145 146
      ],
146 147
      zip_safe=False,
147 148
      classifiers=[
src/authentic2/manager/forms.py
45 45
from . import fields, app_settings, utils
46 46

  
47 47
User = get_user_model()
48
OU = get_ou_model()
48 49

  
49 50
logger = logging.getLogger(__name__)
50 51

  
......
715 716
class SiteImportForm(forms.Form):
716 717
    site_json = forms.FileField(
717 718
        label=_('Site Export File'))
719

  
720

  
721
ENCODINGS = [
722
    ('utf-8', _('UTF-8')),
723
    ('cp1252', _('CP-1252 - Windows France')),
724
    ('iso-8859-1', _('Latin 1')),
725
    ('iso-8859-15', _('Latin 15')),
726
    ('detect', _('Detect encoding')),
727
]
728

  
729

  
730
class UserImportForm(forms.Form):
731
    import_file = forms.FileField(
732
        label=_('Import file'),
733
        help_text=_('A CSV file'))
734
    encoding = forms.ChoiceField(
735
        label=_('Encoding'),
736
        choices=ENCODINGS)
737
    ou = forms.ModelChoiceField(
738
        label=_('Organizational Unit'),
739
        queryset=OU.objects.all())
740

  
741

  
742
class UserNewImportForm(UserImportForm):
743
    def clean(self):
744
        from authentic2.csv_import import CsvImporter
745

  
746
        import_file = self.cleaned_data['import_file']
747
        encoding = self.cleaned_data['encoding']
748
        # force seek(0)
749
        import_file.open()
750
        importer = CsvImporter()
751
        if not importer.run(import_file, encoding):
752
            raise forms.ValidationError(importer.error.description or importer.error.code)
753
        self.cleaned_data['rows_count'] = len(importer.rows)
754

  
755
    def save(self):
756
        from . import user_import
757

  
758
        import_file = self.cleaned_data['import_file']
759
        import_file.open()
760
        new_import = user_import.UserImport.new(
761
            import_file=import_file,
762
            encoding=self.cleaned_data['encoding'])
763
        with new_import.meta_update as meta:
764
            meta['filename'] = import_file.name
765
            meta['ou'] = self.cleaned_data['ou']
766
            meta['rows_count'] = self.cleaned_data['rows_count']
767
        return new_import
768

  
769

  
770
class UserEditImportForm(UserImportForm):
771
    def __init__(self, *args, **kwargs):
772
        self.user_import = kwargs.pop('user_import')
773
        initial = kwargs.setdefault('initial', {})
774
        initial['encoding'] = self.user_import.meta['encoding']
775
        initial['ou'] = self.user_import.meta['ou']
776
        super(UserEditImportForm, self).__init__(*args, **kwargs)
777
        del self.fields['import_file']
778

  
779
    def clean(self):
780
        from authentic2.csv_import import CsvImporter
781
        encoding = self.cleaned_data['encoding']
782
        with self.user_import.import_file as fd:
783
            importer = CsvImporter(fd, encoding)
784
            if not importer.run():
785
                raise forms.ValidationError(importer.error.description or importer.error.code)
786
            self.cleaned_data['rows_count'] = len(importer.rows)
787

  
788
    def save(self):
789
        with self.user_import.meta_update as meta:
790
            meta['ou'] = self.cleaned_data['ou']
791
            meta['encoding'] = self.cleaned_data['encoding']
src/authentic2/manager/static/authentic2/manager/css/user_import.css
1
#import-report-table tr.row-valid td, .legend-row-valid {
2
    background-color: #d5f5e3 ;
3
}
4

  
5
#import-report-table tr.row-invalid td, .legend-row-invalid {
6
    background-color: #ff4408;
7
}
8

  
9
#import-report-table tr td.cell-action-updated, .legend-cell-action-updated {
10
    background-color: #abebc6;
11
}
12

  
13
#import-report-table tr td.cell-errors, .legend-cell-errors {
14
    background-color: #cd6155;
15
}
16
.header-flag-key::after {
17
        content: "\f084"; /* fa-key */
18
        font-family: FontAwesome;
19
        padding-left: 1ex;
20
}
21

  
22
.header-flag-unique::after,
23
.header-flag-globally-unique::after
24
{
25
        content: "\f0cd"; /* fa-underline */
26
        font-family: FontAwesome;
27
        padding-left: 1ex;
28
}
29

  
30
.header-flag-create::after {
31
        content: "\f0fe"; /* fa-plus-square */
32
        font-family: FontAwesome;
33
        padding-left: 1ex;
34
}
35

  
36
.header-flag-update::after {
37
        content: "\f040"; /* fa-pencil */
38
        font-family: FontAwesome;
39
        padding-left: 1ex;
40
}
41

  
42
.header-flag-verified::after {
43
        content: "\f023"; /* fa-lock */
44
        font-family: FontAwesome;
45
        padding-left: 1ex;
46
}
47

  
48
span.icon-check::after {
49
    content: "\f00c";
50
    font-family: FontAwesome;
51
}
src/authentic2/manager/templates/authentic2/manager/user_import.html
1
{% extends "authentic2/manager/base.html" %}
2
{% load i18n gadjo staticfiles %}
3

  
4
{% block page-title %}{{ block.super }} - {% trans "Import Users" %}{% endblock %}
5

  
6
{% block css %}
7
{{ block.super }}
8
<link rel="stylesheet" href="{% static "authentic2/manager/css/user_import.css" %}">
9
{% endblock %}
10

  
11
{% block breadcrumb %}
12
  {{ block.super }}
13
  <a href="{% url 'a2-manager-users' %}">{% trans 'Users' %}</a>
14
  <a href="{% url 'a2-manager-users-imports' %}">{% trans 'Imports' %}</a>
15
  <a href="{% url 'a2-manager-users-import' uuid=user_import.uuid %}">{% trans "User Import" %} {{ user_import.created }}</a>
16
{% endblock %}
17

  
18
{% block sidebar %}
19
  <aside id="sidebar">
20
    <div>
21
      <h3>{% trans "Modify import" %}</h3>
22
      <form method="post" id="action-form">
23
        {% csrf_token %}
24
        {{ form|with_template }}
25
        <div class="buttons">
26
          <button name="modify">{% trans "Modify" %}</button>
27
          <button name="simulate">{% trans "Simulate" %}</button>
28
          <button name="execute">{% trans "Execute" %}</button>
29
        </div>
30
      </form>
31
    </div>
32
    <div>
33
      <h3>{% trans "Download" %}</h3>
34
      <form action="download/{{ user_import.filename }}" id="download-form">
35
        <div class="buttons">
36
          <button>{% trans "Download" %}</button>
37
        </div>
38
      </form>
39
    </div>
40
  </aside>
41
{% endblock %}
42

  
43
{% block main %}
44
  <h2>{% trans "User Import" %} - {{ user_import.created }} - {{ user_import.user }}</h2>
45
  <p>{% trans "Rows count:" %} {{ user_import.rows_count }}</p>
46
  <h2>{% trans "Reports" %}</h2>
47
  <table class="main">
48
    <thead>
49
      <tr>
50
        <td>{% trans "Creation date" %}</td>
51
        <td>{% trans "State" %}</td>
52
        <td>{% trans "Imported" %}</td>
53
        <td></td>
54
      </tr>
55
    </thead>
56
    <tbody>
57
      {% for report in reports %}
58
        <tr data-uuid="{{ report.uuid }}">
59
          <td class="created">{% if report.state != 'running' %}<a href="{% url "a2-manager-users-import-report" import_uuid=user_import.uuid report_uuid=report.uuid %}">{{ report.created }}</a>{% else %}{{ report.created }}{% endif %}</td>
60
          <td class="state"><span>{{ report.state }}</span> {% if report.state == 'error' %}"{{ report.exception }}"{% endif %}</td>
61
          <td class="applied">{% if not report.simulate %}<span class="icon-check"></span>{% endif %}</td>
62
          <td class="delete-action">{% if report.simulate %}<form method="post" id="delete-form-{{ report.uuid }}">{% csrf_token %}<button name="delete" value="{{ report.uuid }}">{% trans "Delete" %}</button></form>{% endif %}</td>
63
        </tr>
64
      {% endfor %}
65
    </tbody>
66
  </table>
67
{% endblock %}
src/authentic2/manager/templates/authentic2/manager/user_import_report.html
1
{% extends "authentic2/manager/base.html" %}
2
{% load i18n gadjo staticfiles %}
3

  
4
{% block page-title %}{{ block.super }} - {% trans "Import Users" %}{% endblock %}
5

  
6
{% block css %}
7
{{ block.super }}
8
<link rel="stylesheet" href="{% static "authentic2/manager/css/user_import.css" %}">
9
{% endblock %}
10

  
11
{% block breadcrumb %}
12
  {{ block.super }}
13
  <a href="{% url 'a2-manager-users' %}">{% trans 'Users' %}</a>
14
  <a href="{% url 'a2-manager-users-imports' %}">{% trans 'Imports' %}</a>
15
  <a href="{% url 'a2-manager-users-import' uuid=user_import.uuid %}">{% trans "User Import" %} {{ user_import.created }}</a>
16
  <a href="{% url 'a2-manager-users-import-report' import_uuid=user_import.uuid report_uuid=report.uuid%}">{{ report_title }} {{ report.created }}</a>
17
{% endblock %}
18

  
19
{% block sidebar %}
20
  <aside id="sidebar">
21
    <h3>{% trans "Legend" %}</td>
22
    <table>
23
      <tr>
24
        <td><span class="header-flag-key"></span></td>
25
        <td>{% trans "value is a key" %}</td>
26
      </tr>
27
      <tr>
28
        <td><span class="header-flag-unique"></span></td>
29
        <td>{% trans "value must be unique" %}</td>
30
      </tr>
31
      <tr>
32
        <td><span class="header-flag-create"></span></td>
33
        <td>{% trans "used on creation" %}</td>
34
      </tr>
35
      <tr>
36
        <td><span class="header-flag-update"></span></td>
37
        <td>{% trans "used on update" %}</td>
38
      </tr>
39
      <tr>
40
        <td><span class="header-flag-verified"></span></td>
41
        <td>{% trans "value is verified" %}</td>
42
      </tr>
43
      <tr>
44
        <td class="legend-row-valid"></td>
45
        <td>{% trans "row is valid" %}</td>
46
      </tr>
47
      <tr>
48
        <td class="legend-cell-action-updated"></td>
49
        <td>{% trans "value will be written" %}</td>
50
      </tr>
51
      <tr>
52
        <td class="legend-row-invalid"></td>
53
        <td>{% trans "row is invalid" %}</td>
54
      </tr>
55
      <tr>
56
        <td class="legend-cell-errors"></td>
57
        <td>{% trans "value has errors" %}</td>
58
      </tr>
59
    </table>
60
  </aside>
61
{% endblock %}
62

  
63
{% block main %}
64
  <h2>{{ report_title }} - {{ report.created }} - {{ report.state }}</h2>
65
  {% if report.exception %}
66
  <p>{% trans "Exception:" %} {{ report.exception}}</p>
67
  {% endif %}
68
  {% if report.importer %}
69
    {% with importer=report.importer %}
70
      {% if importer.errors %}
71
        <h4>{% trans "Errors" %}</h4>
72
        <ul class="errors">
73
          {% for error in importer.errors %}
74
          <li data-code="{{ error.code }}">{% firstof error.description error.code %}</li>
75
          {% endfor %}
76
        </ul>
77
      {% endif %}
78
      <h3>{% trans "Abstract" %}</h3>
79
      <ul>
80
        <li>{{ importer.rows|length }} {% trans "rows" %}</li>
81
        <li>{{ importer.created }} {% trans "user(s) created" %}</li>
82
        <li>{{ importer.updated }} {% trans "user(s) updated" %}</li>
83
        <li>{{ importer.rows_with_errors }} {% trans "rows have errors" %}</li>
84
      </ul>
85
      <h3>{% trans "Details" %}</h3>
86
      {% if importer.rows %}
87
        <table id="import-report-table" class="main">
88
          <thead>
89
              <tr>
90
                <td>{% trans "Line" %}</td>
91
                {% for header in importer.headers %}
92
                  <td
93
                    {% if header.flags %}
94
                    title="flags: {% for flag in header.flags %}{{ flag }} {% endfor %}"
95
                    {% endif %}
96
                    >
97
                    {{ header.name }}
98
                    {% for flag in header.flags %}
99
                      <span class="header-flag-{{ flag }}"></span>
100
                    {% endfor %}
101
                  </td>
102
                {% endfor %}
103
                <td>{% trans "Action" %}</td>
104
              </tr>
105
          </thead>
106
          <tbody>
107
            {% for row in importer.rows %}
108
              <tr
109
                class="{% if row.is_valid %}row-valid{% else %}row-invalid{% endif %} row-action-{{ row.action }}"
110
                {% if not row.is_valid %}title="{% for error in row.errors %}{% firstof error.description error.code %}
111
                {% endfor %}{% for cell in row %}{% for error in cell.errors %}
112
{{ cell.header.name }}: {% firstof error.description error.code %}{% endfor %}{% endfor %}"{% endif %}
113
              >
114
                <td class="row-line">{{ row.line }}</td>
115
                {% for cell in row %}
116
                  <td
117
                    class="{% if cell.errors %}cell-errors{% endif %} {% if cell.action %}cell-action-{{ cell.action }}{% endif %}"
118
                    {% if cell.errors %}title="{% for error in cell.errors %}{% firstof error.description error.code %}
119
{% endfor %}"{% endif %}
120
                  >
121
                    {{ cell.value }}
122
                  </td>
123
                {% endfor %}
124
                <td class="row-action">{% firstof row.action "-" %}</td>
125
              </tr>
126
            {% endfor %}
127
          </tbody>
128
        </table>
129
      {% else %}
130
        <p>{% trans "No row analysed." %}</p>
131
      {% endif %}
132
    {% endwith %}
133
  {% endif %}
134
{% endblock %}
src/authentic2/manager/templates/authentic2/manager/user_imports.html
1
{% extends "authentic2/manager/base.html" %}
2
{% load i18n gadjo staticfiles %}
3

  
4
{% block page-title %}{{ block.super }} - {% trans "Import Users" %}{% endblock %}
5

  
6
{% block breadcrumb %}
7
  {{ block.super }}
8
  <a href="{% url 'a2-manager-users' %}">{% trans 'Users' %}</a>
9
  <a href="{% url 'a2-manager-users-imports' %}">{% trans 'Import Users' %}</a>
10
{% endblock %}
11

  
12
{% block sidebar %}
13
  <aside id="sidebar">
14
    <h3>{% trans "Create new import" %}</h3>
15
    <form method="post" enctype="multipart/form-data">
16
      {% csrf_token %}
17
      {{ form|with_template }}
18
      <button name="create">{% trans "Create" %}</button>
19
    </form>
20
  </aside>
21
{% endblock %}
22

  
23
{% block content %}
24
  <h3>{% trans "Imports" %}</h3>
25
  <table class="main">
26
    <thead>
27
      <tr>
28
        <td>{% trans "Filename" %}</td>
29
        <td>{% trans "Creation date" %}</td>
30
        <td>{% trans "By" %}</td>
31
        <td>{% trans "Rows" %}</td>
32
        <td></td>
33
      </tr>
34
    </thead>
35
    <tbody>
36
      {% for import in imports %}
37
        <tr>
38
          <td><a href="{% url "a2-manager-users-import" uuid=import.uuid %}">{{ import.filename }}</a></td>
39
          <td>{{ import.created }}</td>
40
          <td>{{ import.user }}</td>
41
          <td>{{ import.rows_count }}</td>
42
          <td><form method="post">{% csrf_token %}<button name="delete" value="{{ import.uuid }}">{% trans "Delete" %}</button></form></td>
43
        </tr>
44
      {% endfor %}
45
    </tbody>
46
  </table>
47
{% endblock %}
src/authentic2/manager/templates/authentic2/manager/users.html
6 6
{% block appbar %}
7 7
  {{ block.super }}
8 8
  <span class="actions">
9
    <a class="extra-actions-menu-opener"></a>
9 10
    {% if add_ou %}
10 11
     <a
11 12
        href="{% url "a2-manager-user-add" ou_pk=add_ou.pk %}"
......
20 21
         {% trans "Add user" %}
21 22
     </a>
22 23
   {% endif %}
24
   {% if extra_actions %}
25
     <ul class="extra-actions-menu">
26
       {% for extra_action in extra_actions %}
27
         <li><a href="{{ extra_action.url }}">{{ extra_action.label }}</a></li>
28
       {% endfor %}
29
     </ul>
30
   {% endif %}
23 31
  </span>
32

  
24 33
{% endblock %}
25 34

  
26 35
{% block breadcrumb %}
src/authentic2/manager/urls.py
39 39
            user_views.users_export, name='a2-manager-users-export'),
40 40
        url(r'^users/add/$', user_views.user_add_default_ou,
41 41
            name='a2-manager-user-add-default-ou'),
42
        url(r'^users/import/$',
43
            user_views.user_imports, name='a2-manager-users-imports'),
44
        url(r'^users/import/(?P<uuid>[a-z0-9]+)/download/(?P<filename>.*)$',
45
            user_views.user_import, name='a2-manager-users-import-download'),
46
        url(r'^users/import/(?P<uuid>[a-z0-9]+)/$',
47
            user_views.user_import, name='a2-manager-users-import'),
48
        url(r'^users/import/(?P<import_uuid>[a-z0-9]+)/(?P<report_uuid>[a-z0-9]+)/$',
49
            user_views.user_import_report, name='a2-manager-users-import-report'),
42 50
        url(r'^users/(?P<ou_pk>\d+)/add/$', user_views.user_add,
43 51
            name='a2-manager-user-add'),
44 52
        url(r'^users/(?P<pk>\d+)/$', user_views.user_detail,
src/authentic2/manager/user_import.py
1
# authentic2 - versatile identity manager
2
# Copyright (C) 2010-2019 Entr'ouvert
3
#
4
# This program is free software: you can redistribute it and/or modify it
5
# under the terms of the GNU Affero General Public License as published
6
# by the Free Software Foundation, either version 3 of the License, or
7
# (at your option) any later version.
8
#
9
# This program is distributed in the hope that it will be useful,
10
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12
# GNU Affero General Public License for more details.
13
#
14
# You should have received a copy of the GNU Affero General Public License
15
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
16

  
17
from __future__ import unicode_literals
18

  
19
import base64
20
import contextlib
21
import datetime
22
import logging
23
import os
24
import pickle
25
import shutil
26
import uuid
27
import threading
28

  
29
from atomicwrites import atomic_write
30

  
31
from django.core.files.storage import default_storage
32
from django.utils import six
33
from django.utils.functional import cached_property
34
from django.utils.timezone import utc
35

  
36
logger = logging.getLogger(__name__)
37

  
38

  
39
def new_id():
40
    return (base64.b32encode(uuid.uuid4().get_bytes())
41
            .strip('=')
42
            .lower()
43
            .decode('ascii'))
44

  
45

  
46
class UserImport(object):
47
    def __init__(self, uuid):
48
        self.uuid = uuid
49
        self.path = os.path.join(self.base_path(), self.uuid)
50
        self.import_path = os.path.join(self.path, 'content')
51
        self.meta_path = os.path.join(self.path, 'meta.pck')
52

  
53
    def exists(self):
54
        return os.path.exists(self.import_path) and os.path.exists(self.meta_path)
55

  
56
    @cached_property
57
    def created(self):
58
        return datetime.datetime.fromtimestamp(os.path.getctime(self.path), utc)
59

  
60
    @property
61
    def import_file(self):
62
        return open(self.import_path, 'rb')
63

  
64
    @cached_property
65
    def meta(self):
66
        meta = {}
67
        if os.path.exists(self.meta_path):
68
            with open(self.meta_path) as fd:
69
                meta = pickle.load(fd)
70
        return meta
71

  
72
    @property
73
    @contextlib.contextmanager
74
    def meta_update(self):
75
        try:
76
            yield self.meta
77
        finally:
78
            with atomic_write(self.meta_path, overwrite=True) as fd:
79
                pickle.dump(self.meta, fd)
80

  
81
    @classmethod
82
    def base_path(self):
83
        path = default_storage.path('user_imports')
84
        if not os.path.exists(path):
85
            os.makedirs(path)
86
        return path
87

  
88
    @classmethod
89
    def new(cls, import_file, encoding):
90
        o = cls(new_id())
91
        os.makedirs(o.path)
92
        with open(o.import_path, 'wb') as fd:
93
            import_file.seek(0)
94
            fd.write(import_file.read())
95
        with o.meta_update as meta:
96
            meta['encoding'] = encoding
97
        return o
98

  
99
    @classmethod
100
    def all(cls):
101
        for subpath in os.listdir(cls.base_path()):
102
            user_import = UserImport(subpath)
103
            if user_import.exists():
104
                yield user_import
105

  
106
    @property
107
    def reports(self):
108
        return Reports(self)
109

  
110
    def __getattr__(self, name):
111
        try:
112
            return self.meta[name]
113
        except KeyError:
114
            raise AttributeError(name)
115

  
116
    def delete(self):
117
        if self.exists():
118
            shutil.rmtree(self.path)
119

  
120

  
121
class Report(object):
122
    def __init__(self, user_import, uuid):
123
        self.user_import = user_import
124
        self.uuid = uuid
125
        self.path = os.path.join(self.user_import.path, '%s%s' % (Reports.PREFIX, uuid))
126

  
127
    @cached_property
128
    def created(self):
129
        return datetime.datetime.fromtimestamp(os.path.getctime(self.path), utc)
130

  
131
    @cached_property
132
    def data(self):
133
        data = {}
134
        if os.path.exists(self.path):
135
            with open(self.path) as fd:
136
                data = pickle.load(fd)
137
        return data
138

  
139
    @property
140
    @contextlib.contextmanager
141
    def data_update(self):
142
        try:
143
            yield self.data
144
        finally:
145
            with atomic_write(self.path, overwrite=True) as fd:
146
                pickle.dump(self.data, fd)
147

  
148
    @classmethod
149
    def new(cls, user_import):
150
        report = cls(user_import, new_id())
151
        with report.data_update as data:
152
            data['encoding'] = user_import.meta['encoding']
153
            data['ou'] = user_import.meta.get('ou')
154
            data['state'] = 'waiting'
155
        return report
156

  
157
    def run(self, start=True, simulate=False):
158
        assert self.data.get('state') == 'waiting'
159

  
160
        with self.data_update as data:
161
            data['simulate'] = simulate
162

  
163
        def target():
164
            from authentic2.csv_import import UserCsvImporter
165

  
166
            with self.user_import.import_file as fd:
167
                importer = UserCsvImporter()
168
                try:
169
                    importer.run(fd,
170
                                 encoding=self.data['encoding'],
171
                                 ou=self.data['ou'],
172
                                 simulate=simulate)
173
                except Exception as e:
174
                    logger.exception('error during report %s:%s run', self.user_import.uuid, self.uuid)
175
                    state = 'error'
176
                    try:
177
                        exception = six.text_type(e)
178
                    except Exception:
179
                        exception = repr(repr(e))
180
                else:
181
                    exception = None
182
                    state = 'finished'
183

  
184
                with self.data_update as data:
185
                    data['state'] = state
186
                    data['exception'] = exception
187
                    data['importer'] = importer
188
        t = threading.Thread(target=target)
189
        t.daemon = True
190
        with self.data_update as data:
191
            data['state'] = 'running'
192
        if start:
193
            t.start()
194
        return t
195

  
196
    def __getattr__(self, name):
197
        try:
198
            return self.data[name]
199
        except KeyError:
200
            raise AttributeError(name)
201

  
202
    def exists(self):
203
        return os.path.exists(self.path)
204

  
205
    def delete(self):
206
        if self.simulate and self.exists():
207
            os.unlink(self.path)
208

  
209

  
210
class Reports(object):
211
    PREFIX = 'report-'
212

  
213
    def __init__(self, user_import):
214
        self.user_import = user_import
215

  
216
    def __getitem__(self, uuid):
217
        report = Report(self.user_import, uuid)
218
        if not report.exists():
219
            raise KeyError
220
        return report
221

  
222
    def __iter__(self):
223
        for name in os.listdir(self.user_import.path):
224
            if name.startswith(self.PREFIX):
225
                try:
226
                    yield self[name[len(self.PREFIX):]]
227
                except KeyError:
228
                    pass
src/authentic2/manager/user_views.py
16 16

  
17 17
import datetime
18 18
import collections
19
import operator
19 20

  
20 21
from django.db import models
21 22
from django.utils.translation import ugettext_lazy as _, ugettext
......
26 27
from django.contrib.auth import get_user_model
27 28
from django.contrib.contenttypes.models import ContentType
28 29
from django.contrib import messages
30
from django.views.generic import FormView, TemplateView
31
from django.http import Http404, FileResponse
29 32

  
30 33
import tablib
31 34

  
......
36 39
from django_rbac.utils import get_role_model, get_role_parenting_model, get_ou_model
37 40

  
38 41

  
39
from .views import BaseTableView, BaseAddView, \
40
    BaseEditView, ActionMixin, OtherActionsMixin, Action, ExportMixin, \
41
    BaseSubTableView, HideOUColumnMixin, BaseDeleteView, BaseDetailView
42
from .views import (BaseTableView, BaseAddView, BaseEditView, ActionMixin,
43
                    OtherActionsMixin, Action, ExportMixin, BaseSubTableView,
44
                    HideOUColumnMixin, BaseDeleteView, BaseDetailView,
45
                    PermissionMixin, MediaMixin)
42 46
from .tables import UserTable, UserRolesTable, OuUserRolesTable
43 47
from .forms import (UserSearchForm, UserAddForm, UserEditForm,
44
    UserChangePasswordForm, ChooseUserRoleForm, UserRoleSearchForm, UserChangeEmailForm)
48
                    UserChangePasswordForm, ChooseUserRoleForm,
49
                    UserRoleSearchForm, UserChangeEmailForm, UserNewImportForm,
50
                    UserEditImportForm)
45 51
from .resources import UserResource
46 52
from .utils import get_ou_count, has_show_username
47 53
from . import app_settings
......
104 110
            ou = self.search_form.cleaned_data.get('ou')
105 111
        if ou and self.request.user.has_ou_perm('custom_user.add_user', ou):
106 112
            ctx['add_ou'] = ou
113
        extra_actions = ctx['extra_actions'] = []
114
        if self.request.user.has_perm('custom_user.admin_user'):
115
            extra_actions.append({
116
                'url': reverse('a2-manager-users-imports'),
117
                'label': _('Import users'),
118
            })
107 119
        return ctx
108 120

  
109 121

  
......
618 630

  
619 631

  
620 632
user_delete = UserDeleteView.as_view()
633

  
634

  
635
class UserImportsView(MediaMixin, PermissionMixin, FormView):
636
    form_class = UserNewImportForm
637
    permissions = ['custom_user.admin_user']
638
    permissions_global = True
639
    template_name = 'authentic2/manager/user_imports.html'
640

  
641
    def post(self, request, *args, **kwargs):
642
        from . import user_import
643

  
644
        if 'delete' in request.POST:
645
            uuid = request.POST['delete']
646
            user_import.UserImport(uuid).delete()
647
            return redirect(self.request, 'a2-manager-users-imports')
648
        return super(UserImportsView, self).post(request, *args, **kwargs)
649

  
650
    def form_valid(self, form):
651
        user_import = form.save()
652
        with user_import.meta_update as meta:
653
            meta['user'] = self.request.user.get_full_name()
654
            meta['user_pk'] = self.request.user.pk
655
        return redirect(self.request, 'a2-manager-users-import', kwargs={'uuid': user_import.uuid})
656

  
657
    def get_context_data(self, **kwargs):
658
        from . import user_import
659

  
660
        ctx = super(UserImportsView, self).get_context_data(**kwargs)
661
        ctx['imports'] = sorted(user_import.UserImport.all(), key=operator.attrgetter('created'), reverse=True)
662
        return ctx
663

  
664
user_imports = UserImportsView.as_view()
665

  
666

  
667
class UserImportView(MediaMixin, PermissionMixin, FormView):
668
    form_class = UserEditImportForm
669
    permissions = ['custom_user.admin_user']
670
    permissions_global = True
671
    template_name = 'authentic2/manager/user_import.html'
672

  
673
    def dispatch(self, request, uuid, **kwargs):
674
        from user_import import UserImport
675
        self.user_import = UserImport(uuid)
676
        if not self.user_import.exists():
677
            raise Http404
678
        return super(UserImportView, self).dispatch(request, uuid, **kwargs)
679

  
680
    def get(self, request, uuid, filename=None):
681
        if filename:
682
            return FileResponse(self.user_import.import_file, content_type='text/csv')
683
        return super(UserImportView, self).get(request, uuid=uuid, filename=filename)
684

  
685
    def get_form_kwargs(self):
686
        kwargs = super(UserImportView, self).get_form_kwargs()
687
        kwargs['user_import'] = self.user_import
688
        return kwargs
689

  
690
    def post(self, request, *args, **kwargs):
691
        from . import user_import
692

  
693
        if 'delete' in request.POST:
694
            uuid = request.POST['delete']
695
            try:
696
                report = self.user_import.reports[uuid]
697
            except KeyError:
698
                pass
699
            else:
700
                report.delete()
701
            return redirect(request, 'a2-manager-users-import', kwargs={'uuid': self.user_import.uuid})
702

  
703
        simulate = 'simulate' in request.POST
704
        execute = 'execute' in request.POST
705
        if simulate or execute:
706
            report = user_import.Report.new(self.user_import)
707
            report.run(simulate=simulate)
708
            return redirect(request, 'a2-manager-users-import', kwargs={'uuid': self.user_import.uuid})
709
        return super(UserImportView, self).post(request, *args, **kwargs)
710

  
711
    def form_valid(self, form):
712
        form.save()
713
        return super(UserImportView, self).form_valid(form)
714

  
715
    def get_success_url(self):
716
        return reverse('a2-manager-users-import', kwargs={'uuid': self.user_import.uuid})
717

  
718
    def get_context_data(self, **kwargs):
719
        ctx = super(UserImportView, self).get_context_data()
720
        ctx['user_import'] = self.user_import
721
        ctx['reports'] = sorted(self.user_import.reports, key=operator.attrgetter('created'), reverse=True)
722
        return ctx
723

  
724
user_import = UserImportView.as_view()
725

  
726

  
727
class UserImportReportView(MediaMixin, PermissionMixin, TemplateView):
728
    form_class = UserEditImportForm
729
    permissions = ['custom_user.admin_user']
730
    permissions_global = True
731
    template_name = 'authentic2/manager/user_import_report.html'
732

  
733
    def dispatch(self, request, import_uuid, report_uuid):
734
        from user_import import UserImport
735
        self.user_import = UserImport(import_uuid)
736
        if not self.user_import.exists():
737
            raise Http404
738
        try:
739
            self.report = self.user_import.reports[report_uuid]
740
        except KeyError:
741
            raise Http404
742
        return super(UserImportReportView, self).dispatch(request, import_uuid, report_uuid)
743

  
744
    def get_context_data(self, **kwargs):
745
        ctx = super(UserImportReportView, self).get_context_data()
746
        ctx['user_import'] = self.user_import
747
        ctx['report'] = self.report
748
        if self.report.simulate:
749
            ctx['report_title'] = _('Simulation')
750
        else:
751
            ctx['report_title'] = _('Execution')
752
        return ctx
753

  
754
user_import_report = UserImportReportView.as_view()
src/authentic2/manager/views.py
101 101
class PermissionMixin(object):
102 102
    '''Control access to views based on permissions'''
103 103
    permissions = None
104
    permissions_global = False
104 105

  
105 106
    def authorize(self, request, *args, **kwargs):
106 107
        if hasattr(self, 'model'):
......
130 131
                    and not request.user.has_perm_any(self.permissions):
131 132
                raise PermissionDenied
132 133
        else:
133
            if self.permissions \
134
                    and not request.user.has_perm_any(self.permissions):
135
                raise PermissionDenied
134
            if self.permissions:
135
                if self.permissions_global and not request.user.has_perms(self.permissions):
136
                    raise PermissionDenied
137
                if not self.permissions_global and not request.user.has_perm_any(self.permissions):
138
                    raise PermissionDenied
136 139

  
137 140
    def dispatch(self, request, *args, **kwargs):
138 141
        response = self.authorize(request, *args, **kwargs)
tests/test_manager_user_import.py
1
# -*- coding: utf-8 -*-
2
# authentic2 - versatile identity manager
3
# Copyright (C) 2010-2019 Entr'ouvert
4
#
5
# This program is free software: you can redistribute it and/or modify it
6
# under the terms of the GNU Affero General Public License as published
7
# by the Free Software Foundation, either version 3 of the License, or
8
# (at your option) any later version.
9
#
10
# This program is distributed in the hope that it will be useful,
11
# but WITHOUT ANY WARRANTY; without even the implied warranty of
12
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13
# GNU Affero General Public License for more details.
14
#
15
# You should have received a copy of the GNU Affero General Public License
16
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
17

  
18
from __future__ import unicode_literals
19

  
20
import io
21
import operator
22

  
23
import pytest
24

  
25
from authentic2.manager.user_import import UserImport, Report
26
from authentic2.models import Attribute
27

  
28
from utils import skipif_sqlite
29

  
30

  
31
@pytest.fixture
32
def profile(transactional_db):
33
    Attribute.objects.create(name='phone', kind='phone_number', label='Numéro de téléphone')
34

  
35

  
36
@skipif_sqlite
37
def test_user_import(media, transactional_db, profile):
38
    content = '''email key verified,first_name,last_name,phone no-create
39
tnoel@entrouvert.com,Thomas,Noël,1234
40
fpeters@entrouvert.com,Frédéric,Péters,5678
41
x,x,x,x'''
42
    fd = io.BytesIO(content.encode('utf-8'))
43

  
44
    assert len(list(UserImport.all())) == 0
45

  
46
    UserImport.new(fd, encoding='utf-8')
47
    UserImport.new(fd, encoding='utf-8')
48

  
49
    assert len(list(UserImport.all())) == 2
50
    for user_import in UserImport.all():
51
        with user_import.import_file as fd:
52
            assert fd.read() == content.encode('utf-8')
53

  
54
    for user_import in UserImport.all():
55
        report = Report.new(user_import)
56
        assert user_import.reports[report.uuid].exists()
57
        assert user_import.reports[report.uuid].data['encoding'] == 'utf-8'
58
        assert user_import.reports[report.uuid].data['state'] == 'waiting'
59

  
60
        t = report.run(start=False)
61

  
62
        assert user_import.reports[report.uuid].data['state'] == 'running'
63

  
64
        t.start()
65
        t.join()
66

  
67
        assert user_import.reports[report.uuid].data['state'] == 'finished'
68
        assert user_import.reports[report.uuid].data['importer']
69
        assert not user_import.reports[report.uuid].data['importer'].errors
70

  
71
    for user_import in UserImport.all():
72
        reports = list(user_import.reports)
73
        assert len(reports) == 1
74
        assert reports[0].created
75
        importer = reports[0].data['importer']
76
        assert importer.rows[0].is_valid
77
        assert importer.rows[1].is_valid
78
        assert not importer.rows[2].is_valid
79

  
80
    user_imports = sorted(UserImport.all(), key=operator.attrgetter('created'))
81
    user_import1 = user_imports[0]
82
    report1 = list(user_import1.reports)[0]
83
    importer = report1.data['importer']
84
    assert all(row.action == 'create' for row in importer.rows[:2])
85
    assert all(cell.action == 'updated' for row in importer.rows[:2] for cell in row.cells[:3])
86
    assert all(cell.action == 'nothing' for row in importer.rows[:2] for cell in row.cells[3:])
87

  
88
    user_import2 = user_imports[1]
89
    report2 = list(user_import2.reports)[0]
90
    importer = report2.data['importer']
91
    assert all(row.action == 'update' for row in importer.rows[:2])
92
    assert all(cell.action == 'nothing' for row in importer.rows[:2] for cell in row.cells[:3])
93
    assert all(cell.action == 'updated' for row in importer.rows[:2] for cell in row.cells[3:])
tests/test_user_manager.py
1
# -*- coding: utf-8 -*-
1 2
# authentic2 - versatile identity manager
2 3
# Copyright (C) 2010-2019 Entr'ouvert
3 4
#
......
14 15
# You should have received a copy of the GNU Affero General Public License
15 16
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
16 17
import csv
18
import time
19

  
20
from webtest import Upload
17 21

  
18 22
from django.core.urlresolvers import reverse
19 23

  
......
25 29
from authentic2.custom_user.models import User
26 30
from authentic2.models import Attribute, AttributeValue
27 31
from authentic2.a2_rbac.utils import get_default_ou
32
from authentic2.manager import user_import
28 33

  
29 34

  
30 35
from utils import login, get_link_from_mail, skipif_sqlite
......
210 215

  
211 216
    response = app.get('/manage/users/?search-ou=%s' % ou1.id)
212 217
    assert response.pyquery('td.username')
218

  
219

  
220
@skipif_sqlite
221
def test_user_import(transactional_db, app, admin, ou1, admin_ou1, media):
222
    Attribute.objects.create(name='phone', kind='phone_number', label='Numéro de téléphone')
223

  
224
    user_count = User.objects.count()
225

  
226
    assert Attribute.objects.count() == 3
227

  
228
    response = login(app, admin, '/manage/users/')
229

  
230
    response = response.click('Import users')
231
    response.form.set('import_file',
232
                      Upload(
233
                          'users.csv',
234
                          u'''email key verified,first_name,last_name,phone
235
tnoel@entrouvert.com,Thomas,Noël,1234
236
fpeters@entrouvert.com,Frédéric,Péters,5678
237
x,x,x,x'''.encode('utf-8'),
238
                          'application/octet-stream'))
239
    response.form.set('encoding', 'utf-8')
240
    response.form.set('ou', str(get_default_ou().pk))
241
    response = response.form.submit()
242

  
243
    imports = list(user_import.UserImport.all())
244
    assert len(imports) == 1
245
    _import_uuid = response.location.split('/')[-2]
246
    _import = user_import.UserImport(uuid=_import_uuid)
247
    assert _import.exists()
248

  
249
    response = response.follow()
250
    response = response.forms['action-form'].submit(name='simulate')
251

  
252
    reports = list(_import.reports)
253
    assert len(reports) == 1
254
    uuid = reports[0].uuid
255

  
256
    response = response.follow()
257

  
258
    def assert_timeout(duration, wait_function):
259
        start = time.time()
260
        while True:
261
            result = wait_function()
262
            if result is not None:
263
                return result
264
            assert time.time() - start < duration, '%s timed out after %s seconds' % (wait_function, duration)
265
            time.sleep(0.001)
266

  
267
    def wait_finished():
268
        new_resp = response.click('User Import')
269
        if new_resp.pyquery('tr[data-uuid="%s"] td.state span' % uuid).text() == 'finished':
270
            return new_resp
271

  
272
    simulate = reports[0]
273
    assert simulate.simulate
274

  
275
    response = assert_timeout(20, wait_finished)
276

  
277
    response = response.click(href=simulate.uuid)
278

  
279
    assert len(response.pyquery('table.main tbody tr')) == 3
280
    assert len(response.pyquery('table.main tbody tr.row-valid')) == 2
281
    assert len(response.pyquery('table.main tbody tr.row-invalid')) == 1
282

  
283
    assert User.objects.count() == user_count
284

  
285
    response = response.click('User Import')
286
    response = response.forms['action-form'].submit(name='execute')
287

  
288
    execute = list(report for report in _import.reports if not report.simulate)[0]
289
    uuid = execute.uuid
290

  
291
    response = response.follow()
292
    response = assert_timeout(20, wait_finished)
293

  
294
    assert User.objects.count() == user_count + 2
295
    assert User.objects.filter(
296
        email='tnoel@entrouvert.com',
297
        first_name=u'Thomas',
298
        last_name=u'Noël',
299
        attribute_values__content='1234').count() == 1
300
    assert User.objects.filter(
301
        email='fpeters@entrouvert.com',
302
        first_name=u'Frédéric',
303
        last_name=u'Péters',
304
        attribute_values__content='5678').count() == 1
305

  
306
    # logout
307
    app.session.flush()
308
    response = login(app, admin_ou1, '/manage/users/')
309

  
310
    app.get('/manage/users/import/', status=403)
311
    app.get('/manage/users/import/%s/' % _import.uuid, status=403)
312
    app.get('/manage/users/import/%s/%s/' % (_import.uuid, simulate.uuid), status=403)
313
    app.get('/manage/users/import/%s/%s/' % (_import.uuid, execute.uuid), status=403)
213
-