From b951e02c6ad9f92d32f2b1c87ad0dcb0470e4844 Mon Sep 17 00:00:00 2001
From: Valentin Deniaud <vdeniaud@entrouvert.com>
Date: Mon, 25 Nov 2019 18:00:33 +0100
Subject: [PATCH 2/2] =?UTF-8?q?base=5Fadresse:=20add=20API=20G=C3=A9o=20en?=
 =?UTF-8?q?dpoints=20(#11497)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Namely /cities/, /counties/ and /regions/.
---
 .../migrations/0015_auto_20191128_1416.py     |  30 +++
 passerelle/apps/base_adresse/models.py        | 140 +++++++++++-
 tests/test_base_adresse.py                    | 216 ++++++++++++++++++
 3 files changed, 383 insertions(+), 3 deletions(-)
 create mode 100644 passerelle/apps/base_adresse/migrations/0015_auto_20191128_1416.py

diff --git a/passerelle/apps/base_adresse/migrations/0015_auto_20191128_1416.py b/passerelle/apps/base_adresse/migrations/0015_auto_20191128_1416.py
new file mode 100644
index 00000000..7923b805
--- /dev/null
+++ b/passerelle/apps/base_adresse/migrations/0015_auto_20191128_1416.py
@@ -0,0 +1,30 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.18 on 2019-11-28 13:16
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('base_adresse', '0014_auto_20190207_0456'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='baseadresse',
+            name='api_geo_url',
+            field=models.CharField(default=b'https://geo.api.gouv.fr/', help_text='Base Adresse API Geo URL', max_length=128, verbose_name='API Geo URL'),
+        ),
+        migrations.AddField(
+            model_name='baseadresse',
+            name='county',
+            field=models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='County code to get cities from'),
+        ),
+        migrations.AddField(
+            model_name='baseadresse',
+            name='region',
+            field=models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='Region code to get cities and counties from'),
+        ),
+    ]
diff --git a/passerelle/apps/base_adresse/models.py b/passerelle/apps/base_adresse/models.py
index 64f2767b..5989c9a8 100644
--- a/passerelle/apps/base_adresse/models.py
+++ b/passerelle/apps/base_adresse/models.py
@@ -23,17 +23,34 @@ class BaseAdresse(BaseResource):
         verbose_name=_('Service URL'),
         help_text=_('Base Adresse Web Service URL'))
 
+    api_geo_url = models.CharField(
+        max_length=128, blank=False,
+        default='https://geo.api.gouv.fr/',
+        verbose_name=_('API Geo URL'),
+        help_text=_('Base Adresse API Geo URL'))
+
     category = _('Geographic information system')
 
-    api_description = _("The API is a partial view of OpenStreetMap's Nominatim "
-                        "own API; it currently doesn't support all parameters and "
-                        "is limited to the JSON format.")
+    api_description = _("The geocoding endpoints are a partial view of OpenStreetMap's "
+                        "Nominatim own API; it currently doesn't support all parameters and "
+                        "is limited to the JSON format. The cities, counties and regions "
+                        "endpoints source data from API Geo.")
 
     zipcode = models.CharField(
         max_length=600,
         blank=True,
         verbose_name=_('Postal codes or county number to get streets, separated with commas'))
 
+    county = models.PositiveSmallIntegerField(
+        blank=True,
+        null=True,
+        verbose_name=_('County code to get cities from'))
+
+    region = models.PositiveSmallIntegerField(
+        blank=True,
+        null=True,
+        verbose_name=_('Region code to get cities and counties from'))
+
     class Meta:
         verbose_name = _('Base Adresse Web Service')
 
@@ -152,6 +169,123 @@ class BaseAdresse(BaseResource):
 
         return {'data': result}
 
