From 0dbbf4199df131649dd77f645294f106f9a5b0f1 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Laur=C3=A9line=20Gu=C3=A9rin?= <zebuline@entrouvert.com>
Date: Tue, 6 Oct 2020 15:44:54 +0200
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

diff --git a/src/authentic2/api_urls.py b/src/authentic2/api_urls.py
index b4a2e0c9..9aab37db 100644
--- a/src/authentic2/api_urls.py
+++ b/src/authentic2/api_urls.py
@@ -28,6 +28,7 @@ urlpatterns = [
         name='a2-api-role-members'),
     url(r'^check-password/$', api_views.check_password, name='a2-api-check-password'),
     url(r'^validate-password/$', api_views.validate_password, name='a2-api-validate-password'),
+    url(r'^address-autocomplete/$', api_views.address_autocomplete, name='a2-api-address-autocomplete'),
 ]
 
 urlpatterns += api_views.router.urls
diff --git a/src/authentic2/api_views.py b/src/authentic2/api_views.py
index b3d9b381..6b48238b 100644
--- a/src/authentic2/api_views.py
+++ b/src/authentic2/api_views.py
@@ -21,6 +21,7 @@ import smtplib
 from pytz.exceptions import AmbiguousTimeError
 import django
 from django.db import models
+from django.conf import settings
 from django.contrib.auth import get_user_model
 from django.contrib.auth.hashers import identify_hasher
 from django.core.exceptions import MultipleObjectsReturned
@@ -33,6 +34,8 @@ from django.views.decorators.cache import cache_control
 from django.shortcuts import get_object_or_404
 
 from django_rbac.utils import get_ou_model, get_role_model
+import requests
+from requests.exceptions import RequestException
 
 from rest_framework import serializers, pagination
 from rest_framework.validators import UniqueTogetherValidator
@@ -1038,3 +1041,23 @@ class ValidatePasswordAPI(BaseRpcView):
         return result, status.HTTP_200_OK
 
 validate_password = ValidatePasswordAPI.as_view()
+
+
+class AddressAutocompleteAPI(APIView):
+    permission_classes = (permissions.IsAuthenticated,)
+
+    def get(self, request):
+        if not getattr(settings, 'ADDRESS_AUTOCOMPLETE_URL', None):
+            return Response({})
+        try:
+            response = requests.get(
+                settings.ADDRESS_AUTOCOMPLETE_URL,
+                params=request.GET
+            )
+            response.raise_for_status()
+            return Response(response.json())
+        except RequestException:
+            return Response({})
+
+
+address_autocomplete = AddressAutocompleteAPI.as_view()
diff --git a/src/authentic2/attribute_kinds.py b/src/authentic2/attribute_kinds.py
index 76946bf6..0e7e9468 100644
--- a/src/authentic2/attribute_kinds.py
+++ b/src/authentic2/attribute_kinds.py
@@ -23,8 +23,10 @@ import os
 from itertools import chain
 
 from django import forms
+from django.conf import settings
 from django.core.exceptions import ValidationError
 from django.core.validators import RegexValidator
+from django.urls import reverse
 from django.utils import six, formats
 from django.utils.translation import ugettext_lazy as _, pgettext_lazy
 from django.utils import html
@@ -110,6 +112,28 @@ class BirthdateRestField(DateRestField):
     ]
 
 
+class AddressAutocompleteInput(forms.Select):
+    template_name = 'authentic2/widgets/address_autocomplete.html'
+
+    class Media:
+        js = [
+            settings.SELECT2_JS,
+            'authentic2/js/address_autocomplete.js',
+        ]
+        css = {
+            'screen': [settings.SELECT2_CSS],
+        }
+
+    def __init__(self, **kwargs):
+        super().__init__(**kwargs)
+        self.attrs['data-select2-url'] = reverse('a2-api-address-autocomplete')
+        self.attrs['class'] = 'address-autocomplete'
+
+
+class AddressAutocompleteField(forms.CharField):
+    widget = AddressAutocompleteInput
+
+
 @to_iter
 def get_title_choices():
     return app_settings.A2_ATTRIBUTE_KIND_TITLE_CHOICES or DEFAULT_TITLE_CHOICES
