Projet

Général

Profil

0001-profile_views-address-autocomplete-field-41919.patch

Lauréline Guérin, 08 octobre 2020 11:57

Télécharger (23,1 ko)

Voir les différences:

Subject: [PATCH] profile_views: address autocomplete field (#41919)

 src/authentic2/api_urls.py                    |  1 +
 src/authentic2/api_views.py                   | 23 +++++++
 src/authentic2/attribute_kinds.py             | 29 +++++++++
 .../static/authentic2/manager/css/style.css   |  3 +-
 src/authentic2/manager/user_views.py          |  2 +
 .../authentic2/js/address_autocomplete.js     | 60 +++++++++++++++++++
 .../widgets/address_autocomplete.html         |  5 ++
 src/authentic2/views.py                       |  3 +-
 tests/test_api.py                             | 47 +++++++++++++++
 tests/test_attribute_kinds.py                 | 10 ++--
 tests/test_profile.py                         | 56 ++++++++---------
 tests/test_registration.py                    | 12 ++--
 tests/test_user_manager.py                    | 23 +++++++
 13 files changed, 232 insertions(+), 42 deletions(-)
 create mode 100644 src/authentic2/static/authentic2/js/address_autocomplete.js
 create mode 100644 src/authentic2/templates/authentic2/widgets/address_autocomplete.html
src/authentic2/api_urls.py
28 28
        name='a2-api-role-members'),
29 29
    url(r'^check-password/$', api_views.check_password, name='a2-api-check-password'),
30 30
    url(r'^validate-password/$', api_views.validate_password, name='a2-api-validate-password'),
31
    url(r'^address-autocomplete/$', api_views.address_autocomplete, name='a2-api-address-autocomplete'),
31 32
]
32 33

  
33 34
urlpatterns += api_views.router.urls
src/authentic2/api_views.py
21 21
from pytz.exceptions import AmbiguousTimeError
22 22
import django
23 23
from django.db import models
24
from django.conf import settings
24 25
from django.contrib.auth import get_user_model
25 26
from django.contrib.auth.hashers import identify_hasher
26 27
from django.core.exceptions import MultipleObjectsReturned
......
33 34
from django.shortcuts import get_object_or_404
34 35

  
35 36
from django_rbac.utils import get_ou_model, get_role_model
37
import requests
38
from requests.exceptions import RequestException
36 39

  
37 40
from rest_framework import serializers, pagination
38 41
from rest_framework.validators import UniqueTogetherValidator
......
1038 1041
        return result, status.HTTP_200_OK
1039 1042

  
1040 1043
validate_password = ValidatePasswordAPI.as_view()
1044

  
1045

  
1046
class AddressAutocompleteAPI(APIView):
1047
    permission_classes = (permissions.IsAuthenticated,)
1048

  
1049
    def get(self, request):
1050
        if not getattr(settings, 'ADDRESS_AUTOCOMPLETE_URL', None):
1051
            return Response({})
1052
        try:
1053
            response = requests.get(
1054
                settings.ADDRESS_AUTOCOMPLETE_URL,
1055
                params=request.GET
1056
            )
1057
            response.raise_for_status()
1058
            return Response(response.json())
1059
        except RequestException:
1060
            return Response({})
1061

  
1062

  
1063
address_autocomplete = AddressAutocompleteAPI.as_view()
src/authentic2/attribute_kinds.py
23 23
from itertools import chain
24 24

  
25 25
from django import forms
26
from django.conf import settings
26 27
from django.core.exceptions import ValidationError
27 28
from django.core.validators import RegexValidator
29
from django.urls import reverse
28 30
from django.utils import six, formats
29 31
from django.utils.translation import ugettext_lazy as _, pgettext_lazy
30 32
from django.utils import html
......
110 112
    ]
111 113

  
112 114

  
115
class AddressAutocompleteInput(forms.Select):
116
    template_name = 'authentic2/widgets/address_autocomplete.html'
117

  
118
    class Media:
119
        js = [
120
            settings.SELECT2_JS,
121
            'authentic2/js/address_autocomplete.js',
122
        ]
123
        css = {
124
            'screen': [settings.SELECT2_CSS],
125
        }
126

  
127
    def __init__(self, **kwargs):
128
        super().__init__(**kwargs)
129
        self.attrs['data-select2-url'] = reverse('a2-api-address-autocomplete')
130
        self.attrs['class'] = 'address-autocomplete'
131

  
132

  
133
class AddressAutocompleteField(forms.CharField):
134
    widget = AddressAutocompleteInput