+    @endpoint(description=_('Cities list'),
+              parameters={
+                  'zipcode': {'description': _('Zipcode'), 'example_value': '75014'},
+                  'q': {'description': _("City's name"), 'example_value': 'Paris'},
+                  'code': {'description': _('City code (INSEE)'), 'example_value': '75056'},
+                  'region_code': {'description': _('Region code'), 'example_value': '11'},
+                  'county_code': {'description': _('County code'), 'example_value': '75'},
+              })
+    def cities(self, request, id=None, zipcode=None, q=None, code=None,
+               region_code=None, county_code=None, **kwargs):
+        if id is not None:
+            code, zipcode = id.split('.')
+
+        request_data = {
+            'format': 'json',
+            'boost': 'population',
+            'fields': 'nom,code,codesPostaux,surface,codeDepartement,'
+            'departement,codeRegion,region,population',
+        }
+
+        if code:
+            request_data['code'] = code
+        if zipcode:
+            request_data['codePostal'] = zipcode
+        if q:
+            request_data['nom'] = q
+        if self.county or county_code:
+            request_data['codeDepartement'] = county_code or self.county
+        if self.region or region_code:
+            request_data['codeRegion'] = region_code or self.region
+
+        scheme, netloc, path, params, query, fragment = urlparse.urlparse(self.api_geo_url)
+        path = os.path.join(path, 'communes/')
+        query = urlencode(request_data)
+        url = urlparse.urlunparse((scheme, netloc, path, params, query, fragment))
+
+        result_response = self.requests.get(url)
+
+        data = []
+        for city in result_response.json():
+            zipcodes = [zipcode] if zipcode else city['codesPostaux']
+            for zipcode in zipcodes:
+                new_city = city.copy()
+                new_city['codePostal'] = zipcode
+                new_city['id'] = '.'.join((city['code'], zipcode))
+                new_city['text'] = '%s (%s)' % (city['nom'], zipcode)
+                data.append(new_city)
+
+        return {'data': data}
+
+    @endpoint(description=_('Counties list'),
+              parameters={
+                  'q': {'description': _('County name'), 'example_value': 'Nord'},
+                  'code': {'description': _('County code'), 'example_value': '59'},
+                  'region_code': {'description': _('Region code'), 'example_value': '32'},
+              })
+    def counties(self, request, id=None, q=None, code=None, region_code=None, **kwargs):
+        if id is not None:
+            code = id
+
+        request_data = {
+            'fields': 'nom,code,codeRegion,region',
+        }
+
+        if code:
+            request_data['code'] = code
+        if q:
+            request_data['nom'] = q
+        if self.region or region_code:
+            request_data['codeRegion'] = region_code or self.region
+
+        scheme, netloc, path, params, query, fragment = urlparse.urlparse(self.api_geo_url)
+        path = os.path.join(path, 'departements/')
+        query = urlencode(request_data)
+        url = urlparse.urlunparse((scheme, netloc, path, params, query, fragment))
+
+        result_response = self.requests.get(url)
+
+        data = result_response.json()
+        for county in data:
+            county['id'] = county['code']
+            county['text'] = '%s (%s)' % (county['nom'], county['code'])
+
+        return {'data': data}
+
+    @endpoint(description=_('Regions list'),
+              parameters={
+                  'q': {'description': _('Region name'), 'example_value': 'Hauts-de-France'},
+                  'code': {'description': _('Region code'), 'example_value': '32'},
+              })
+    def regions(self, request, id=None, q=None, code=None, **kwargs):
+        if id is not None:
+            code = id
+
+        request_data = {
+            'fields': 'nom,code',
+        }
+
+        if code:
+            request_data['code'] = code
+        if q:
+            request_data['nom'] = q
+
+        scheme, netloc, path, params, query, fragment = urlparse.urlparse(self.api_geo_url)
+        path = os.path.join(path, 'regions/')
+        query = urlencode(request_data)
+        url = urlparse.urlunparse((scheme, netloc, path, params, query, fragment))
+
+        result_response = self.requests.get(url)
+
+        data = result_response.json()
+        for region in data:
+            region['id'] = region['code']
+            region['text'] = region['nom']
+
+        return {'data': data}
+
     def check_status(self):
         if self.service_url == 'https://api-adresse.data.gouv.fr/':
             result = self.search(None, '169 rue du chateau, paris')
