diff --git a/passerelle/apps/photon/migrations/0001_initial.py b/passerelle/apps/photon/migrations/0001_initial.py new file mode 100644 index 00000000..c93df3e5 --- /dev/null +++ b/passerelle/apps/photon/migrations/0001_initial.py @@ -0,0 +1,43 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.18 on 2021-02-07 22:48 +from __future__ import unicode_literals + +import django.contrib.postgres.fields.jsonb +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('base', '0029_auto_20210202_1627'), + ] + + operations = [ + migrations.CreateModel( + name='AddressCacheModel', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('api_id', models.CharField(max_length=32, unique=True)), + ('data', django.contrib.postgres.fields.jsonb.JSONField()), + ('timestamp', models.DateTimeField(auto_now=True)), + ], + ), + migrations.CreateModel( + name='Photon', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=50, verbose_name='Title')), + ('slug', models.SlugField(unique=True, verbose_name='Identifier')), + ('description', models.TextField(verbose_name='Description')), + ('service_url', models.CharField(default='https://photon.komoot.io/', help_text='Base Adresse Web Service URL', max_length=128, verbose_name='Service URL')), + ('latitude', models.FloatField(blank=True, help_text='Geographic priority for /addresses/ endpoint.', null=True, verbose_name='Latitude')), + ('longitude', models.FloatField(blank=True, help_text='Geographic priority for /addresses/ endpoint.', null=True, verbose_name='Longitude')), + ('users', models.ManyToManyField(blank=True, related_name='_photon_users_+', related_query_name='+', to='base.ApiUser')), + ], + options={ + 'verbose_name': 'Photon Web Service', + }, + ), + ] diff --git a/passerelle/apps/photon/migrations/__init__.py b/passerelle/apps/photon/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/passerelle/apps/photon/models.py b/passerelle/apps/photon/models.py new file mode 100644 index 00000000..9bb7ea5b --- /dev/null +++ b/passerelle/apps/photon/models.py @@ -0,0 +1,228 @@ +# passerelle - uniform access to multiple data sources and services +# Copyright (C) 2021 Entr'ouvert +# +# This program is free software: you can redistribute it and/or modify it +# under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +import datetime +from requests import RequestException + +from django.contrib.postgres.fields import JSONField +from django.db import models +from django.utils.http import urlencode +from django.utils.translation import ugettext_lazy as _ +from django.utils import timezone +from django.utils.six.moves.urllib import parse as urlparse + +from passerelle.base.models import BaseResource +from passerelle.utils.api import endpoint +from passerelle.utils.jsonresponse import APIError + + +class AddressCacheModel(models.Model): + api_id = models.CharField(max_length=32, unique=True) + data = JSONField() + timestamp = models.DateTimeField(auto_now=True) + + def update_timestamp(self): + self.save() + + +class Photon(BaseResource): + service_url = models.CharField( + max_length=128, + blank=False, + default='https://photon.komoot.io/', + verbose_name=_('Service URL'), + help_text=_('Base Adresse Web Service URL'), + ) + latitude = models.FloatField( + null=True, + blank=True, + verbose_name=_('Latitude'), + help_text=_('Geographic priority for /addresses/ endpoint.'), + ) + longitude = models.FloatField( + null=True, + blank=True, + verbose_name=_('Longitude'), + help_text=_('Geographic priority for /addresses/ endpoint.'), + ) + + category = _('Geographic information system') + + api_description = _( + 'photon is an open source geocoder built for OpenStreetMap data. ' + 'It provides search-as-you-type and multilingual support.' + ) + + class Meta: + verbose_name = _('Photon') + + @staticmethod + def format_address_data(data): + result = {} + result['lon'] = str(data['geometry']['coordinates'][0]) + result['lat'] = str(data['geometry']['coordinates'][1]) + result['address'] = {} + for prop, value in data['properties'].items(): + if prop in ('country', 'city', 'postcode'): + result['address'][prop] = value + elif prop == 'housenumber': + result['address']['house_number'] = value + elif prop == 'street': + result['address']['road'] = value + + result['text'] = '' + if result['address'].get('house_number'): + result['text'] += '%s, ' % result['address']['house_number'] + if result['address'].get('road'): + result['text'] += '%s ' % result['address']['road'] + if result['address'].get('postcode'): + result['text'] += '%s ' % result['address']['postcode'] + if result['address'].get('city'): + result['text'] += '%s' % result['address']['city'] + result['display_name'] = result['text'] + result['id'] = data['properties']['osm_id'] + return result + + @endpoint( + pattern='(?P.+)?$', + description=_('Addresses list'), + parameters={ + 'id': {'description': _('Address identifier')}, + 'q': {'description': _('Address'), 'example_value': '169 rue du chateau, paris'}, + 'page_limit': { + 'description': _('Maximum number of results to return. Must be ' 'lower than 20.') + }, + 'zipcode': {'description': _('Zipcode'), 'example_value': '75014'}, + 'lat': {'description': _('Prioritize results according to coordinates.')}, + 'lon': {'description': _('Prioritize results according to coordinates.')}, + }, + ) + def addresses( + self, request, id=None, q=None, zipcode=None, lat=None, lon=None, page_limit=5, lang='fr', bbox=None + ): + if id is not None: + try: + address = AddressCacheModel.objects.get(api_id=id) + except AddressCacheModel.DoesNotExist: + return {'err': _('Address ID not found')} + address.update_timestamp() + return {'data': [address.data]} + + if not q: + return {'data': []} + + try: + if int(page_limit) > 20: + page_limit = 20 + except ValueError: + page_limit = 5 + + scheme, netloc, path, params, query, fragment = urlparse.urlparse(self.service_url) + path = urlparse.urljoin(path, 'api/') + query_args = {'q': q, 'limit': page_limit, 'lang': lang} + if self.latitude and self.longitude or lat and lon: + query_args['lat'] = lat or self.latitude + query_args['lon'] = lon or self.longitude + if bbox: + query_args['bbox'] = bbox + query = urlencode(query_args) + url = urlparse.urlunparse((scheme, netloc, path, params, query, fragment)) + + result_response = self.requests.get(url) + try: + result_response.raise_for_status() + except RequestException as e: + raise APIError('Bad response code from API: %s' % e) + + result = [] + + for feature in result_response.json().get('features'): + if not feature['geometry']['type'] == 'Point': + continue # skip unknown + if zipcode and feature['properties'].get('postcode') != zipcode: + continue + data = self.format_address_data(feature) + result.append(data) + address, created = AddressCacheModel.objects.get_or_create( + api_id=data['id'], defaults={'data': data} + ) + if not created: + address.update_timestamp() + + return {'data': result} + + @endpoint( + description=_('Geocoding (Nominatim API)'), + pattern='(?P.+)?$', + parameters={ + 'q': {'description': _('Address'), 'example_value': '169 rue du chateau, paris'}, + 'zipcode': {'description': _('Zipcode')}, + 'lat': {'description': _('Prioritize results according to coordinates.')}, + 'lon': {'description': _('Prioritize results according to coordinates.')}, + }, + ) + def search(self, request, q, zipcode=None, lat=None, lon=None, **kwargs): + if kwargs.get('format', 'json') != 'json': + raise NotImplementedError() + lang = kwargs.get('accept-language', 'fr') + bbox = kwargs.get('viewbox') + result = self.addresses( + request, q=q, zipcode=zipcode, lat=lat, lon=lon, page_limit=1, lang=lang, bbox=bbox + ) + return result['data'] + + @endpoint( + description=_('Reverse geocoding (Nominatim API)'), + parameters={ + 'lat': {'description': _('Latitude'), 'example_value': 48.833708}, + 'lon': {'description': _('Longitude'), 'example_value': 2.323349}, + }, + ) + def reverse(self, request, lat, lon, **kwargs): + if kwargs.get('format', 'json') != 'json': + raise NotImplementedError() + + scheme, netloc, path, params, query, fragment = urlparse.urlparse(self.service_url) + path = urlparse.urljoin(path, 'reverse/') + query = urlencode({'lat': lat, 'lon': lon}) + url = urlparse.urlunparse((scheme, netloc, path, params, query, fragment)) + + result_response = self.requests.get(url) + try: + result_response.raise_for_status() + except RequestException as e: + raise APIError('Bad response code from API: %s' % e) + result = None + + for feature in result_response.json().get('features'): + if not feature['geometry']['type'] == 'Point': + continue # skip unknown + result = self.format_address_data(feature) + break + return result + + def check_status(self): + self.search(None, 'passerelle status check') + + def clean_addresses_cache(self): + old_addresses = AddressCacheModel.objects.filter( + timestamp__lt=timezone.now() - datetime.timedelta(hours=1) + ) + old_addresses.delete() + + def hourly(self): + super().hourly() + self.clean_addresses_cache() diff --git a/passerelle/settings.py b/passerelle/settings.py index 72bebf43..60be4a0f 100644 --- a/passerelle/settings.py +++ b/passerelle/settings.py @@ -155,6 +155,7 @@ INSTALLED_APPS = ( 'passerelle.apps.ovh', 'passerelle.apps.oxyd', 'passerelle.apps.phonecalls', + 'passerelle.apps.photon', 'passerelle.apps.solis', 'passerelle.apps.twilio', 'passerelle.apps.vivaticket', diff --git a/tests/test_photon.py b/tests/test_photon.py new file mode 100644 index 00000000..ecbe7878 --- /dev/null +++ b/tests/test_photon.py @@ -0,0 +1,309 @@ +# passerelle - uniform access to multiple data sources and services +# Copyright (C) 2021 Entr'ouvert +# +# This program is free software: you can redistribute it and/or modify it +# under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +import datetime +from httmock import urlmatch, HTTMock, response, remember_called +import json +import mock +import pytest +import utils + +from requests.exceptions import HTTPError + +from django.core.management import call_command + +from passerelle.apps.photon.models import Photon, AddressCacheModel + + +CONTENT = { + "features": [ + { + "geometry": {"coordinates": [4.8522272, 45.7587414], "type": "Point"}, + "properties": { + "city": "Lyon 3ème Arrondissement", + "country": "France", + "housenumber": "208", + "osm_id": 154419, + "osm_key": "place", + "osm_type": "N", + "osm_value": "house", + "postcode": "69003", + "street": "Rue Garibaldi", + "type": "house", + }, + "type": "Feature", + }, + { + "geometry": {"coordinates": [4.8522681, 45.7585214], "type": "Point"}, + "properties": { + "city": "Lyon 3ème Arrondissement", + "country": "France", + "housenumber": "208bis", + "osm_id": 153400, + "osm_key": "place", + "osm_type": "N", + "osm_value": "house", + "postcode": "69003", + "street": "Rue Garibaldi", + "type": "house", + }, + "type": "Feature", + }, + ], + "type": "FeatureCollection", +} + +FAKED_CONTENT = json.dumps(CONTENT) + + +@urlmatch(netloc='^photon.komoot.io$', path='^/api/$') +@remember_called +def photon_search(url, request): + return response(200, CONTENT, request=request) + + +@urlmatch(netloc='^photon.komoot.io$', path='^/reverse/$') +def photon_reverse(url, request): + return response(200, CONTENT, request=request) + + +@pytest.yield_fixture +def mock_photon_search(): + with HTTMock(photon_search): + yield photon_search + + +@pytest.yield_fixture +def mock_photon_reverse(): + with HTTMock(photon_reverse): + yield None + + +@pytest.fixture +def photon(db): + return utils.setup_access_rights(Photon.objects.create(slug='test')) + + +@pytest.fixture +def photon_coordinates(db): + return utils.setup_access_rights(Photon.objects.create(slug='test', latitude=1.2, longitude=2.1)) + + +@mock.patch('passerelle.utils.Request.get') +def test_photon_search(mocked_get, app, photon): + photon.service_url = 'http://example.net/path/' + photon.save() + endpoint = utils.generic_endpoint_url('photon', 'search', slug=photon.slug) + mocked_get.return_value = utils.FakedResponse(content=FAKED_CONTENT, status_code=200) + resp = app.get(endpoint, params={'q': 'plop'}, status=200) + assert mocked_get.call_args[0][0].startswith('http://example.net/path/api/?') + data = resp.json[0] + assert data['lat'] == '45.7587414' + assert data['lon'] == '4.8522272' + assert data['display_name'] == '208, Rue Garibaldi 69003 Lyon 3ème Arrondissement' + + +def test_photon_search_qs(app, photon, mock_photon_search): + resp = app.get('/photon/%s/search?q=plop' % photon.slug) + assert 'display_name' in resp.json[0] + + +def test_photon_search_qs_zipcode(app, photon, mock_photon_search): + resp = app.get('/photon/%s/search?q=plop&zipcode=69003' % photon.slug) + assert 'display_name' in resp.json[0] + + +@mock.patch('passerelle.utils.Request.get') +def test_photon_search_qs_lat_lon(mocked_get, app, photon): + app.get('/photon/%s/search?q=plop&lat=0&lon=1' % photon.slug) + assert 'lat=0' in mocked_get.call_args[0][0] + assert 'lon=1' in mocked_get.call_args[0][0] + + +@mock.patch('passerelle.utils.Request.get') +def test_photon_search_qs_viewbox(mocked_get, app, photon): + app.get('/photon/%s/search?q=plop&viewbox=4.830,45.753,4.831,45.754&bounded=1' % photon.slug) + assert ( + mocked_get.call_args[0][0] + == 'https://photon.komoot.io/api/?q=plop&limit=1&lang=fr&bbox=4.830%2C45.753%2C4.831%2C45.754' + ) + + +def test_photon_search_qs_empty(app, photon, mock_photon_search): + resp = app.get('/photon/%s/search?q=' % photon.slug) + assert len(resp.json) == 0 + + +def test_photon_search_qs_parameters_error(app, photon, mock_photon_search): + resp = app.get('/photon/%s/search' % photon.slug, status=400) + assert resp.json['err'] == 1 + assert resp.json['err_class'] == 'passerelle.views.WrongParameter' + assert resp.json['err_desc'] == u"missing parameters: 'q'." + + +@mock.patch('passerelle.utils.Request.get') +def test_photon_api_error(mocked_get, app, photon): + def raise_for_status(): + raise HTTPError("400 Client Error: Bad Request for url: xxx") + + response = utils.FakedResponse(content=json.dumps({'title': 'error'}), status_code=400) + response.raise_for_status = raise_for_status + mocked_get.return_value = response + resp = app.get('/photon/%s/search' % photon.slug, params={'q': 'plop'}, status=200) + + assert resp.json['err'] == 1 + + +@mock.patch('passerelle.utils.Request.get') +def test_photon_reverse_path(mocked_get, app, photon): + mocked_get.return_value = utils.FakedResponse(content=json.dumps({'features': []}), status_code=200) + photon.service_url = 'http://example.net/path/' + photon.save() + app.get('/photon/%s/reverse?lon=4.8522272&lat=45.7587414' % photon.slug) + assert mocked_get.call_args[0][0].startswith('http://example.net/path/reverse/?') + + +def test_photon_reverse(app, photon, mock_photon_reverse): + resp = app.get('/photon/%s/reverse?lon=4.8522272&lat=45.7587414' % photon.slug) + data = resp.json + assert 'display_name' in data + assert data['address']['city'] == 'Lyon 3ème Arrondissement' + assert data['address']['postcode'] == '69003' + assert data['address']['house_number'] == '208' + + +@mock.patch('passerelle.utils.Request.get') +def test_photon_addresses(mocked_get, app, photon): + endpoint = utils.generic_endpoint_url('photon', 'addresses', slug=photon.slug) + assert endpoint == '/photon/test/addresses' + mocked_get.return_value = utils.FakedResponse(content=FAKED_CONTENT, status_code=200) + resp = app.get(endpoint, params={'q': 'plop'}, status=200) + data = resp.json['data'][0] + assert data['lat'] == '45.7587414' + assert data['lon'] == '4.8522272' + assert data['display_name'] == '208, Rue Garibaldi 69003 Lyon 3ème Arrondissement' + assert data['text'] == '208, Rue Garibaldi 69003 Lyon 3ème Arrondissement' + assert data['id'] == 154419 + assert data['address']['city'] == 'Lyon 3ème Arrondissement' + assert data['address']['postcode'] == '69003' + assert data['address']['road'] == 'Rue Garibaldi' + + +@mock.patch('passerelle.utils.Request.get') +def test_photon_addresses_qs_page_limit(mocked_get, app, photon): + resp = app.get('/photon/%s/addresses?q=plop&page_limit=1' % photon.slug) + assert 'limit=1' in mocked_get.call_args[0][0] + + resp = app.get('/photon/%s/addresses?q=plop&page_limit=100' % photon.slug) + assert 'limit=20' in mocked_get.call_args[0][0] + + resp = app.get('/photon/%s/addresses?q=plop&page_limit=blabla' % photon.slug, status=400) + assert 'invalid value' in resp.json['err_desc'] + + +def test_photon_addresses_qs_zipcode(app, photon, mock_photon_search): + resp = app.get('/photon/%s/addresses?q=plop&zipcode=69003' % photon.slug) + assert 'display_name' in resp.json['data'][0] + + +@mock.patch('passerelle.utils.Request.get') +def test_photon_addresses_qs_coordinates(mocked_get, app, photon_coordinates): + resp = app.get('/photon/%s/addresses?q=plop' % photon_coordinates.slug) + assert 'lat=%s' % photon_coordinates.latitude in mocked_get.call_args[0][0] + assert 'lon=%s' % photon_coordinates.longitude in mocked_get.call_args[0][0] + + resp = app.get('/photon/%s/addresses?q=plop&lat=42&lon=43' % photon_coordinates.slug) + assert 'lat=42' in mocked_get.call_args[0][0] + assert 'lon=43' in mocked_get.call_args[0][0] + + +def test_photon_addresses_cache(app, photon, mock_photon_search): + resp = app.get('/photon/%s/addresses?q=plop' % photon.slug) + assert mock_photon_search.call['count'] == 1 + + data = resp.json['data'][0] + assert data['text'] == '208, Rue Garibaldi 69003 Lyon 3ème Arrondissement' + + api_id = data['id'] + assert AddressCacheModel.objects.filter(api_id=api_id).exists() + assert AddressCacheModel.objects.count() == 2 + + resp = app.get('/photon/%s/addresses?id=%s' % (photon.slug, api_id)) + assert mock_photon_search.call['count'] == 1 # no new call + assert data['text'] == '208, Rue Garibaldi 69003 Lyon 3ème Arrondissement' + assert 'address' in data + + resp = app.get('/photon/%s/addresses?q=plop' % photon.slug) + assert AddressCacheModel.objects.count() == 2 # no new object has been created + + +def test_photon_addresses_cache_err(app, photon, mock_photon_search): + resp = app.get('/photon/%s/addresses?id=%s' % (photon.slug, 'wrong_id')) + assert mock_photon_search.call['count'] == 0 + assert 'err' in resp.json + + +def test_photon_addresses_clean_cache(app, photon, freezer, mock_photon_search): + app.get('/photon/%s/addresses?q=plop' % photon.slug) + assert AddressCacheModel.objects.count() == 2 + + freezer.move_to(datetime.timedelta(minutes=30)) + call_command('cron', 'hourly') + assert AddressCacheModel.objects.count() == 2 + + freezer.move_to(datetime.timedelta(minutes=30, seconds=1)) + call_command('cron', 'hourly') + assert AddressCacheModel.objects.count() == 0 + + app.get('/photon/%s/addresses?q=plop' % photon.slug) + assert AddressCacheModel.objects.count() == 2 + + # asking for the address again resets the timestamp + freezer.move_to(datetime.timedelta(hours=1, seconds=1)) + app.get('/photon/%s/addresses?q=plop' % photon.slug) + call_command('cron', 'hourly') + assert AddressCacheModel.objects.count() == 2 + + freezer.move_to(datetime.timedelta(hours=1, seconds=1)) + app.get('/photon/%s/addresses?id=%s' % (photon.slug, '154419')) + call_command('cron', 'hourly') + assert AddressCacheModel.objects.count() == 1 + + +@mock.patch('passerelle.utils.Request.get') +def test_photon_addresses_data_change(mocked_get, app, photon): + endpoint = utils.generic_endpoint_url('photon', 'addresses', slug=photon.slug) + mocked_get.return_value = utils.FakedResponse(content=FAKED_CONTENT, status_code=200) + + # one user selects an address + resp = app.get(endpoint, params={'q': 'plop'}, status=200) + data = resp.json['data'][0] + address_id, address_text = data['id'], data['text'] + + # another requests the same while upstream data has been updated + new_content = json.loads(FAKED_CONTENT) + new_content['features'][0]['properties']['housenumber'] = 'changed' + mocked_get.return_value = utils.FakedResponse(content=json.dumps(new_content), status_code=200) + resp = app.get(endpoint, params={'q': 'plop'}, status=200) + + # first user saves the form, data should not have changed + resp = app.get(endpoint, params={'id': address_id}, status=200) + assert resp.json['data'][0]['text'] == address_text + + # when cache is cleared, we get the updated data + AddressCacheModel.objects.all().delete() + resp = app.get(endpoint, params={'q': 'plop'}, status=200) + assert 'changed' in resp.json['data'][0]['text']