@@ -269,6 +293,11 @@ DEFAULT_ATTRIBUTE_KINDS = [
         'rest_framework_field_class': BirthdateRestField,
         'free_text_search': date_free_text_search,
     },
+    {
+        'label': _('address (autocomplete)'),
+        'name': 'address_auto',
+        'field_class': AddressAutocompleteField,
+    },
     {
         'label': _('french postcode'),
         'name': 'fr_postcode',
diff --git a/src/authentic2/manager/static/authentic2/manager/css/style.css b/src/authentic2/manager/static/authentic2/manager/css/style.css
index d833b896..8d692fdb 100644
--- a/src/authentic2/manager/static/authentic2/manager/css/style.css
+++ b/src/authentic2/manager/static/authentic2/manager/css/style.css
@@ -248,7 +248,8 @@ span.select2-container {
 	margin-right: 1em;
 }
 
-.ui-dialog-content span.select2-container {
+.ui-dialog-content span.select2-container,
+form .widget span.select2-container {
 	width: 100% !important;
 }
 
diff --git a/src/authentic2/manager/user_views.py b/src/authentic2/manager/user_views.py
index a0e03e1e..5690e40c 100644
--- a/src/authentic2/manager/user_views.py
+++ b/src/authentic2/manager/user_views.py
@@ -315,6 +315,8 @@ class UserDetailView(OtherActionsMixin, BaseDetailView):
         if not self.object.username and self.object.ou and not self.object.ou.show_username:
             fields.remove('username')
         for attribute in Attribute.objects.all():
+            if attribute.name == 'address_autocomplete':
+                continue
             fields.append(attribute.name)
         if self.request.user.is_superuser and \
                 'is_superuser' not in self.fields:
diff --git a/src/authentic2/static/authentic2/js/address_autocomplete.js b/src/authentic2/static/authentic2/js/address_autocomplete.js
new file mode 100644
index 00000000..36198252
--- /dev/null
+++ b/src/authentic2/static/authentic2/js/address_autocomplete.js
@@ -0,0 +1,60 @@
+$(function() {
+    $('select.address-autocomplete').select2({
+        ajax: {
+            delay: 250,
+            dataType: 'json',
+            data: function(params) {
+                return {q: params.term, page_limit: 10};
+            },
+            processResults: function (data, params) {
+                return {results: data.data};
+            },
+            url: function (params) {
+                return $(this).data('select2-url')
+            }
+        }
+    }).on('select2:select', function(e) {
+        var data = e.params.data;
+        if (data) {
+            var address = undefined;
+            if (typeof data.address == "object") {
+                address = data.address;
+            } else {
+                address = data;
+            }
+            var road = address.road || address.nom_rue;
+            var house_number = address.house_number || address.numero;
+            var city = address.city || address.nom_commune;
+            var postcode = address.postcode || address.code_postal;
+            var number_and_street = null;
+            if (house_number && road) {
+                number_and_street = house_number + ' ' + road;
+            } else {
+                number_and_street = road;
+            }
+            $('#id_address').val(number_and_street);
+            $('#id_city').val(city);
+            $('#id_zipcode').val(postcode);
+        }
+    });
+    $('#id_address, #id_city, #id_zipcode').attr('readonly', 'readonly');
+    $('#manual-address').on('change', function() {
+        $('#id_address, #id_city, #id_zipcode').attr('readonly', this.checked ? null : 'readonly');
+    });
+    if ($('#id_address').val() || $('#id_city').val() || $('#id_zipcode').val()) {
+        var data = {
+            id: 1,
+            text: ''
+        }
+        $.each(['#id_address', '#id_zipcode', '#id_city'], function(idx, value) {
+            if ($(value).val()) {
+                if (data.text) {
+                    data.text += ' ';
+                }
+                data.text += $(value).val();
+            }
+        })
+        var newOption = new Option(data.text, data.id, false, false);
+        $('select.address-autocomplete').append(newOption).trigger('change');
+    }
+});
diff --git a/src/authentic2/templates/authentic2/widgets/address_autocomplete.html b/src/authentic2/templates/authentic2/widgets/address_autocomplete.html
new file mode 100644
index 00000000..21f7af73
--- /dev/null
+++ b/src/authentic2/templates/authentic2/widgets/address_autocomplete.html
@@ -0,0 +1,5 @@
+{% load i18n %}
+{% include "django/forms/widgets/select.html" %}
+<div>
+  <label><input id="manual-address" type="checkbox">{% trans "Manually enter the address" %}</label>
+</div>
diff --git a/src/authentic2/views.py b/src/authentic2/views.py
index ec2b5063..d886d5c7 100644
--- a/src/authentic2/views.py
+++ b/src/authentic2/views.py
@@ -133,7 +133,6 @@ class EditProfile(cbv.HookMixin, cbv.TemplateNamesMixin, UpdateView):
 
     def get_form_kwargs(self, **kwargs):
         kwargs = super(EditProfile, self).get_form_kwargs(**kwargs)
-        kwargs['prefix'] = 'edit-profile'
         kwargs['next_url'] = utils.select_next_url(self.request, reverse('account_management'))
         return kwargs
 
@@ -141,7 +140,7 @@ class EditProfile(cbv.HookMixin, cbv.TemplateNamesMixin, UpdateView):
         return utils.select_next_url(
             self.request,
             default=reverse('account_management'),
-            field_name='edit-profile-next_url',
+            field_name='next_url',
             include_post=True)
 
     def post(self, request, *args, **kwargs):
diff --git a/tests/test_api.py b/tests/test_api.py
index fdc73f4b..0774b3ed 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -18,6 +18,7 @@
 
 import datetime
 import json
+import mock
 import pytest
 import random
 import uuid
@@ -36,6 +37,7 @@ from django.utils.http import urlencode
 
 from django_rbac.models import SEARCH_OP
 from django_rbac.utils import get_role_model, get_ou_model
+from requests.models import Response
 
 from authentic2.a2_rbac.models import Role
 from authentic2.a2_rbac.utils import get_default_ou
@@ -1926,3 +1928,48 @@ def test_find_duplicates_birthdate(app, admin, settings):
     resp = app.get('/api/users/find_duplicates/', params=params)
     assert len(resp.json['data']) == 2
     assert resp.json['data'][0]['id'] == homonym.pk
+
+
+class MockedRequestResponse(mock.Mock):
+    status_code = 200
+
+    def json(self):
+        return json.loads(self.content)
+
+
+def test_api_address_autocomplete(app, admin, settings):
+    app.authorization = ('Basic', (admin.username, admin.username))
+
+    settings.ADDRESS_AUTOCOMPLETE_URL = 'example.com'
+
+    params = {'q': '42 avenue'}
+    with mock.patch('authentic2.api_views.requests.get') as requests_get:
+        mock_resp = Response()
+        mock_resp.status_code = 500
+        requests_get.return_value = mock_resp
+        resp = app.get('/api/address-autocomplete/', params=params)
+    assert resp.json == {}
+    assert requests_get.call_args_list[0][0][0] == 'example.com'
+    assert requests_get.call_args_list[0][1]['params'] == {'q': ['42 avenue']}
+    with mock.patch('authentic2.api_views.requests.get') as requests_get:
+        mock_resp = Response()
+        mock_resp.status_code = 404
+        requests_get.return_value = mock_resp
+        resp = app.get('/api/address-autocomplete/', params=params)
+    assert resp.json == {}
+    with mock.patch('authentic2.api_views.requests.get') as requests_get:
+        requests_get.return_value = MockedRequestResponse(content=json.dumps({'data': {'foo': 'bar'}}))
+        resp = app.get('/api/address-autocomplete/', params=params)
+    assert resp.json == {'data': {'foo': 'bar'}}
+
+    settings.ADDRESS_AUTOCOMPLETE_URL = None
+    with mock.patch('authentic2.api_views.requests.get') as requests_get:
+        resp = app.get('/api/address-autocomplete/', params=params)
+    assert resp.json == {}
+    assert requests_get.call_args_list == []
+
+    del settings.ADDRESS_AUTOCOMPLETE_URL
+    with mock.patch('authentic2.api_views.requests.get') as requests_get:
+        resp = app.get('/api/address-autocomplete/', params=params)
+    assert resp.json == {}
+    assert requests_get.call_args_list == []
diff --git a/tests/test_attribute_kinds.py b/tests/test_attribute_kinds.py
index fea3f4f8..8a7b2712 100644
--- a/tests/test_attribute_kinds.py
+++ b/tests/test_attribute_kinds.py
@@ -456,9 +456,9 @@ def test_profile_image(db, app, admin, mailoutbox):
     # verify we can clear the image
     response = app.get('/accounts/edit/')
     form = response.form
-    form.set('edit-profile-first_name', 'John')
-    form.set('edit-profile-last_name', 'Doe')
-    form.set('edit-profile-cityscape_image-clear', True)
+    form.set('first_name', 'John')
+    form.set('last_name', 'Doe')
+    form.set('cityscape_image-clear', True)
     response = form.submit()
     assert john().attributes.cityscape_image == None
 
@@ -470,7 +470,7 @@ def test_profile_image(db, app, admin, mailoutbox):
     # verify 201x201 image is accepted and resized
     response = app.get('/accounts/edit/')
     form = response.form
-    form.set('edit-profile-cityscape_image', Upload('tests/201x201.jpg'))
+    form.set('cityscape_image', Upload('tests/201x201.jpg'))
     response = form.submit()
     with PIL.Image.open(os.path.join(settings.MEDIA_ROOT, john().attributes.cityscape_image.name)) as image:
         assert image.width == 200
@@ -480,7 +480,7 @@ def test_profile_image(db, app, admin, mailoutbox):
     # verify file input mentions image files
     response = app.get('/accounts/edit/')
     form = response.form
-    assert form['edit-profile-cityscape_image'].attrs['accept'] == 'image/*'
+    assert form['cityscape_image'].attrs['accept'] == 'image/*'
 
 
 def test_multiple_attribute_setter(db, app, simple_user):
diff --git a/tests/test_profile.py b/tests/test_profile.py
index f87e2392..e04481f6 100644
--- a/tests/test_profile.py
+++ b/tests/test_profile.py
@@ -47,10 +47,10 @@ def test_account_edit_view(app, simple_user):
         kind='boolean', user_visible=True, user_editable=True)
 
     resp = old_resp = app.get(url, status=200)
-    resp.form['edit-profile-phone'] = '1234'
-    assert resp.form['edit-profile-phone'].attrs['type'] == 'tel'
-    resp.form['edit-profile-title'] = 'Mrs'
-    resp.form['edit-profile-agreement'] = False
+    resp.form['phone'] = '1234'
+    assert resp.form['phone'].attrs['type'] == 'tel'
+    resp.form['title'] = 'Mrs'
+    resp.form['agreement'] = False
     resp = resp.form.submit()
     # verify that missing next_url in POST is ok
     assert resp['Location'].endswith(reverse('account_management'))
@@ -70,12 +70,12 @@ def test_account_edit_view(app, simple_user):
     ]
 
     resp = app.get(url, status=200)
