Projet

Général

Profil

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

Benjamin Dauvergne, 21 juin 2019 18:57

Télécharger (42,6 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               |  72 ++++++
 .../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                    | 106 ++++++++
 14 files changed, 962 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', _('Unicode')),
723
    ('cp1252', _('Western Europe (Windows-1252)')),
724
    ('iso-8859-15', _('Western Europe (ISO-8859-15)')),
725
]
726

  
727

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

  
739

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

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

  
753
    def save(self):
754
        from . import user_import
755

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

  
767

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

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

  
786
    def save(self):
787
        with self.user_import.meta_update as meta:
788
            meta['ou'] = self.cleaned_data['ou']
789
            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
import pytest
21
from webtest import Upload
17 22

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

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

  
29 35

  
30 36
from utils import login, get_link_from_mail, skipif_sqlite
......
210 216

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

  
220

  
221
@skipif_sqlite
222
@pytest.mark.parametrize('encoding', ['utf-8', 'cp1252', 'iso-8859-15'])
223
def test_user_import(encoding, transactional_db, app, admin, ou1, admin_ou1, media):
224
    Attribute.objects.create(name='phone', kind='phone_number', label='Numéro de téléphone')
225

  
226
    user_count = User.objects.count()
227

  
228
    assert Attribute.objects.count() == 3
229

  
230
    response = login(app, admin, '/manage/users/')
231

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

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

  
251
    response = response.follow()
252

  
253
    response = response.forms['action-form'].submit(name='modify').follow()
254

  
255
    response = response.forms['action-form'].submit(name='simulate')
256

  
257
    reports = list(_import.reports)
258
    assert len(reports) == 1
259
    uuid = reports[0].uuid
260

  
261
    response = response.follow()
262

  
263
    def assert_timeout(duration, wait_function):
264
        start = time.time()
265
        while True:
266
            result = wait_function()
267
            if result is not None:
268
                return result
269
            assert time.time() - start < duration, '%s timed out after %s seconds' % (wait_function, duration)
270
            time.sleep(0.001)
271

  
272
    def wait_finished():
273
        new_resp = response.click('User Import')
274
        if new_resp.pyquery('tr[data-uuid="%s"] td.state span' % uuid).text() == 'finished':
275
            return new_resp
276

  
277
    simulate = reports[0]
278
    assert simulate.simulate
279

  
280
    response = assert_timeout(2, wait_finished)
281

  
282
    response = response.click(href=simulate.uuid)
283

  
284
    assert len(response.pyquery('table.main tbody tr')) == 3
285
    assert len(response.pyquery('table.main tbody tr.row-valid')) == 2
286
    assert len(response.pyquery('table.main tbody tr.row-invalid')) == 1
287

  
288
    assert User.objects.count() == user_count
289

  
290
    response = response.click('User Import')
291
    response = response.forms['action-form'].submit(name='execute')
292

  
293
    execute = list(report for report in _import.reports if not report.simulate)[0]
294
    uuid = execute.uuid
295

  
296
    response = response.follow()
297
    response = assert_timeout(2, wait_finished)
298

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

  
311
    # logout
312
    app.session.flush()
313
    response = login(app, admin_ou1, '/manage/users/')
314

  
315
    app.get('/manage/users/import/', status=403)
316
    app.get('/manage/users/import/%s/' % _import.uuid, status=403)
317
    app.get('/manage/users/import/%s/%s/' % (_import.uuid, simulate.uuid), status=403)
318
    app.get('/manage/users/import/%s/%s/' % (_import.uuid, execute.uuid), status=403)
213
-