Projet

Général

Profil

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

Valentin Deniaud, 31 mars 2021 16:24

Télécharger (19,9 ko)

Voir les différences:

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

 src/authentic2/custom_user/managers.py        |  14 ++--
 .../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                |  10 +++
 src/authentic2/manager/user_export.py         |  74 +++++++++++++++++
 src/authentic2/manager/user_views.py          |  77 ++++++++++++++----
 src/authentic2/utils/__init__.py              |   4 +-
 src/authentic2/utils/spooler.py               |   7 ++
 tests/test_user_manager.py                    |  35 ++++++--
 10 files changed, 242 insertions(+), 29 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
src/authentic2/custom_user/managers.py
44 44
            return wrap_qs(self.none())
45 45

  
46 46
        if '@' in search and len(search.split()) == 1:
47
            with connection.cursor() as cursor:
48
                cursor.execute("SET pg_trgm.similarity_threshold = %f" % app_settings.A2_FTS_THRESHOLD)
47
            self.set_trigram_similarity_threshold()
49 48
            qs = self.filter(email__icontains=search).order_by(Unaccent('last_name'), Unaccent('first_name'))
50 49
            if qs.exists():
51 50
                return wrap_qs(qs)
......
109 108
    def find_duplicates(
110 109
        self, first_name=None, last_name=None, fullname=None, birthdate=None, limit=5, threshold=None
111 110
    ):
112
        with connection.cursor() as cursor:
113
            cursor.execute(
114
                "SET pg_trgm.similarity_threshold = %f" % (threshold or app_settings.A2_DUPLICATES_THRESHOLD)
115
            )
111
        self.set_trigram_similarity_threshold(threshold=threshold or app_settings.A2_DUPLICATES_THRESHOLD)
116 112

  
117 113
        if fullname is not None:
118 114
            name = fullname
......
146 142

  
147 143
        return qs
148 144

  
145
    def set_trigram_similarity_threshold(self, threshold=None):
146
        with connection.cursor() as cursor:
147
            cursor.execute(
148
                "SET pg_trgm.similarity_threshold = %f" % (threshold or app_settings.A2_FTS_THRESHOLD)
149
            )
150

  
149 151

  
150 152
class UserManager(BaseUserManager):
151 153
    def _create_user(self, username, email, password, is_staff, is_superuser, **extra_fields):
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
        # Authentic2 users
39 39
        url(r'^users/$', user_views.users, name='a2-manager-users'),
40 40
        url(r'^users/export/(?P<format>csv)/$', user_views.users_export, name='a2-manager-users-export'),
41
        url(
42
            r'^users/export/(?P<uuid>[a-z0-9-]+)/progress/$',
43
            user_views.users_export_progress,
44
            name='a2-manager-users-export-progress',
45
        ),
46
        url(
47
            r'^users/export/(?P<uuid>[a-z0-9-]+)/$',
48
            user_views.users_export_file,
49
            name='a2-manager-users-export-file',
50
        ),
41 51
        url(r'^users/add/$', user_views.user_add_default_ou, name='a2-manager-user-add-default-ou'),
42 52
        url(r'^users/add/choose-ou/$', user_views.user_add_choose_ou, name='a2-manager-user-add-choose-ou'),
43 53
        url(r'^users/import/$', user_views.user_imports, name='a2-manager-users-imports'),
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
from django.contrib.auth import get_user_model
22 25
from django.contrib.contenttypes.models import ContentType
26
from django.core.files.storage import default_storage
23 27

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

  
27 32

  
28 33
def get_user_dataset(qs):
......
72 77
    for user in qs:
73 78
        dataset.append(create_record(user))
74 79
    return dataset
80

  
81

  
82
class UserExport(object):
83
    def __init__(self, uuid):
84
        self.uuid = uuid
85
        self.path = os.path.join(self.base_path(), self.uuid)
86
        self.export_path = os.path.join(self.path, 'export.csv')
87
        self.progress_path = os.path.join(self.path, 'progress')
88

  
89
    @classmethod
90
    def base_path(self):
91
        path = default_storage.path('user_exports')
92
        if not os.path.exists(path):
93
            os.makedirs(path)
94
        return path
95

  
96
    @property
97
    def exists(self):
98
        return os.path.exists(self.path)
99

  
100
    @classmethod
101
    def new(cls):
102
        export = cls(str(uuid.uuid4()))
103
        os.makedirs(export.path)
104
        return export
105

  
106
    @property
107
    def csv(self):
108
        return open(self.export_path, 'r')
109

  
110
    def set_export_content(self, content):
111
        with open(self.export_path, 'w') as f:
112
            f.write(content)
113

  
114
    @property
115
    def progress(self):
116
        progress = 0
117
        if os.path.exists(self.progress_path):
118
            with open(self.progress_path, 'r') as f:
119
                progress = f.read()
120
        return int(progress) if progress else 0
121

  
122
    def set_progress(self, progress):
123
        with open(self.progress_path, 'w') as f:
124
            f.write(str(progress))