-    resp.form.set('edit-profile-phone', '0123456789')
+    resp.form.set('phone', '0123456789')
     resp = resp.form.submit().follow()
     assert phone.get_value(simple_user) == '0123456789'
 
     resp = app.get(url, status=200)
-    resp.form.set('edit-profile-phone', '9876543210')
+    resp.form.set('phone', '9876543210')
     resp = resp.form.submit('cancel').follow()
     assert phone.get_value(simple_user) == '0123456789'
 
@@ -83,16 +83,16 @@ def test_account_edit_view(app, simple_user):
     title.set_value(simple_user, 'Mr', verified=True)
     agreement.set_value(simple_user, True, verified=True)
     resp = app.get(url, status=200)
-    assert 'edit-profile-phone' not in resp.form.fields
-    assert 'edit-profile-title' not in resp.form.fields
-    assert 'edit-profile-agreement' not in resp.form.fields
-    assert 'readonly' in resp.form['edit-profile-phone@disabled'].attrs
-    assert resp.form['edit-profile-phone@disabled'].value == '0123456789'
-    assert resp.form['edit-profile-title@disabled'].value == 'Mr'
-    assert resp.form['edit-profile-agreement@disabled'].value == 'Yes'
-    resp.form.set('edit-profile-phone@disabled', '1234')
-    resp.form.set('edit-profile-title@disabled', 'Mrs')
-    resp.form.set('edit-profile-agreement@disabled', 'False')
+    assert 'phone' not in resp.form.fields
+    assert 'title' not in resp.form.fields
+    assert 'agreement' not in resp.form.fields
+    assert 'readonly' in resp.form['phone@disabled'].attrs
+    assert resp.form['phone@disabled'].value == '0123456789'
+    assert resp.form['title@disabled'].value == 'Mr'
+    assert resp.form['agreement@disabled'].value == 'Yes'
+    resp.form.set('phone@disabled', '1234')
+    resp.form.set('title@disabled', 'Mrs')
+    resp.form.set('agreement@disabled', 'False')
     resp = resp.form.submit().follow()
     assert phone.get_value(simple_user) == '0123456789'
     assert title.get_value(simple_user) == 'Mr'