135

  
136

  
113 137
@to_iter
114 138
def get_title_choices():
115 139
    return app_settings.A2_ATTRIBUTE_KIND_TITLE_CHOICES or DEFAULT_TITLE_CHOICES
......
269 293
        'rest_framework_field_class': BirthdateRestField,
270 294
        'free_text_search': date_free_text_search,
271 295
    },
296
    {
297
        'label': _('address (autocomplete)'),
298
        'name': 'address_auto',
299
        'field_class': AddressAutocompleteField,
300
    },
272 301
    {
273 302
        'label': _('french postcode'),
274 303
        'name': 'fr_postcode',
src/authentic2/manager/static/authentic2/manager/css/style.css
248 248
	margin-right: 1em;
249 249
}
250 250

  
251
.ui-dialog-content span.select2-container {
251
.ui-dialog-content span.select2-container,
252
form .widget span.select2-container {
252 253
	width: 100% !important;
253 254
}
254 255

  
src/authentic2/manager/user_views.py
315 315
        if not self.object.username and self.object.ou and not self.object.ou.show_username:
316 316
            fields.remove('username')
317 317
        for attribute in Attribute.objects.all():
318
            if attribute.name == 'address_autocomplete':
319
                continue
318 320
            fields.append(attribute.name)
319 321
        if self.request.user.is_superuser and \
320 322
                'is_superuser' not in self.fields:
src/authentic2/static/authentic2/js/address_autocomplete.js
1
$(function() {
2
    $('select.address-autocomplete').select2({
3
        ajax: {
4
            delay: 250,
5
            dataType: 'json',
6
            data: function(params) {
7
                return {q: params.term, page_limit: 10};
8
            },
9
            processResults: function (data, params) {
10
                return {results: data.data};
11
            },
12
            url: function (params) {
13
                return $(this).data('select2-url')
14
            }
15
        }
16
    }).on('select2:select', function(e) {
17
        var data = e.params.data;
18
        if (data) {
19
            var address = undefined;
20
            if (typeof data.address == "object") {
21
                address = data.address;
22
            } else {
23
                address = data;
24
            }
25
            var road = address.road || address.nom_rue;
26
            var house_number = address.house_number || address.numero;
27
            var city = address.city || address.nom_commune;
28
            var postcode = address.postcode || address.code_postal;
29
            var number_and_street = null;
30
            if (house_number && road) {
31
                number_and_street = house_number + ' ' + road;
32
            } else {
33
                number_and_street = road;
34
            }
35
            $('#id_address').val(number_and_street);
36
            $('#id_city').val(city);
37
            $('#id_zipcode').val(postcode);
38
        }
39
    });
40
    $('#id_address, #id_city, #id_zipcode').attr('readonly', 'readonly');
41
    $('#manual-address').on('change', function() {
42
        $('#id_address, #id_city, #id_zipcode').attr('readonly', this.checked ? null : 'readonly');
43
    });
44
    if ($('#id_address').val() || $('#id_city').val() || $('#id_zipcode').val()) {
45
        var data = {
46
            id: 1,
47
            text: ''
48
        }
49
        $.each(['#id_address', '#id_zipcode', '#id_city'], function(idx, value) {
50
            if ($(value).val()) {
51
                if (data.text) {
52
                    data.text += ' ';
53
                }
54
                data.text += $(value).val();
55
            }
56
        })
57
        var newOption = new Option(data.text, data.id, false, false);
58
        $('select.address-autocomplete').append(newOption).trigger('change');
59
    }
60
});
src/authentic2/templates/authentic2/widgets/address_autocomplete.html
1
{% load i18n %}
2
{% include "django/forms/widgets/select.html" %}
3
<div>
4
  <label><input id="manual-address" type="checkbox">{% trans "Manually enter the address" %}</label>
5
</div>
src/authentic2/views.py
133 133

  
134 134
    def get_form_kwargs(self, **kwargs):
135 135
        kwargs = super(EditProfile, self).get_form_kwargs(**kwargs)
136
        kwargs['prefix'] = 'edit-profile'
137 136
        kwargs['next_url'] = utils.select_next_url(self.request, reverse('account_management'))
138 137
        return kwargs
139 138

  
......
141 140
        return utils.select_next_url(
142 141
            self.request,
143 142
            default=reverse('account_management'),
144
            field_name='edit-profile-next_url',
143
            field_name='next_url',
145 144
            include_post=True)
146 145

  
147 146
    def post(self, request, *args, **kwargs):
tests/test_api.py
18 18

  
19 19
import datetime
20 20
import json
21
import mock
21 22
import pytest
22 23
import random
23 24
import uuid
......
36 37

  
37 38
from django_rbac.models import SEARCH_OP
38 39
from django_rbac.utils import get_role_model, get_ou_model
40
from requests.models import Response
39 41

  
40 42
from authentic2.a2_rbac.models import Role
41 43
from authentic2.a2_rbac.utils import get_default_ou
......
1926 1928
    resp = app.get('/api/users/find_duplicates/', params=params)
1927 1929
    assert len(resp.json['data']) == 2
1928 1930
    assert resp.json['data'][0]['id'] == homonym.pk
1931

  
1932

  
1933
class MockedRequestResponse(mock.Mock):
1934
    status_code = 200
1935

  
1936
    def json(self):
1937
        return json.loads(self.content)
1938

  
1939

  
1940
def test_api_address_autocomplete(app, admin, settings):
1941
    app.authorization = ('Basic', (admin.username, admin.username))
1942

  
1943
    settings.ADDRESS_AUTOCOMPLETE_URL = 'example.com'
1944

  
1945
    params = {'q': '42 avenue'}
1946
    with mock.patch('authentic2.api_views.requests.get') as requests_get:
1947
        mock_resp = Response()
1948
        mock_resp.status_code = 500
1949
        requests_get.return_value = mock_resp
1950
        resp = app.get('/api/address-autocomplete/', params=params)
1951
    assert resp.json == {}
1952
    assert requests_get.call_args_list[0][0][0] == 'example.com'
1953
    assert requests_get.call_args_list[0][1]['params'] == {'q': ['42 avenue']}
1954
    with mock.patch('authentic2.api_views.requests.get') as requests_get:
1955
        mock_resp = Response()
1956
        mock_resp.status_code = 404
1957
        requests_get.return_value = mock_resp
1958
        resp = app.get('/api/address-autocomplete/', params=params)
1959
    assert resp.json == {}
1960
    with mock.patch('authentic2.api_views.requests.get') as requests_get:
1961
        requests_get.return_value = MockedRequestResponse(content=json.dumps({'data': {'foo': 'bar'}}))
1962
        resp = app.get('/api/address-autocomplete/', params=params)
1963
    assert resp.json == {'data': {'foo': 'bar'}}
1964

  
1965
    settings.ADDRESS_AUTOCOMPLETE_URL = None
1966
    with mock.patch('authentic2.api_views.requests.get') as requests_get:
1967
        resp = app.get('/api/address-autocomplete/', params=params)
1968
    assert resp.json == {}
1969
    assert requests_get.call_args_list == []
1970

  
1971
    del settings.ADDRESS_AUTOCOMPLETE_URL
1972
    with mock.patch('authentic2.api_views.requests.get') as requests_get:
1973
        resp = app.get('/api/address-autocomplete/', params=params)
1974
    assert resp.json == {}
1975
    assert requests_get.call_args_list == []
tests/test_attribute_kinds.py
456 456
    # verify we can clear the image
457 457
    response = app.get('/accounts/edit/')
458 458
    form = response.form
459
    form.set('edit-profile-first_name', 'John')
460
    form.set('edit-profile-last_name', 'Doe')
461
    form.set('edit-profile-cityscape_image-clear', True)
459
    form.set('first_name', 'John')
460
    form.set('last_name', 'Doe')
461
    form.set('cityscape_image-clear', True)
462 462
    response = form.submit()
463 463
    assert john().attributes.cityscape_image == None
464 464

  
......
470 470
    # verify 201x201 image is accepted and resized
471 471
    response = app.get('/accounts/edit/')
472 472
    form = response.form
473
    form.set('edit-profile-cityscape_image', Upload('tests/201x201.jpg'))
473
    form.set('cityscape_image', Upload('tests/201x201.jpg'))
474 474
    response = form.submit()
475 475
    with PIL.Image.open(os.path.join(settings.MEDIA_ROOT, john().attributes.cityscape_image.name)) as image:
476 476
        assert image.width == 200
......
480 480
    # verify file input mentions image files
481 481
    response = app.get('/accounts/edit/')
482 482
    form = response.form
483
    assert form['edit-profile-cityscape_image'].attrs['accept'] == 'image/*'
483
    assert form['cityscape_image'].attrs['accept'] == 'image/*'
484 484

  
485 485

  
486 486
def test_multiple_attribute_setter(db, app, simple_user):
tests/test_profile.py
47 47
        kind='boolean', user_visible=True, user_editable=True)
