From fa1eec37fa71e0a17286b369d85fe4135f9e6b9b 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 ++
 tests/test_api.py                             | 47 +++++++++++++++
 tests/test_user_manager.py                    | 23 +++++++
 9 files changed, 192 insertions(+), 1 deletion(-)
 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..dd46cc60
--- /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('disabled', 'disabled');
+    $('#manual-address').on('change', function() {
+        $('#id_address, #id_city, #id_zipcode').attr('disabled', this.checked ? null : 'disabled');
+    });
+    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/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_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