@@ -106,9 +106,9 @@ def test_account_edit_view(app, simple_user):
     phone.disabled = True
     phone.save()
     resp = app.get(url, status=200)
-    assert 'edit-profile-phone@disabled' not in resp
-    assert 'edit-profile-title@disabled' in resp
-    assert 'edit-profile-agreement@disabled' in resp
+    assert 'phone@disabled' not in resp
+    assert 'title@disabled' in resp
+    assert 'agreement@disabled' in resp
     assert phone.get_value(simple_user) == '0123456789'
 
 
@@ -122,13 +122,13 @@ def test_account_edit_next_url(app, simple_user, external_redirect_next_url, ass
         user_editable=True)
 
     resp = app.get(url + '?next=%s' % external_redirect_next_url, status=200)
-    resp.form.set('edit-profile-phone', '0123456789')
+    resp.form.set('phone', '0123456789')
     resp = resp.form.submit()
     assert_external_redirect(resp, reverse('account_management'))
     assert attribute.get_value(simple_user) == '0123456789'
 
     resp = app.get(url + '?next=%s' % external_redirect_next_url, status=200)
-    resp.form.set('edit-profile-phone', '1234')
+    resp.form.set('phone', '1234')
     resp = resp.form.submit('cancel')
     assert_external_redirect(resp, reverse('account_management'))
     assert attribute.get_value(simple_user) == '0123456789'