48 48

  
49 49
    resp = old_resp = app.get(url, status=200)
50
    resp.form['edit-profile-phone'] = '1234'
51
    assert resp.form['edit-profile-phone'].attrs['type'] == 'tel'
52
    resp.form['edit-profile-title'] = 'Mrs'
53
    resp.form['edit-profile-agreement'] = False
50
    resp.form['phone'] = '1234'
51
    assert resp.form['phone'].attrs['type'] == 'tel'
52
    resp.form['title'] = 'Mrs'
53
    resp.form['agreement'] = False
54 54
    resp = resp.form.submit()
55 55
    # verify that missing next_url in POST is ok
56 56
    assert resp['Location'].endswith(reverse('account_management'))
......
70 70
    ]
71 71

  
72 72
    resp = app.get(url, status=200)
73
    resp.form.set('edit-profile-phone', '0123456789')
73
    resp.form.set('phone', '0123456789')
74 74
    resp = resp.form.submit().follow()
75 75
    assert phone.get_value(simple_user) == '0123456789'
76 76

  
77 77
    resp = app.get(url, status=200)
78
    resp.form.set('edit-profile-phone', '9876543210')
78
    resp.form.set('phone', '9876543210')
79 79
    resp = resp.form.submit('cancel').follow()
80 80
    assert phone.get_value(simple_user) == '0123456789'
