From 7b130d6ffc34e1a6b706aa460f1e3ab7a32d5f2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Laur=C3=A9line=20Gu=C3=A9rin?= 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 b4a2e0c98..9aab37db9 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 b3d9b3814..6b48238bd 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 76946bf6c..0e7e9468f 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 d833b8963..8d692fdb9 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 a095d6660..8d82d84ab 100644 --- a/src/authentic2/manager/user_views.py +++ b/src/authentic2/manager/user_views.py @@ -319,6 +319,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 000000000..361982528 --- /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 000000000..21f7af737 --- /dev/null +++ b/src/authentic2/templates/authentic2/widgets/address_autocomplete.html @@ -0,0 +1,5 @@ +{% load i18n %} +{% include "django/forms/widgets/select.html" %} +
+ +
diff --git a/src/authentic2/views.py b/src/authentic2/views.py index ec2b50637..d886d5c7e 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 fdc73f4be..0774b3ed6 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 fea3f4f84..8a7b27128 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 f87e23922..e04481f6d 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 c0d815452..de785b25f 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 e8f9ef258..327042aa0 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)