125

  
126

  
127
def export_users_to_file(uuid, query):
128
    export = UserExport(uuid)
129
    qs = get_user_model().objects.all()
130
    qs.set_trigram_similarity_threshold()
131
    qs.query = query
132
    qs = qs.select_related('ou')
133
    qs = qs.prefetch_related('roles', 'roles__parent_relation__parent')
134
    count = qs.count() or 1
135

  
136
    def callback(progress):
137
        export.set_progress(round(progress / count * 100))
138

  
139
    qs = batch_queryset(qs, progress_callback=callback)
140
    dataset = get_user_dataset(qs)
141

  
142
    if hasattr(dataset, 'csv'):
143
        # compatiblity for tablib < 0.11
144
        csv = dataset.csv
145
    else:
146
        csv = dataset.export('csv')
147
    export.set_export_content(csv)
148
    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 23
from django.contrib import messages
22 24
from django.contrib.auth import REDIRECT_FIELD_NAME, get_user_model
23 25
from django.core.exceptions import PermissionDenied
24 26
from django.core.mail import EmailMultiAlternatives
25
from django.db import models, transaction
26
from django.http import FileResponse, Http404
27
from django.db import connection, models, transaction
28
from django.http import FileResponse, Http404, HttpResponse
27 29
from django.shortcuts import get_object_or_404
28 30
from django.template import loader
29 31
from django.urls import reverse, reverse_lazy
32
from django.utils import timezone
30 33
from django.utils.functional import cached_property
31 34
from django.utils.html import format_html
32 35
from django.utils.translation import pgettext_lazy, ugettext
33 36
from django.utils.translation import ugettext_lazy as _
34
from django.views.generic import DetailView, FormView, TemplateView
37
from django.views.generic import DetailView, FormView, TemplateView, View
35 38
from django.views.generic.detail import SingleObjectMixin
36 39
from django.views.generic.edit import BaseFormView
37 40

  
......
39 42
from authentic2.a2_rbac.utils import get_default_ou
40 43
from authentic2.apps.journal.views import JournalViewWithContext
41 44
from authentic2.models import Attribute, PasswordReset
42
from authentic2.utils import make_url, redirect, select_next_url, send_password_reset_mail, switch_user
45
from authentic2.utils import (
46
    make_url,
47
    redirect,
48
    select_next_url,
49
    send_password_reset_mail,
50
    spooler,
51
    switch_user,
52
)
43 53
from authentic2_idp_oidc.models import OIDCAuthorization, OIDCClient
44 54
from django_rbac.utils import get_ou_model, get_role_model, get_role_parenting_model
45 55

  
......
60 70
from .journal_views import BaseJournalView
61 71
from .resources import UserResource
62 72
from .tables import OuUserRolesTable, UserAuthorizationsTable, UserRolesTable, UserTable
63
from .user_export import get_user_dataset
73
from .user_export import UserExport, get_user_dataset
64 74
from .utils import get_ou_count, has_show_username
65 75
from .views import (
66 76
    Action,
......
501 511
user_edit = UserEditView.as_view()
502 512

  
503 513

  
504
class UsersExportView(ExportMixin, UsersView):
514
class UsersExportView(UsersView):
505 515
    permissions = ['custom_user.view_user']
506
    resource_class = UserResource
507 516
    export_prefix = 'users-'
508 517

  
509
    @property
510
    def csv(self):
511
        if hasattr(self._dataset, 'csv'):
512
            # compatiblity for tablib < 0.11
513
            return self._dataset.csv
514
        return self._dataset.export('csv')
515

  
516
    def get_dataset(self):
517
        self._dataset = get_user_dataset(self.get_data())
518
        return self
518
    def get(self, request, *args, **kwargs):
519
        export = UserExport.new()
520
        query = self.get_table_data().query
521
        transaction.on_commit(lambda: spooler.export_users(uuid=export.uuid, query=query))
522
        return redirect(request, 'a2-manager-users-export-progress', kwargs={'uuid': export.uuid})
519 523

  
520 524

  
521 525
users_export = UsersExportView.as_view()
522 526

  
523 527

  
528
class UsersExportFileView(ExportMixin, PermissionMixin, View):
529
    permissions = ['custom_user.view_user']
530

  
531
    def get(self, request, *args, **kwargs):
532
        self.export = UserExport(kwargs.get('uuid'))
533
        if not self.export.exists:
534
            raise Http404()
535
        response = HttpResponse(self.export.csv, content_type='text/csv')
536
        filename = 'users-%s.csv' % timezone.now().strftime('%Y%m%d_%H%M%S')
537
        response['Content-Disposition'] = 'attachment; filename="%s"' % filename
538
        return response
539

  
540

  
541
users_export_file = UsersExportFileView.as_view()
542

  
543

  
544
class UsersExportProgressView(MediaMixin, TemplateView):
545
    template_name = 'authentic2/manager/user_export.html'
546

  
547
    def get(self, request, *args, **kwargs):
548
        self.uuid = kwargs.get('uuid')
549
        export = UserExport(self.uuid)
550
        if not export.exists:
551
            raise Http404()
552

  
553
        if request.is_ajax():
554
            return HttpResponse(export.progress)
555

  
556
        return super().get(request, *args, **kwargs)
557

  
558
    def get_context_data(self, **kwargs):
559
        ctx = super().get_context_data(**kwargs)
560
        ctx['uuid'] = self.uuid
561
        return ctx
562

  
563

  
564
users_export_progress = UsersExportProgressView.as_view()
565

  
566

  
524 567
class UserChangePasswordView(BaseEditView):
525 568
    template_name = 'authentic2/manager/form.html'
526 569
    model = get_user_model()
src/authentic2/utils/__init__.py
963 963
        yield chain([batchiter.next()], batchiter)
964 964

  
965 965

  
966
def batch_queryset(qs, size=1000):
966
def batch_queryset(qs, size=1000, progress_callback=None):
967 967
    """Batch prefetched potentially very large queryset, it's a middle ground
968 968
    between using .iterator() which cannot be prefetched and prefetching a full
969 969
    table, which can take a larte place in memory.
970 970
    """
971 971
    for i in count(0):
972
        if progress_callback:
973
            progress_callback(i * size)
972 974
        chunk = qs[i * size : (i + 1) * size]
973 975
        if not chunk:
974 976
            break
src/authentic2/utils/spooler.py
84 84
        return base_spooler(*args, **kwargs)
85 85

  
86 86
    return spooler
87

  
88

  
89
@tenantspool
90
def export_users(uuid, query):
91
    from authentic2.manager.user_export import export_users_to_file
92

  
93
    export_users_to_file(uuid, query)
tests/test_user_manager.py
309 309
    assert visible_users(response) == set()
310 310

  
311 311

  
312
def test_export_csv(settings, app, superuser, django_assert_num_queries):
312
def test_export_csv(settings, app, superuser, django_assert_num_queries, transactional_db):
313 313
    AT_COUNT = 30
314 314
    USER_COUNT = 2000
315 315
    DEFAULT_BATCH_SIZE = 1000
......
341 341
    num_queries = int(4 + 4 * (user_count / DEFAULT_BATCH_SIZE + bool(user_count % DEFAULT_BATCH_SIZE)))
342 342
    with django_assert_num_queries(num_queries):
343 343
        response = response.click('CSV')
344

  
345
    url = response.url
346
    response = response.follow()
347
    assert 'Preparing CSV export file...' in response.text
348
    assert '<span id="progress">0</span>' in response.text
349

  
350
    response = response.click('Download CSV')
344 351
    table = list(csv.reader(response.text.splitlines()))
345 352
    assert len(table) == (user_count + 1)
346 353
    assert len(table[0]) == (15 + AT_COUNT)
347 354

  
355
    # ajax call returns 100% progress
356
    resp = app.get(url, xhr=True)
357
    assert resp.text == '100'
358

  
359

  
360
def test_export_csv_search(settings, app, superuser, transactional_db):
361
    users = [User(username='user%s' % i) for i in range(10)]
362
    User.objects.bulk_create(users)
363

  
364
    response = login(app, superuser)
365
    resp = app.get('/manage/users/?search-text=user1')
366
    resp = resp.click('CSV').follow()
367
    resp = resp.click('Download CSV')
368
    table = list(csv.reader(resp.text.splitlines()))
369
    assert len(table) == 3  # user1 and superuser match
370

  
348 371

  
349
def test_export_csv_disabled_attribute(settings, app, superuser):
372
def test_export_csv_disabled_attribute(settings, app, superuser, transactional_db):
350 373
    attr = Attribute.objects.create(name='attr', label='Attr', kind='string')
351 374
    attr_d = Attribute.objects.create(name='attrd', label='Attrd', kind='string')
352 375

  
......
359 382

  
360 383
    response = login(app, superuser, reverse('a2-manager-users'))
361 384
    settings.A2_CACHE_ENABLED = True
362
    response = response.click('CSV')
385
    response = response.click('CSV').follow()
386
    response = response.click('Download CSV')
363 387

  
364 388
    user_count = User.objects.count()
365 389
    table = list(csv.reader(response.text.splitlines()))
......
370 394
        assert len(line) == num_col
371 395

  
372 396

  
373
def test_export_csv_user_delete(settings, app, superuser):
397
def test_export_csv_user_delete(settings, app, superuser, transactional_db):
374 398
    for i in range(10):
375 399
        User.objects.create(username='user-%s' % i)
376 400

  
......
380 404

  
381 405
    response = login(app, superuser, reverse('a2-manager-users'))
382 406
    settings.A2_CACHE_ENABLED = True
383
    response = response.click('CSV')
407
    response = response.click('CSV').follow()
408
    response = response.click('Download CSV')
384 409
    table = list(csv.reader(response.text.splitlines()))
385 410
    # superuser + ten created users + csv header - three users marked as deteled
386 411
    assert len(table) == (1 + 10 + 1 - 3)
387
-