81 81

  
......
83 83
    title.set_value(simple_user, 'Mr', verified=True)
84 84
    agreement.set_value(simple_user, True, verified=True)
85 85
    resp = app.get(url, status=200)
86
    assert 'edit-profile-phone' not in resp.form.fields
87
    assert 'edit-profile-title' not in resp.form.fields
88
    assert 'edit-profile-agreement' not in resp.form.fields
89
    assert 'readonly' in resp.form['edit-profile-phone@disabled'].attrs
90
    assert resp.form['edit-profile-phone@disabled'].value == '0123456789'
91
    assert resp.form['edit-profile-title@disabled'].value == 'Mr'
92
    assert resp.form['edit-profile-agreement@disabled'].value == 'Yes'
93
    resp.form.set('edit-profile-phone@disabled', '1234')
94
    resp.form.set('edit-profile-title@disabled', 'Mrs')
95
    resp.form.set('edit-profile-agreement@disabled', 'False')
86
    assert 'phone' not in resp.form.fields
87
    assert 'title' not in resp.form.fields
88
    assert 'agreement' not in resp.form.fields
89
    assert 'readonly' in resp.form['phone@disabled'].attrs
90
    assert resp.form['phone@disabled'].value == '0123456789'
91
    assert resp.form['title@disabled'].value == 'Mr'
92
    assert resp.form['agreement@disabled'].value == 'Yes'
93
    resp.form.set('phone@disabled', '1234')
94
    resp.form.set('title@disabled', 'Mrs')
95
    resp.form.set('agreement@disabled', 'False')
96 96
    resp = resp.form.submit().follow()
97 97
    assert phone.get_value(simple_user) == '0123456789'
98 98
    assert title.get_value(simple_user) == 'Mr'
......
106 106
    phone.disabled = True
107 107
    phone.save()
108 108
    resp = app.get(url, status=200)
109
    assert 'edit-profile-phone@disabled' not in resp
110
    assert 'edit-profile-title@disabled' in resp
111
    assert 'edit-profile-agreement@disabled' in resp
109
    assert 'phone@disabled' not in resp
110
    assert 'title@disabled' in resp
111
    assert 'agreement@disabled' in resp
112 112
    assert phone.get_value(simple_user) == '0123456789'
113 113

  
114 114

  
......
122 122
        user_editable=True)
123 123

  
124 124
    resp = app.get(url + '?next=%s' % external_redirect_next_url, status=200)
125
    resp.form.set('edit-profile-phone', '0123456789')
125
    resp.form.set('phone', '0123456789')
126 126
    resp = resp.form.submit()
127 127
    assert_external_redirect(resp, reverse('account_management'))
128 128
    assert attribute.get_value(simple_user) == '0123456789'
129 129

  
130 130
    resp = app.get(url + '?next=%s' % external_redirect_next_url, status=200)
131
    resp.form.set('edit-profile-phone', '1234')
131
    resp.form.set('phone', '1234')
132 132
    resp = resp.form.submit('cancel')
133 133
    assert_external_redirect(resp, reverse('account_management'))
134 134
    assert attribute.get_value(simple_user) == '0123456789'
......
153 153
                             scopes='address')
154 154

  
155 155
    def get_fields(resp):
