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'), name='a2-api-role-members'),
url(r'^check-password/$', api_views.check_password, name='a2-api-check-password'), 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'^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 urlpatterns += api_views.router.urls

View File

@ -21,6 +21,7 @@ import smtplib
from pytz.exceptions import AmbiguousTimeError from pytz.exceptions import AmbiguousTimeError
import django import django
from django.db import models from django.db import models
from django.conf import settings
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.contrib.auth.hashers import identify_hasher from django.contrib.auth.hashers import identify_hasher
from django.core.exceptions import MultipleObjectsReturned 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.shortcuts import get_object_or_404
from django_rbac.utils import get_ou_model, get_role_model 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 import serializers, pagination
from rest_framework.validators import UniqueTogetherValidator from rest_framework.validators import UniqueTogetherValidator
@ -1038,3 +1041,23 @@ class ValidatePasswordAPI(BaseRpcView):
return result, status.HTTP_200_OK return result, status.HTTP_200_OK
validate_password = ValidatePasswordAPI.as_view() 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 itertools import chain
from django import forms from django import forms
from django.conf import settings
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.validators import RegexValidator from django.core.validators import RegexValidator
from django.urls import reverse
from django.utils import six, formats from django.utils import six, formats
from django.utils.translation import ugettext_lazy as _, pgettext_lazy from django.utils.translation import ugettext_lazy as _, pgettext_lazy
from django.utils import html 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 @to_iter
def get_title_choices(): def get_title_choices():
return app_settings.A2_ATTRIBUTE_KIND_TITLE_CHOICES or DEFAULT_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, 'rest_framework_field_class': BirthdateRestField,
'free_text_search': date_free_text_search, 'free_text_search': date_free_text_search,
}, },
{
'label': _('address (autocomplete)'),
'name': 'address_auto',
'field_class': AddressAutocompleteField,
},
{ {
'label': _('french postcode'), 'label': _('french postcode'),
'name': 'fr_postcode', 'name': 'fr_postcode',

View File

@ -248,7 +248,8 @@ span.select2-container {
margin-right: 1em; margin-right: 1em;
} }
.ui-dialog-content span.select2-container { .ui-dialog-content span.select2-container,
form .widget span.select2-container {
width: 100% !important; 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: if not self.object.username and self.object.ou and not self.object.ou.show_username:
fields.remove('username') fields.remove('username')
for attribute in Attribute.objects.all(): for attribute in Attribute.objects.all():
if attribute.name == 'address_autocomplete':
continue
fields.append(attribute.name) fields.append(attribute.name)
if self.request.user.is_superuser and \ if self.request.user.is_superuser and \
'is_superuser' not in self.fields: '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): def get_form_kwargs(self, **kwargs):
kwargs = super(EditProfile, self).get_form_kwargs(**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')) kwargs['next_url'] = utils.select_next_url(self.request, reverse('account_management'))
return kwargs return kwargs
@ -141,7 +140,7 @@ class EditProfile(cbv.HookMixin, cbv.TemplateNamesMixin, UpdateView):
return utils.select_next_url( return utils.select_next_url(
self.request, self.request,
default=reverse('account_management'), default=reverse('account_management'),
field_name='edit-profile-next_url', field_name='next_url',
include_post=True) include_post=True)
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):

View File

@ -18,6 +18,7 @@
import datetime import datetime
import json import json
import mock
import pytest import pytest
import random import random
import uuid import uuid
@ -36,6 +37,7 @@ from django.utils.http import urlencode
from django_rbac.models import SEARCH_OP from django_rbac.models import SEARCH_OP
from django_rbac.utils import get_role_model, get_ou_model 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.models import Role
from authentic2.a2_rbac.utils import get_default_ou 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) resp = app.get('/api/users/find_duplicates/', params=params)
assert len(resp.json['data']) == 2 assert len(resp.json['data']) == 2
assert resp.json['data'][0]['id'] == homonym.pk 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 # verify we can clear the image
response = app.get('/accounts/edit/') response = app.get('/accounts/edit/')
form = response.form form = response.form
form.set('edit-profile-first_name', 'John') form.set('first_name', 'John')
form.set('edit-profile-last_name', 'Doe') form.set('last_name', 'Doe')
form.set('edit-profile-cityscape_image-clear', True) form.set('cityscape_image-clear', True)
response = form.submit() response = form.submit()
assert john().attributes.cityscape_image == None 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 # verify 201x201 image is accepted and resized
response = app.get('/accounts/edit/') response = app.get('/accounts/edit/')
form = response.form form = response.form
form.set('edit-profile-cityscape_image', Upload('tests/201x201.jpg')) form.set('cityscape_image', Upload('tests/201x201.jpg'))
response = form.submit() response = form.submit()
with PIL.Image.open(os.path.join(settings.MEDIA_ROOT, john().attributes.cityscape_image.name)) as image: with PIL.Image.open(os.path.join(settings.MEDIA_ROOT, john().attributes.cityscape_image.name)) as image:
assert image.width == 200 assert image.width == 200
@ -480,7 +480,7 @@ def test_profile_image(db, app, admin, mailoutbox):
# verify file input mentions image files # verify file input mentions image files
response = app.get('/accounts/edit/') response = app.get('/accounts/edit/')
form = response.form 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): 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) kind='boolean', user_visible=True, user_editable=True)
resp = old_resp = app.get(url, status=200) resp = old_resp = app.get(url, status=200)
resp.form['edit-profile-phone'] = '1234' resp.form['phone'] = '1234'
assert resp.form['edit-profile-phone'].attrs['type'] == 'tel' assert resp.form['phone'].attrs['type'] == 'tel'
resp.form['edit-profile-title'] = 'Mrs' resp.form['title'] = 'Mrs'
resp.form['edit-profile-agreement'] = False resp.form['agreement'] = False
resp = resp.form.submit() resp = resp.form.submit()
# verify that missing next_url in POST is ok # verify that missing next_url in POST is ok
assert resp['Location'].endswith(reverse('account_management')) 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 = app.get(url, status=200)
resp.form.set('edit-profile-phone', '0123456789') resp.form.set('phone', '0123456789')
resp = resp.form.submit().follow() resp = resp.form.submit().follow()
assert phone.get_value(simple_user) == '0123456789' assert phone.get_value(simple_user) == '0123456789'
resp = app.get(url, status=200) resp = app.get(url, status=200)
resp.form.set('edit-profile-phone', '9876543210') resp.form.set('phone', '9876543210')
resp = resp.form.submit('cancel').follow() resp = resp.form.submit('cancel').follow()
assert phone.get_value(simple_user) == '0123456789' 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) title.set_value(simple_user, 'Mr', verified=True)
agreement.set_value(simple_user, True, verified=True) agreement.set_value(simple_user, True, verified=True)
resp = app.get(url, status=200) resp = app.get(url, status=200)
assert 'edit-profile-phone' not in resp.form.fields assert 'phone' not in resp.form.fields
assert 'edit-profile-title' not in resp.form.fields assert 'title' not in resp.form.fields
assert 'edit-profile-agreement' not in resp.form.fields assert 'agreement' not in resp.form.fields
assert 'readonly' in resp.form['edit-profile-phone@disabled'].attrs assert 'readonly' in resp.form['phone@disabled'].attrs
assert resp.form['edit-profile-phone@disabled'].value == '0123456789' assert resp.form['phone@disabled'].value == '0123456789'
assert resp.form['edit-profile-title@disabled'].value == 'Mr' assert resp.form['title@disabled'].value == 'Mr'
assert resp.form['edit-profile-agreement@disabled'].value == 'Yes' assert resp.form['agreement@disabled'].value == 'Yes'
resp.form.set('edit-profile-phone@disabled', '1234') resp.form.set('phone@disabled', '1234')
resp.form.set('edit-profile-title@disabled', 'Mrs') resp.form.set('title@disabled', 'Mrs')
resp.form.set('edit-profile-agreement@disabled', 'False') resp.form.set('agreement@disabled', 'False')
resp = resp.form.submit().follow() resp = resp.form.submit().follow()
assert phone.get_value(simple_user) == '0123456789' assert phone.get_value(simple_user) == '0123456789'
assert title.get_value(simple_user) == 'Mr' assert title.get_value(simple_user) == 'Mr'
@ -106,9 +106,9 @@ def test_account_edit_view(app, simple_user):
phone.disabled = True phone.disabled = True
phone.save() phone.save()
resp = app.get(url, status=200) resp = app.get(url, status=200)
assert 'edit-profile-phone@disabled' not in resp assert 'phone@disabled' not in resp
assert 'edit-profile-title@disabled' in resp assert 'title@disabled' in resp
assert 'edit-profile-agreement@disabled' in resp assert 'agreement@disabled' in resp
assert phone.get_value(simple_user) == '0123456789' 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) user_editable=True)
resp = app.get(url + '?next=%s' % external_redirect_next_url, status=200) 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() resp = resp.form.submit()
assert_external_redirect(resp, reverse('account_management')) assert_external_redirect(resp, reverse('account_management'))
assert attribute.get_value(simple_user) == '0123456789' assert attribute.get_value(simple_user) == '0123456789'
resp = app.get(url + '?next=%s' % external_redirect_next_url, status=200) 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') resp = resp.form.submit('cancel')
assert_external_redirect(resp, reverse('account_management')) assert_external_redirect(resp, reverse('account_management'))
assert attribute.get_value(simple_user) == '0123456789' assert attribute.get_value(simple_user) == '0123456789'
@ -153,8 +153,8 @@ def test_account_edit_scopes(app, simple_user):
scopes='address') scopes='address')
def get_fields(resp): def get_fields(resp):
return set(key.split('edit-profile-')[1] return set(key for key in resp.form.fields.keys() if key and key not in ['csrfmiddlewaretoken', 'cancel'])
for key in resp.form.fields.keys() if key and key.startswith('edit-profile-'))
resp = app.get(url, status=200) resp = app.get(url, status=200)
assert get_fields(resp) == set(['first_name', 'last_name', 'phone', 'mobile', 'city', 'zipcode', 'next_url']) 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) utils.login(app, simple_user)
url = reverse('profile_edit') url = reverse('profile_edit')
response = app.get(url, status=200) 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="title"]')) == 2
assert len(response.pyquery('input[type="radio"][name="edit-profile-title"][readonly="true"]')) == 0 assert len(response.pyquery('input[type="radio"][name="title"][readonly="true"]')) == 0
assert len(response.pyquery('select[name="edit-profile-title"]')) == 0 assert len(response.pyquery('select[name="title"]')) == 0
simple_user.verified_attributes.title = 'Monsieur' simple_user.verified_attributes.title = 'Monsieur'
response = app.get(url, status=200) response = app.get(url, status=200)
assert len(response.pyquery('input[type="radio"][name="edit-profile-title"]')) == 0 assert len(response.pyquery('input[type="radio"][name="title"]')) == 0
assert len(response.pyquery('input[type="text"][name="edit-profile-title@disabled"][readonly]')) == 1 assert len(response.pyquery('input[type="text"][name="title@disabled"][readonly]')) == 1
def test_account_view(app, simple_user, settings): 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 assert u'Prénom' not in response.text
response = app.get(reverse('profile_edit')) response = app.get(reverse('profile_edit'))
assert 'edit-profile-profession' in response.form.fields assert 'profession' in response.form.fields
assert 'edit-profile-prenom' not in response.form.fields assert 'prenom' not in response.form.fields
assert 'edit-profile-nom' not in response.form.fields assert 'nom' not in response.form.fields
assert response.pyquery('[for=id_edit-profile-profession]') assert response.pyquery('[for=id_profession]')
assert not response.pyquery('[for=id_edit-profile-profession].form-field-required') assert not response.pyquery('[for=id_profession].form-field-required')
response.form.set('edit-profile-profession', 'pompier') response.form.set('profession', 'pompier')
response = response.form.submit() response = response.form.submit()
assert urlparse(response['Location']).path == reverse('account_management') 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 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): def test_manager_email_verified_column_user(app, simple_user, superuser_or_admin):
login(app, superuser_or_admin, '/manage/') 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'}) 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): def test_manager_user_roles_visibility(app, simple_user, admin, ou1, ou2):
Role = get_role_model() Role = get_role_model()
role1 = Role.objects.create(name='Role 1', slug='role1', ou=ou1) role1 = Role.objects.create(name='Role 1', slug='role1', ou=ou1)