diff --git a/tests/test_base_adresse.py b/tests/test_base_adresse.py
index acbbb94b..b5e43a4d 100644
--- a/tests/test_base_adresse.py
+++ b/tests/test_base_adresse.py
@@ -7,6 +7,7 @@ import utils
 import json
 
 from django.core.management import call_command
+from django.utils.six.moves.urllib.parse import quote
 
 from passerelle.apps.base_adresse.models import BaseAdresse, StreetModel
 
@@ -41,6 +42,59 @@ FAKED_CONTENT = json.dumps({
 
 FAKE_DATA = ''
 
+FAKE_API_GEO = json.dumps([{
+    "code": "75056",
+    "codeDepartement": "75",
+    "codeRegion": "11",
+    "codesPostaux": [
+        "75001",
+        "75002",
+    ],
+    "departement": {
+        "code": "75",
+        "nom": "Paris"
+    },
+    "nom": "Paris",
+    "population": 2190327,
+    "region": {
+        "code": "11",
+        "nom": "Île-de-France"
+    },
+    "surface": 10528.81
+}])
+
+FAKE_API_GEO_COUNTIES = [
+    {
+        "code": "59",
+        "codeRegion": "32",
+        "nom": "Nord",
+        "region": {
+            "code": "32",
+            "nom": "Hauts-de-France"
+        }
+    },
+    {
+        "code": "58",
+        "codeRegion": "27",
+        "nom": "Nievre",
+        "region": {
+            "code": "27",
+            "nom": "Bourgogne-Franche-Comté"
+        }
+    }
+]
+
+FAKE_API_GEO_REGIONS = [
+    {
+        "code": "32",
+        "nom": "Hauts-de-France"
+    },
+    {
+        "code": "24",
+        "nom": "Centre-Val de Loire"
+    }
+]
+
 
 @pytest.fixture
 def base_adresse(db):
@@ -258,3 +312,165 @@ def test_base_adresse_command_update_multiple(mocked_get, db, base_adresse_multi
     mocked_get.assert_any_call('http://bano.openstreetmap.fr/BAN_odbl/BAN_odbl_2A-json.bz2')
     mocked_get.assert_any_call('http://bano.openstreetmap.fr/BAN_odbl/BAN_odbl_2B-json.bz2')
     assert StreetModel.objects.count() == 5
+
+
+@mock.patch('passerelle.utils.Request.get')
+def test_base_adresse_cities(mocked_get, app, base_adresse):
+    endpoint = utils.generic_endpoint_url('base-adresse', 'cities',
+                                          slug=base_adresse.slug)
+    assert endpoint == '/base-adresse/base-adresse/cities'
+    mocked_get.return_value = utils.FakedResponse(content=FAKE_API_GEO, status_code=200)
+    resp = app.get(endpoint, params={}, status=200)
+    data = resp.json['data']
+    assert len(data) == 2
+    paris1, paris2 = data
+    assert paris1.pop('id') == '75056.75001'
+    assert paris2.pop('id') == '75056.75002'
+    assert paris1.pop('codePostal') == '75001'
+    assert paris2.pop('codePostal') == '75002'
+    assert paris1.pop('text') == 'Paris (75001)'
+    assert paris2.pop('text') == 'Paris (75002)'
+    assert paris1 == paris2
+
+
+@mock.patch('passerelle.utils.Request.get')
+def test_base_adresse_cities_zipcode(mocked_get, app, base_adresse):
+    endpoint = utils.generic_endpoint_url('base-adresse', 'cities',
+                                          slug=base_adresse.slug)
+    mocked_get.return_value = utils.FakedResponse(content=FAKE_API_GEO, status_code=200)
+    resp = app.get(endpoint, params={'zipcode': 75002}, status=200)
+    data = resp.json['data']
+    assert len(data) == 1
+    assert data[0]['id'] == '75056.75002'
+
+
+@mock.patch('passerelle.utils.Request.get')
+def test_base_adresse_cities_id(mocked_get, app, base_adresse):
+    endpoint = utils.generic_endpoint_url('base-adresse', 'cities',
+                                          slug=base_adresse.slug)
+    mocked_get.return_value = utils.FakedResponse(content=FAKE_API_GEO, status_code=200)
+    resp = app.get(endpoint, params={'zipcode': 75002, 'code': 75056}, status=200)
+    data = resp.json['data']
+    assert len(data) == 1
+
+    resp = app.get(endpoint, params={'id': data[0]['id']}, status=200)
+    new_data = resp.json['data']
+    assert new_data == data
+
+
+@mock.patch('passerelle.utils.Request.get')
+def test_base_adresse_cities_path(mocked_get, app, base_adresse):
+    base_adresse.api_geo_url = 'http://example.net/path/'
+    base_adresse.save()
+    endpoint = utils.generic_endpoint_url('base-adresse', 'cities',
+                                          slug=base_adresse.slug)
+    mocked_get.return_value = utils.FakedResponse(content=FAKE_API_GEO, status_code=200)
+    resp = app.get(endpoint, params={}, status=200)
+    assert mocked_get.call_args[0][0].startswith('http://example.net/path/communes/?')
+
+
+@mock.patch('passerelle.utils.Request.get')
+def test_base_adresse_cities_qs(mocked_get, app, base_adresse):
+    endpoint = utils.generic_endpoint_url('base-adresse', 'cities',
+                                          slug=base_adresse.slug)
+    mocked_get.return_value = utils.FakedResponse(content=FAKE_API_GEO, status_code=200)
+    resp = app.get(endpoint, params={}, status=200)
+    assert 'format=json' in mocked_get.call_args[0][0]
+    assert 'boost=population' in mocked_get.call_args[0][0]
+    assert 'fields=' + quote('nom,code,codesPostaux,surface,codeDepartement,'
+        'departement,codeRegion,region,population') in mocked_get.call_args[0][0]
+    assert not 'codeRegion=' in mocked_get.call_args[0][0]
+    assert not 'codeDepartement=' in mocked_get.call_args[0][0]
+
+
+@mock.patch('passerelle.utils.Request.get')
+def test_base_adresse_cities_qs_county_region(mocked_get, app, base_adresse):
+    base_adresse.county = 75
+    base_adresse.region = 11
+    base_adresse.save()
+    endpoint = utils.generic_endpoint_url('base-adresse', 'cities',
+                                          slug=base_adresse.slug)
+    mocked_get.return_value = utils.FakedResponse(content=FAKE_API_GEO, status_code=200)
+    resp = app.get(endpoint, params={}, status=200)
+    assert 'codeRegion=11' in mocked_get.call_args[0][0]
+    assert 'codeDepartement=75' in mocked_get.call_args[0][0]
+
+    resp = app.get(endpoint, params={'county_code': 42, 'region_code': 42}, status=200)
+    assert 'codeRegion=42' in mocked_get.call_args[0][0]
+    assert 'codeDepartement=42' in mocked_get.call_args[0][0]
+
+
+@mock.patch('passerelle.utils.Request.get')
+def test_base_adresse_cities_qs_filter(mocked_get, app, base_adresse):
+    endpoint = utils.generic_endpoint_url('base-adresse', 'cities',
+                                          slug=base_adresse.slug)
+    mocked_get.return_value = utils.FakedResponse(content=FAKE_API_GEO, status_code=200)
+    resp = app.get(endpoint, params={'q': 'Par', 'zipcode': 75001, 'code': 75056},
+                   status=200)
+    assert 'nom=Par' in mocked_get.call_args[0][0]
+    assert 'codePostal=75001' in mocked_get.call_args[0][0]
+    assert 'code=75056' in mocked_get.call_args[0][0]
+
+    resp = app.get(endpoint, params={'id': '75056.75001'}, status=200)
+    assert 'codePostal=75001' in mocked_get.call_args[0][0]
+    assert 'code=75056' in mocked_get.call_args[0][0]
+
+
+@mock.patch('passerelle.utils.Request.get')
+def test_base_adresse_counties(mocked_get, app, base_adresse):
+    endpoint = utils.generic_endpoint_url('base-adresse', 'counties',
+                                          slug=base_adresse.slug)
+    assert endpoint == '/base-adresse/base-adresse/counties'
+    mocked_get.return_value = utils.FakedResponse(
+        content=json.dumps(FAKE_API_GEO_COUNTIES), status_code=200)
+    resp = app.get(endpoint, params={}, status=200)
+    data = resp.json['data']
+    assert data[0]['id'] == '59'
+    assert data[0]['text'] == 'Nord (59)'
+    assert data[1]['id'] == '58'
+    assert data[1]['text'] == 'Nievre (58)'
+
+
+@mock.patch('passerelle.utils.Request.get')
+def test_base_adresse_counties_qs(mocked_get, app, base_adresse):
+    endpoint = utils.generic_endpoint_url('base-adresse', 'counties',
+                                          slug=base_adresse.slug)
+    mocked_get.return_value = utils.FakedResponse(
+        content=json.dumps([FAKE_API_GEO_COUNTIES[0]]), status_code=200)
+    resp = app.get(endpoint, params={'code': '59', 'q': 'N', 'region_code': '32'}, status=200)
+    assert 'fields=' + quote('nom,code,codeRegion,region') in mocked_get.call_args[0][0]
+    assert 'nom=N' in mocked_get.call_args[0][0]
+    assert 'code=59' in mocked_get.call_args[0][0]
+    assert 'codeRegion=32' in mocked_get.call_args[0][0]
+
+    resp = app.get(endpoint, params={'id': '59'}, status=200)
+    assert 'code=59' in mocked_get.call_args[0][0]
+
+
+@mock.patch('passerelle.utils.Request.get')
+def test_base_adresse_regions(mocked_get, app, base_adresse):
+    endpoint = utils.generic_endpoint_url('base-adresse', 'regions',
+                                          slug=base_adresse.slug)
+    assert endpoint == '/base-adresse/base-adresse/regions'
+    mocked_get.return_value = utils.FakedResponse(
+        content=json.dumps(FAKE_API_GEO_REGIONS), status_code=200)
+    resp = app.get(endpoint, params={}, status=200)
+    data = resp.json['data']
+    assert data[0]['id'] == '32'
+    assert data[0]['text'] == 'Hauts-de-France'
+    assert data[1]['id'] == '24'
+    assert data[1]['text'] == 'Centre-Val de Loire'
+
+
+@mock.patch('passerelle.utils.Request.get')
+def test_base_adresse_regions_qs(mocked_get, app, base_adresse):
+    endpoint = utils.generic_endpoint_url('base-adresse', 'regions',
+                                          slug=base_adresse.slug)
+    mocked_get.return_value = utils.FakedResponse(
+        content=json.dumps([FAKE_API_GEO_REGIONS[0]]), status_code=200)
+    resp = app.get(endpoint, params={'code': '32', 'q': 'Hauts'}, status=200)
+    assert 'nom=Hauts' in mocked_get.call_args[0][0]
+    assert 'code=32' in mocked_get.call_args[0][0]
+
+    resp = app.get(endpoint, params={'id': '32'}, status=200)
+    assert 'code=32' in mocked_get.call_args[0][0]
-- 
2.20.1