@@ -153,8 +153,8 @@ def test_account_edit_scopes(app, simple_user):
                              scopes='address')
 
     def get_fields(resp):
-        return set(key.split('edit-profile-')[1]
-                   for key in resp.form.fields.keys() if key and key.startswith('edit-profile-'))
+        return set(key for key in resp.form.fields.keys() if key and key not in ['csrfmiddlewaretoken', 'cancel'])
+
     resp = app.get(url, status=200)
     assert get_fields(resp) == set(['first_name', 'last_name', 'phone', 'mobile', 'city', 'zipcode', 'next_url'])
 
@@ -185,15 +185,15 @@ def test_account_edit_locked_title(app, simple_user):
     utils.login(app, simple_user)
     url = reverse('profile_edit')
     response = app.get(url, status=200)
-    assert len(response.pyquery('input[type="radio"][name="edit-profile-title"]')) == 2
-    assert len(response.pyquery('input[type="radio"][name="edit-profile-title"][readonly="true"]')) == 0
-    assert len(response.pyquery('select[name="edit-profile-title"]')) == 0
+    assert len(response.pyquery('input[type="radio"][name="title"]')) == 2
+    assert len(response.pyquery('input[type="radio"][name="title"][readonly="true"]')) == 0
+    assert len(response.pyquery('select[name="title"]')) == 0
 
     simple_user.verified_attributes.title = 'Monsieur'
 
     response = app.get(url, status=200)
