Projet

Général

Profil

0003-manager-export-users-asynchronously-43153.patch

Valentin Deniaud, 10 mars 2021 14:35

Télécharger (17,4 ko)

Voir les différences:

Subject: [PATCH 3/3] manager: export users asynchronously (#43153)

 .../authentic2/manager/css/indicator.gif      | Bin 0 -> 1553 bytes
 .../static/authentic2/manager/css/style.css   |   5 ++
 .../authentic2/manager/user_export.html       |  45 +++++++++++
 src/authentic2/manager/urls.py                |   4 +
 src/authentic2/manager/user_export.py         |  71 ++++++++++++++++++
 src/authentic2/manager/user_views.py          |  63 +++++++++++++++-
 src/authentic2/utils/__init__.py              |   4 +-
 src/authentic2/utils/spooler.py               |  37 +++++++++
 tests/test_user_manager.py                    |  52 +++++++++++++
 9 files changed, 276 insertions(+), 5 deletions(-)
 create mode 100644 src/authentic2/manager/static/authentic2/manager/css/indicator.gif
 create mode 100644 src/authentic2/manager/templates/authentic2/manager/user_export.html
 create mode 100644 src/authentic2/utils/spooler.py
src/authentic2/manager/static/authentic2/manager/css/style.css
264 264
.journal-list--timestamp-column {
265 265
	white-space: pre;
266 266
}
267

  
268
span.activity {
269
	background: url(indicator.gif) no-repeat top right;
270
	padding-right: 30px;
271
}
src/authentic2/manager/templates/authentic2/manager/user_export.html
1
{% extends "authentic2/manager/base.html" %}
2
{% load i18n gadjo staticfiles %}
3

  
4
{% block page-title %}{{ block.super }} - {% trans "User export" %}{% endblock %}
5

  
6
{% block appbar %}
7
<h2>Users export</h2>
8
{% endblock %}
9

  
10
{% block breadcrumb %}
11
  {{ block.super }}
12
  <a href="{% url 'a2-manager-users' %}">{% trans 'Users' %}</a>
13
  <a href="{% url 'a2-manager-users-export-progress' uuid=uuid %}"></a>
14
{% endblock %}
15

  
16
{% block main %}
17
<div class="section">
18
  <div class="running">
19
    <p>{% trans "Preparing CSV export file..." %}</p>
20
    <span class="activity">{% trans "Progress:" %} <span id="progress">0</span>%</span>
21
  </div>
22
  <div class="done">
23
    <p>{% trans "Export completed." %}</p>
24
    <p><a class="button" href="{% url 'a2-manager-users-export-file' uuid=uuid %}">{% trans "Download CSV" %}</a></p>
25
  </div>
26
</div>
27
<script>
28
function updateStatus() {
29
  $('div.done').hide();
30
  $.get('{% url 'a2-manager-users-export-progress' uuid=uuid %}', null,
31
    function (text) {
32
      if(text != 100) {
33
        $('span#progress').text(text);
34
        window.setTimeout(updateStatus, 2500);
35
      } else {
36
        $('div.running').hide();
37
        $('div.done').show();
38
      }
39
    }
40
  );
41
}
42

  
43
$(document).ready(updateStatus);
44
</script>
45
{% endblock %}
src/authentic2/manager/urls.py
38 38
        url(r'^users/$', user_views.users, name='a2-manager-users'),
39 39
        url(r'^users/export/(?P<format>csv)/$',
40 40
            user_views.users_export, name='a2-manager-users-export'),
41
        url(r'^users/export-progress/(?P<uuid>[a-z0-9-]+)/$',
42
            user_views.users_export_progress, name='a2-manager-users-export-progress'),
43
        url(r'^users/export/(?P<uuid>[a-z0-9-]+)/$',
44
            user_views.users_export_file, name='a2-manager-users-export-file'),
41 45
        url(r'^users/add/$', user_views.user_add_default_ou,
42 46
            name='a2-manager-user-add-default-ou'),
43 47
        url(r'^users/add/choose-ou/$', user_views.user_add_choose_ou,
src/authentic2/manager/user_export.py
16 16

  
17 17
import collections
18 18
import datetime
19
import os
20
import pickle
21
import uuid
19 22

  
20 23
import tablib
21 24

  
22 25
from django.contrib.auth import get_user_model
23 26
from django.contrib.contenttypes.models import ContentType
27
from django.core.files.storage import default_storage
24 28

  
25 29
from authentic2.manager.resources import UserResource
26 30
from authentic2.models import Attribute, AttributeValue
31
from authentic2.utils import batch_queryset
27 32

  
28 33

  
29 34
def iso(rec):
......
78 83
        dataset.append(create_record(user))
79 84

  
80 85
    return dataset
86

  
87

  
88
class UserExport(object):
89
    def __init__(self, uuid):
90
        self.uuid = uuid
91
        self.path = os.path.join(self.base_path(), self.uuid)
92
        self.export_path = os.path.join(self.path, 'export.csv')
93
        self.progress_path = os.path.join(self.path, 'progress')
94

  
95
    @classmethod
96
    def base_path(self):
97
        path = default_storage.path('user_exports')
98
        if not os.path.exists(path):
99
            os.makedirs(path)
100
        return path
101

  
102
    @property
103
    def exists(self):
104
        return os.path.exists(self.path)
105

  
106
    @classmethod
107
    def new(cls):
108
        export = cls(str(uuid.uuid4()))
109
        os.makedirs(export.path)
110
        return export
111

  
112
    @property
113
    def csv(self):
114
        return open(self.export_path, 'r')
115

  
116
    def set_export_content(self, content):
117
        with open(self.export_path, 'w') as f:
118
            f.write(content)
119

  
120
    @property
121
    def progress(self):
122
        progress = 0
123
        if os.path.exists(self.progress_path):
124
            with open(self.progress_path, 'r') as f:
125
                progress = f.read()
126
        return int(progress) if progress else 0
127

  
128
    def set_progress(self, progress):
129
        with open(self.progress_path, 'w') as f:
130
            f.write(str(progress))
131

  
132

  
133
def export_users_to_file(uuid, query):
134
    export = UserExport(uuid)
135
    qs = get_user_model().objects.all()
136
    qs.query = pickle.loads(query.encode('latin-1'))
137
    count = qs.count() or 1
138

  
139
    def callback(progress):
140
        export.set_progress(round(progress / count * 100))
141

  
142
    qs = batch_queryset(qs, progress_callback=callback)
143
    dataset = get_user_dataset(qs)
144

  
145
    if hasattr(dataset, 'csv'):
146
        # compatiblity for tablib < 0.11
147
        csv = dataset.csv
148
    else:
149
        csv = dataset.export('csv')
150
    export.set_export_content(csv)
151
    export.set_progress(100)
src/authentic2/manager/user_views.py
17 17
import base64
18 18
import collections
19 19
import operator
20
import pickle
21
import sys
20 22

  
21
from django.db import models, transaction
23
from django.db import models, transaction, connection
22 24
from django.utils.functional import cached_property
23 25
from django.utils.translation import ugettext_lazy as _, pgettext_lazy, ugettext
24 26
from django.utils.html import format_html
......
28 30
from django.template import loader
29 31
from django.contrib.auth import get_user_model, REDIRECT_FIELD_NAME
30 32
from django.contrib import messages
31
from django.views.generic import FormView, TemplateView, DetailView
33
from django.views.generic import FormView, TemplateView, DetailView, View
32 34
from django.views.generic.edit import BaseFormView
33 35
from django.views.generic.detail import SingleObjectMixin
34
from django.http import Http404, FileResponse, HttpResponseRedirect
36
from django.http import Http404, FileResponse, HttpResponseRedirect, HttpResponse
35 37
from django.shortcuts import get_object_or_404
36 38

  
37 39
from authentic2.models import Attribute, PasswordReset
......
55 57
from .resources import UserResource
56 58
from .utils import get_ou_count, has_show_username
57 59
from .journal_views import BaseJournalView
58
from .user_export import get_user_dataset
60
from .user_export import get_user_dataset, UserExport
59 61
from . import app_settings
60 62

  
61 63
User = get_user_model()
......
481 483
            return self._dataset.csv
482 484
        return self._dataset.export('csv')
483 485

  
486
    def get(self, request, *args, **kwargs):
487
        if 'uwsgi' in sys.modules:
488
            from authentic2.utils.spooler import export_users
489

  
490
            export = UserExport.new()
491
            query = pickle.dumps(self.get_table_data().query)
492
            # workaround django-uwsgi encoding issue https://github.com/unbit/django-uwsgi/issues/10
493
            query = query.decode('latin1').encode('utf-8')
494
            tenant = getattr(connection, 'tenant', None)
495
            export_users.spool(domain=getattr(tenant, 'domain_url', None), uuid=export.uuid, query=query)
496
            return redirect(request, 'a2-manager-users-export-progress', kwargs={'uuid': export.uuid})
497
        else:
498
            return super().get(request, *args, **kwargs)
499

  
484 500
    def get_dataset(self):
485 501
        self._dataset = get_user_dataset(self.get_data())
486 502
        return self
......
489 505
users_export = UsersExportView.as_view()
490 506

  
491 507

  
508
class UsersExportFileView(ExportMixin, PermissionMixin, View):
509
    permissions = ['custom_user.view_user']
510

  
511
    def get(self, request, *args, **kwargs):
512
        self.export = UserExport(kwargs.get('uuid'))
513
        if not self.export.exists:
514
            raise Http404()
515
        return super().get(request, *args, format='csv', **kwargs)
516

  
517
    def get_dataset(self):
518
        return self.export
519

  
520

  
521
users_export_file = UsersExportFileView.as_view()
522

  
523

  
524
class UsersExportProgressView(MediaMixin, TemplateView):
525
    template_name = 'authentic2/manager/user_export.html'
526

  
527
    def get(self, request, *args, **kwargs):
528
        self.uuid = kwargs.get('uuid')
529
        export = UserExport(self.uuid)
530
        if not export.exists:
531
            raise Http404()
532

  
533
        if request.is_ajax():
534
            return HttpResponse(export.progress)
535

  
536
        return super().get(request, *args, **kwargs)
537

  
538
    def get_context_data(self, **kwargs):
539
        ctx = super().get_context_data(**kwargs)
540
        ctx['uuid'] = self.uuid
541
        return ctx
542

  
543

  
544
users_export_progress = UsersExportProgressView.as_view()
545

  
546

  
492 547
class UserChangePasswordView(BaseEditView):
493 548
    template_name = 'authentic2/manager/form.html'
494 549
    model = get_user_model()
src/authentic2/utils/__init__.py
879 879
        yield chain([batchiter.next()], batchiter)
880 880

  
881 881

  
882
def batch_queryset(qs, size=1000):
882
def batch_queryset(qs, size=1000, progress_callback=None):
883 883
    '''Batch prefetched potentially very large queryset, it's a middle ground
884 884
       between using .iterator() which cannot be prefetched and prefetching a full
885 885
       table, which can take a larte place in memory.
886 886
    '''
887 887
    for i in count(0):
888
        if progress_callback:
889
            progress_callback(i * size)
888 890
        chunk = qs[i * size:(i + 1) * size]
889 891
        if not chunk:
890 892
            break
src/authentic2/utils/spooler.py
1
# authentic2 - versatile identity manager
2
# Copyright (C) 2010-2021 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 uwsgidecorators import spool
18

  
19
from django.db import connection
20

  
21
from authentic2.manager.user_export import export_users_to_file
22

  
23

  
24
def set_connection(domain):
25
    from hobo.multitenant.middleware import TenantMiddleware
26

  
27
    tenant = TenantMiddleware.get_tenant_by_hostname(domain)
28
    connection.set_tenant(tenant)
29

  
30

  
31
@spool
32
def export_users(args):
33
    if args.get('domain'):
34
        # multitenant installation
35
        set_connection(args['domain'])
36

  
37
    export_users_to_file(args['uuid'], args['query'])
tests/test_user_manager.py
19 19

  
20 20
import csv
21 21
import datetime
22
import mock
22 23
import re
24
import sys
23 25
import time
24 26
from urllib.parse import urlparse
25 27

  
......
53 55
OU = get_ou_model()
54 56

  
55 57

  
58
@pytest.fixture
59
def mock_uwsgi():
60
    from authentic2.manager.user_export import export_users_to_file
61

  
62
    def run_now(f):
63
        def spool(**kwargs):
64
            for k, v in kwargs.items():
65
                # workaround django-uwsgi encoding issue https://github.com/unbit/django-uwsgi/issues/10
66
                if isinstance(v, bytes):
67
                    kwargs[k] = v.decode('utf-8')
68
            f(kwargs)
69

  
70
        f.spool = spool
71
        return f
72

  
73
    sys.modules['uwsgi'] = mock.MagicMock()
74
    uwsgidecorators = mock.MagicMock()
75
    uwsgidecorators.spool = run_now
76
    with mock.patch.dict('sys.modules', uwsgidecorators=uwsgidecorators):
77
        yield
78
    del sys.modules['uwsgi']
79

  
80

  
56 81
def visible_users(response):
57 82
    return set(elt.text for elt in response.pyquery('td.username'))
58 83

  
......
423 448
    assert response.pyquery('td.username')
424 449

  
425 450

  
451
def test_export_csv_async(settings, app, superuser, mock_uwsgi):
452
    users = [User(username='user%s' % i) for i in range(10)]
453
    User.objects.bulk_create(users)
454

  
455
    resp = login(app, superuser, reverse('a2-manager-users'))
456
    resp = resp.click('CSV')
457
    url = resp.url
458
    resp = resp.follow()
459
    assert 'Preparing CSV export file...' in resp.text
460
    assert '<span id="progress">0</span>' in resp.text
461

  
462
    # spooler mock is in fact synchronous, csv file is already there
463
    resp = resp.click('Download CSV')
464
    table = list(csv.reader(resp.text.splitlines()))
465
    assert len(table) == User.objects.count() + 1
466

  
467
    # ajax call returns 100% progress
468
    resp = app.get(url, xhr=True)
469
    assert resp.text == '100'
470

  
471
    resp = app.get('/manage/users/?search-text=user1')
472
    resp = resp.click('CSV').follow()
473
    resp = resp.click('Download CSV')
474
    table = list(csv.reader(resp.text.splitlines()))
475
    assert len(table) == 3  # user1 and superuser match
476

  
477

  
426 478
@pytest.mark.parametrize('encoding', ['utf-8-sig', 'cp1252', 'iso-8859-15'])
427 479
def test_user_import(encoding, transactional_db, app, admin, ou1, admin_ou1):
428 480
    Attribute.objects.create(name='phone', kind='phone_number', label='Numéro de téléphone')
429
-