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)