-    assert len(response.pyquery('input[type="radio"][name="edit-profile-title"]')) == 0
-    assert len(response.pyquery('input[type="text"][name="edit-profile-title@disabled"][readonly]')) == 1
+    assert len(response.pyquery('input[type="radio"][name="title"]')) == 0
+    assert len(response.pyquery('input[type="text"][name="title@disabled"][readonly]')) == 1
 
 
 def test_account_view(app, simple_user, settings):
diff --git a/tests/test_registration.py b/tests/test_registration.py
index c0d81545..de785b25 100644
--- a/tests/test_registration.py
+++ b/tests/test_registration.py
@@ -379,13 +379,13 @@ def test_attribute_model(app, db, settings, mailoutbox):
     assert u'Prénom' not in response.text
 
     response = app.get(reverse('profile_edit'))
-    assert 'edit-profile-profession' in response.form.fields
-    assert 'edit-profile-prenom' not in response.form.fields
-    assert 'edit-profile-nom' not in response.form.fields
+    assert 'profession' in response.form.fields
+    assert 'prenom' not in response.form.fields
+    assert 'nom' not in response.form.fields
 
-    assert response.pyquery('[for=id_edit-profile-profession]')
-    assert not response.pyquery('[for=id_edit-profile-profession].form-field-required')
-    response.form.set('edit-profile-profession', 'pompier')
+    assert response.pyquery('[for=id_profession]')
+    assert not response.pyquery('[for=id_profession].form-field-required')
+    response.form.set('profession', 'pompier')
     response = response.form.submit()
     assert urlparse(response['Location']).path == reverse('account_management')
 
diff --git a/tests/test_user_manager.py b/tests/test_user_manager.py
index 764cc6ed..bee2ad8c 100644
--- a/tests/test_user_manager.py
+++ b/tests/test_user_manager.py
@@ -733,6 +733,19 @@ def test_manager_edit_user_email_verified(app, simple_user, superuser_or_admin):
     assert not user.email_verified
 
 
+def test_manager_edit_user_address_autocomplete(app, simple_user, superuser_or_admin):
+    url = u'/manage/users/%s/edit/' % simple_user.pk
+    login(app, superuser_or_admin, '/manage/')
+
+    Attribute.objects.create(
+        name='address_autocomplete', label='Address (autocomplete)',
+        kind='address_auto', user_visible=True, user_editable=True)
+
+    resp = app.get(url)
+    assert resp.html.find('select', {'name': 'address_autocomplete'})
+    assert resp.html.find('input', {'id': 'manual-address'})
+
+
 def test_manager_email_verified_column_user(app, simple_user, superuser_or_admin):
     login(app, superuser_or_admin, '/manage/')
 
@@ -793,6 +806,16 @@ def test_manager_user_username_field(app, superuser, simple_user):
     assert resp.html.find('input', {'name': 'username'})
 
 
+def test_manager_user_address_autocomplete_field(app, superuser, simple_user):
+    login(app, superuser, '/manage/')
+    Attribute.objects.create(
+        name='address_autocomplete', label='Address (autocomplete)',
+        kind='address_auto', user_visible=True, user_editable=True)
+    resp = app.get(reverse('a2-manager-user-detail', kwargs={'pk': simple_user.id}))
+    assert not resp.html.find('select', {'name': 'address_autocomplete'})
+    assert not resp.html.find('input', {'id': 'manual-address'})
+
+
 def test_manager_user_roles_visibility(app, simple_user, admin, ou1, ou2):
     Role = get_role_model()
     role1 = Role.objects.create(name='Role 1', slug='role1', ou=ou1)
-- 
2.28.0