156
        return set(key.split('edit-profile-')[1]
157
                   for key in resp.form.fields.keys() if key and key.startswith('edit-profile-'))
156
        return set(key for key in resp.form.fields.keys() if key and key not in ['csrfmiddlewaretoken', 'cancel'])
157

  
158 158
    resp = app.get(url, status=200)
159 159
    assert get_fields(resp) == set(['first_name', 'last_name', 'phone', 'mobile', 'city', 'zipcode', 'next_url'])
160 160

  
......
185 185
    utils.login(app, simple_user)
186 186
    url = reverse('profile_edit')
187 187
    response = app.get(url, status=200)
188
    assert len(response.pyquery('input[type="radio"][name="edit-profile-title"]')) == 2
189
    assert len(response.pyquery('input[type="radio"][name="edit-profile-title"][readonly="true"]')) == 0
190
    assert len(response.pyquery('select[name="edit-profile-title"]')) == 0
188
    assert len(response.pyquery('input[type="radio"][name="title"]')) == 2
189
    assert len(response.pyquery('input[type="radio"][name="title"][readonly="true"]')) == 0
190
    assert len(response.pyquery('select[name="title"]')) == 0
191 191

  
192 192
    simple_user.verified_attributes.title = 'Monsieur'
193 193

  
194 194
    response = app.get(url, status=200)
195
    assert len(response.pyquery('input[type="radio"][name="edit-profile-title"]')) == 0
196
    assert len(response.pyquery('input[type="text"][name="edit-profile-title@disabled"][readonly]')) == 1
195
    assert len(response.pyquery('input[type="radio"][name="title"]')) == 0
196
    assert len(response.pyquery('input[type="text"][name="title@disabled"][readonly]')) == 1
197 197

  
198 198

  
199 199
def test_account_view(app, simple_user, settings):
tests/test_registration.py
379 379
    assert u'Prénom' not in response.text
380 380

  
381 381
    response = app.get(reverse('profile_edit'))
382
    assert 'edit-profile-profession' in response.form.fields
383
    assert 'edit-profile-prenom' not in response.form.fields
384
    assert 'edit-profile-nom' not in response.form.fields
382
    assert 'profession' in response.form.fields
383
    assert 'prenom' not in response.form.fields
384
    assert 'nom' not in response.form.fields
385 385

  
386
    assert response.pyquery('[for=id_edit-profile-profession]')
387
    assert not response.pyquery('[for=id_edit-profile-profession].form-field-required')
388
    response.form.set('edit-profile-profession', 'pompier')
386
    assert response.pyquery('[for=id_profession]')
387
    assert not response.pyquery('[for=id_profession].form-field-required')
388
    response.form.set('profession', 'pompier')
389 389
    response = response.form.submit()
390 390
    assert urlparse(response['Location']).path == reverse('account_management')
391 391

  
tests/test_user_manager.py
733 733
    assert not user.email_verified
734 734

  
735 735

  
736
def test_manager_edit_user_address_autocomplete(app, simple_user, superuser_or_admin):
737
    url = u'/manage/users/%s/edit/' % simple_user.pk
738
    login(app, superuser_or_admin, '/manage/')
739

  
740
    Attribute.objects.create(
741
        name='address_autocomplete', label='Address (autocomplete)',
742
        kind='address_auto', user_visible=True, user_editable=True)
743

  
744
    resp = app.get(url)
745
    assert resp.html.find('select', {'name': 'address_autocomplete'})
746
    assert resp.html.find('input', {'id': 'manual-address'})
747

  
748

  
736 749
def test_manager_email_verified_column_user(app, simple_user, superuser_or_admin):
737 750
    login(app, superuser_or_admin, '/manage/')
738 751

  
......
793 806
    assert resp.html.find('input', {'name': 'username'})
794 807

  
795 808

  
809
def test_manager_user_address_autocomplete_field(app, superuser, simple_user):
810
    login(app, superuser, '/manage/')
811
    Attribute.objects.create(
812
        name='address_autocomplete', label='Address (autocomplete)',
813
        kind='address_auto', user_visible=True, user_editable=True)
814
    resp = app.get(reverse('a2-manager-user-detail', kwargs={'pk': simple_user.id}))
815
    assert not resp.html.find('select', {'name': 'address_autocomplete'})
816
    assert not resp.html.find('input', {'id': 'manual-address'})
817

  
818

  
796 819
def test_manager_user_roles_visibility(app, simple_user, admin, ou1, ou2):
797 820
    Role = get_role_model()
798 821
    role1 = Role.objects.create(name='Role 1', slug='role1', ou=ou1)
799
-