profile_views: address autocomplete field (#41919)

This commit is contained in:
Lauréline Guérin 2020-10-06 15:44:54 +02:00
parent 3b6d2cc4cd
commit 7b130d6ffc
No known key found for this signature in database
GPG Key ID: 1FAB9B9B4F93D473
13 changed files with 232 additions and 42 deletions

View File

@ -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

View File

@ -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()

View File

@ -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',

View File

@ -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;
}

View File

@ -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:

View File

@ -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');
}
});

View File

@ -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>

View File

@ -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):

View File

@ -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 == []

View File

@ -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):

View File

@ -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):

View File

@ -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')

View File

@ -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)