Projet

Général

Profil

0001-base_adresse-add-addresses-endpoint-39387.patch

Valentin Deniaud, 10 février 2020 17:43

Télécharger (18,5 ko)

Voir les différences:

Subject: [PATCH] base_adresse: add /addresses/ endpoint (#39387)

Compatible with wcs API.
 .../migrations/0016_auto_20200130_1604.py     |  35 +++++
 passerelle/apps/base_adresse/models.py        | 132 ++++++++++++-----
 tests/conftest.py                             |   5 +-
 tests/test_base_adresse.py                    | 133 +++++++++++++++++-
 4 files changed, 268 insertions(+), 37 deletions(-)
 create mode 100644 passerelle/apps/base_adresse/migrations/0016_auto_20200130_1604.py
passerelle/apps/base_adresse/migrations/0016_auto_20200130_1604.py
1
# -*- coding: utf-8 -*-
2
# Generated by Django 1.11.18 on 2020-01-30 15:04
3
from __future__ import unicode_literals
4

  
5
from django.db import migrations, models
6
import jsonfield.fields
7

  
8

  
9
class Migration(migrations.Migration):
10

  
11
    dependencies = [
12
        ('base_adresse', '0015_auto_20191206_1244'),
13
    ]
14

  
15
    operations = [
16
        migrations.CreateModel(
17
            name='AddressCacheModel',
18
            fields=[
19
                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
20
                ('api_id', models.CharField(max_length=30, unique=True)),
21
                ('data', jsonfield.fields.JSONField(default=dict)),
22
                ('timestamp', models.DateTimeField(auto_now=True)),
23
            ],
24
        ),
25
        migrations.AddField(
26
            model_name='baseadresse',
27
            name='latitude',
28
            field=models.FloatField(blank=True, help_text='Geographic priority for /addresses/ endpoint.', null=True, verbose_name='Latitude'),
29
        ),
30
        migrations.AddField(
31
            model_name='baseadresse',
32
            name='longitude',
33
            field=models.FloatField(blank=True, help_text='Geographic priority for /addresses/ endpoint.', null=True, verbose_name='Longitude'),
34
        ),
35
    ]
passerelle/apps/base_adresse/models.py
1 1
import bz2
2
import datetime
2 3
import unicodedata
3 4

  
5
from jsonfield import JSONField
4 6
from requests import RequestException
5 7

  
6 8
from django.db import connection, models
......
46 48
        blank=True,
47 49
        verbose_name=_('Postal codes or department number to get streets, separated with commas'))
48 50

  
51
    latitude = models.FloatField(
52
        null=True, blank=True,
53
        verbose_name=_('Latitude'),
54
        help_text=_('Geographic priority for /addresses/ endpoint.'),
55
    )
56
    longitude = models.FloatField(
57
        null=True, blank=True,
58
        verbose_name=_('Longitude'),
59
        help_text=_('Geographic priority for /addresses/ endpoint.'),
60
    )
61

  
49 62
    class Meta:
50 63
        verbose_name = _('Base Adresse Web Service')
51 64

  
65
    @staticmethod
66
    def format_address_data(data):
67
        result = {}
68
        result['lon'] = str(data['geometry']['coordinates'][0])
69
        result['lat'] = str(data['geometry']['coordinates'][1])
70
        result['address'] = {'country': 'France'}
71
        for prop, value in data['properties'].items():
72
            if prop in ('city', 'postcode', 'citycode'):
73
                result['address'][prop] = value
74
            elif prop == 'housenumber':
75
                result['address']['house_number'] = value
76
            elif prop == 'label':
77
                result['text'] = result['display_name'] = value
78
            elif prop == 'name':
79
                house_number = data['properties'].get('housenumber')
80
                if house_number and value.startswith(house_number):
81
                    value = value[len(house_number):].strip()
82
                result['address']['road'] = value
83
            elif prop == 'id':
84
                result['id'] = value
85
        return result
86

  
52 87
    @endpoint(pattern='(?P<q>.+)?$',
53
              description=_('Geocoding'),
88
              description=_('Addresses list'),
54 89
              parameters={
55
                  'q': {'description': _('Address'), 'example_value': '169 rue du chateau, paris'}
90
                  'id': {'description': _('Address identifier')},
91
                  'q': {'description': _('Address'), 'example_value': '169 rue du chateau, paris'},
92
                  'page_limit': {'description': _('Maximum number of results to return. Must be '
93
                                                  'lower than 20.')},
94
                  'zipcode': {'description': _('Zipcode'), 'example_value': '75014'},
95
                  'lat': {'description': _('Prioritize results according to coordinates. "lat" '
96
                                           'parameter must be present.')},
97
                  'lon': {'description': _('Prioritize results according to coordinates. "lon" '
98
                                           'parameter must be present.')},
56 99
              })
57
    def search(self, request, q, zipcode='', lat=None, lon=None, **kwargs):
58
        if kwargs.get('format', 'json') != 'json':
59
            raise NotImplementedError()
100
    def addresses(self, request, id=None, q=None, zipcode='', lat=None, lon=None, page_limit=5):
101
        if id is not None:
102
            try:
103
                address = AddressCacheModel.objects.get(api_id=id)
104
            except AddressCacheModel.DoesNotExist:
105
                return {'err': _('Address ID not found')}
106
            address.update_timestamp()
107
            return {'data': [address.data]}
60 108

  
61 109
        if not q:
62
            return []
110
            return {'data': []}
111

  
112
        try:
113
            if int(page_limit) > 20:
114
                page_limit = 20
115
        except ValueError:
116
            page_limit = 5
63 117

  
64 118
        scheme, netloc, path, params, query, fragment = urlparse.urlparse(self.service_url)
65 119
        path = urlparse.urljoin(path, 'search/')
66
        query_args = {'q': q, 'limit': 1}
120
        query_args = {'q': q, 'limit': page_limit}
67 121
        if zipcode:
68 122
            query_args['postcode'] = zipcode
69
        if lat and lon:
70
            query_args['lat'] = lat
71
            query_args['lon'] = lon
123
        if self.latitude and self.longitude or lat and lon:
124
            query_args['lat'] = lat or self.latitude
125
            query_args['lon'] = lon or self.longitude
72 126
        query = urlencode(query_args)
73 127
        url = urlparse.urlunparse((scheme, netloc, path, params, query, fragment))
74 128

  
......
78 132
        for feature in result_response.json().get('features'):
79 133
            if not feature['geometry']['type'] == 'Point':
80 134
                continue  # skip unknown
81
            result.append({
82
                'lon': str(feature['geometry']['coordinates'][0]),
83
                'lat': str(feature['geometry']['coordinates'][1]),
84
                'display_name': feature['properties']['label'],
85
            })
86
            break
135
            data = self.format_address_data(feature)
136
            result.append(data)
137
            address, created = AddressCacheModel.objects.get_or_create(
138
                api_id=data['id'], defaults={'data': data})
139
            if not created:
140
                address.update_timestamp()
87 141

  
88
        return result
142
        return {'data': result}
143

  
144
    @endpoint(pattern='(?P<q>.+)?$', description=_('Geocoding (Nominatim API)'),
145
              parameters={
146
                  'q': {'description': _('Address'), 'example_value': '169 rue du chateau, paris'},
147
              })
148
    def search(self, request, q, zipcode='', lat=None, lon=None, **kwargs):
149
        if kwargs.get('format', 'json') != 'json':
150
            raise NotImplementedError()
151
        result = self.addresses(request, q=q, zipcode=zipcode, lat=lat, lon=lon, page_limit=1)
152
        return result['data']
89 153

  
90 154
    @endpoint(description=_('Reverse geocoding'),
91 155
              parameters={
......
107 171
        for feature in result_response.json().get('features'):
108 172
            if not feature['geometry']['type'] == 'Point':
109 173
                continue  # skip unknown
110
            result = {}
111
            result['lon'] = str(feature['geometry']['coordinates'][0])
112
            result['lat'] = str(feature['geometry']['coordinates'][1])
113
            result['address'] = {'country': 'France'}
114
            for prop in feature['properties']:
115
                if prop in ('city', 'postcode', 'citycode'):
116
                    result['address'][prop] = feature['properties'][prop]
117
                elif prop == 'housenumber':
118
                    result['address']['house_number'] = feature['properties'][prop]
119
                elif prop == 'label':
120
                    result['display_name'] = feature['properties'][prop]
121
                elif prop == 'name':
122
                    house_number = feature['properties'].get('housenumber')
123
                    value = feature['properties'][prop]
124
                    if house_number and value.startswith(house_number):
125
                        value = value[len(house_number):].strip()
126
                    result['address']['road'] = value
174
            result = self.format_address_data(feature)
127 175
        return result
128 176

  
129 177
    @endpoint(description=_('Streets from zipcode'),
......
368 416
                    code=data['code'], zipcode=zipcode, defaults=defaults)
369 417
        CityModel.objects.filter(last_update__lt=start_update).delete()
370 418

  
419
    def clean_addresses_cache(self):
420
        old_addresses = AddressCacheModel.objects.filter(
421
            timestamp__lt=timezone.now() - datetime.timedelta(hours=1)
422
        )
423
        old_addresses.delete()
424

  
371 425
    def hourly(self):
372 426
        super(BaseAdresse, self).hourly()
427
        self.clean_addresses_cache()
373 428
        # don't wait for daily job to grab data
374 429
        if self.get_zipcodes() and not self.get_streets_queryset().exists():
375 430
            self.update_streets_data()
......
489 544

  
490 545
    def __str__(self):
491 546
        return '%s %s' % (self.zipcode, self.name)
547

  
548

  
549
class AddressCacheModel(models.Model):
550
    api_id = models.CharField(max_length=30, unique=True)
551
    data = JSONField()
552
    timestamp = models.DateTimeField(auto_now=True)
553

  
554
    def update_timestamp(self):
555
        self.save()
tests/conftest.py
1 1
import pytest
2
from httmock import urlmatch, HTTMock, response
2
from httmock import urlmatch, HTTMock, response, remember_called
3 3

  
4 4
import django_webtest
5 5

  
......
25 25

  
26 26

  
27 27
@urlmatch(netloc='^api-adresse.data.gouv.fr$', path='^/search/$')
28
@remember_called
28 29
def api_adresse_data_gouv_fr_search(url, request):
29 30
    return response(200, {
30 31
        "limit": 1,
......
95 96
@pytest.yield_fixture
96 97
def mock_api_adresse_data_gouv_fr_search():
97 98
    with HTTMock(api_adresse_data_gouv_fr_search):
98
        yield None
99
        yield api_adresse_data_gouv_fr_search
99 100

  
100 101

  
101 102
@pytest.yield_fixture
tests/test_base_adresse.py
1 1
# -*- coding: utf-8 -*-
2 2

  
3
import datetime
3 4
import os
4 5
import pytest
5 6
import mock
......
13 14
from django.utils.six.moves.urllib.parse import urljoin
14 15

  
15 16
from passerelle.apps.base_adresse.models import (BaseAdresse, StreetModel, CityModel,
16
                                                 DepartmentModel, RegionModel)
17
                                                 DepartmentModel, RegionModel, AddressCacheModel)
17 18

  
18 19
FAKED_CONTENT = json.dumps({
19 20
    "limit": 1,
......
118 119
                                                                zipcode='73, 73100, 97425,20000 '))
119 120

  
120 121

  
122
@pytest.fixture
123
def base_adresse_coordinates(db):
124
    return utils.setup_access_rights(BaseAdresse.objects.create(slug='base-adresse',
125
                                                                latitude=1.2, longitude=2.1))
126

  
127

  
121 128
@pytest.fixture
122 129
def street(db):
123 130
    return StreetModel.objects.create(city=u'Chambéry',
......
604 611
    call_command('cron', 'daily')
605 612
    assert mocked_get.call_count == 3
606 613
    assert not RegionModel.objects.exists()
614

  
615

  
616
@mock.patch('passerelle.utils.Request.get')
617
def test_base_adresse_addresses(mocked_get, app, base_adresse):
618
    endpoint = utils.generic_endpoint_url('base-adresse', 'addresses', slug=base_adresse.slug)
619
    assert endpoint == '/base-adresse/base-adresse/addresses'
620
    mocked_get.return_value = utils.FakedResponse(content=FAKED_CONTENT, status_code=200)
621
    resp = app.get(endpoint, params={'q': 'plop'}, status=200)
622
    data = resp.json['data'][0]
623
    assert data['lat'] == '47.474633'
624
    assert data['lon'] == '-0.593775'
625
    assert data['display_name'] == 'Rue Roger Halope 49000 Angers'
626
    assert data['text'] == 'Rue Roger Halope 49000 Angers'
627
    assert data['id'] == '49007_6950_be54bd'
628
    assert data['address']['city'] == 'Angers'
629
    assert data['address']['postcode'] == '49000'
630
    assert data['address']['citycode'] == '49007'
631
    assert data['address']['road'] == 'Rue Roger Halope'
632

  
633

  
634
@mock.patch('passerelle.utils.Request.get')
635
def test_base_adresse_addresses_qs_page_limit(mocked_get, app, base_adresse):
636
    resp = app.get('/base-adresse/%s/addresses?q=plop&page_limit=1' % base_adresse.slug)
637
    assert 'limit=1' in mocked_get.call_args[0][0]
638

  
639
    resp = app.get('/base-adresse/%s/addresses?q=plop&page_limit=100' % base_adresse.slug)
640
    assert 'limit=20' in mocked_get.call_args[0][0]
641

  
642
    resp = app.get('/base-adresse/%s/addresses?q=plop&page_limit=blabla' % base_adresse.slug,
643
                   status=400)
644
    assert 'invalid value' in resp.json['err_desc']
645

  
646

  
647
@mock.patch('passerelle.utils.Request.get')
648
def test_base_adresse_addresses_qs_coordinates(mocked_get, app, base_adresse_coordinates):
649
    resp = app.get('/base-adresse/%s/addresses?q=plop' % base_adresse_coordinates.slug)
650
    assert 'lat=%s' % base_adresse_coordinates.latitude in mocked_get.call_args[0][0]
651
    assert 'lon=%s' % base_adresse_coordinates.longitude in mocked_get.call_args[0][0]
652

  
653
    resp = app.get('/base-adresse/%s/addresses?q=plop&lat=42&lon=43' % base_adresse_coordinates.slug)
654
    assert 'lat=42' in mocked_get.call_args[0][0]
655
    assert 'lon=43' in mocked_get.call_args[0][0]
656

  
657

  
658
def test_base_adresse_addresses_cache(app, base_adresse, mock_api_adresse_data_gouv_fr_search):
659
    resp = app.get('/base-adresse/%s/addresses?q=plop' % base_adresse.slug)
660
    assert mock_api_adresse_data_gouv_fr_search.call['count'] == 1
661

  
662
    data = resp.json['data'][0]
663
    assert data['text'] == 'Rue Roger Halope 49000 Angers'
664

  
665
    api_id = data['id']
666
    assert AddressCacheModel.objects.filter(api_id=api_id).exists()
667
    assert AddressCacheModel.objects.count() == 1
668

  
669
    resp = app.get('/base-adresse/%s/addresses?id=%s' % (base_adresse.slug, api_id))
670
    assert mock_api_adresse_data_gouv_fr_search.call['count'] == 1  # no new call
671
    assert data['text'] == 'Rue Roger Halope 49000 Angers'
672
    assert 'address' in data
673

  
674
    resp = app.get('/base-adresse/%s/addresses?q=plop' % base_adresse.slug)
675
    assert AddressCacheModel.objects.count() == 1  # no new object has been created
676

  
677

  
678
def test_base_adresse_addresses_cache_err(app, base_adresse,
679
                                          mock_api_adresse_data_gouv_fr_search):
680
    resp = app.get('/base-adresse/%s/addresses?id=%s' % (base_adresse.slug, 'wrong_id'))
681
    assert mock_api_adresse_data_gouv_fr_search.call['count'] == 0
682
    assert 'err' in resp.json
683

  
684

  
685
@pytest.mark.usefixtures('mock_update_api_geo', 'mock_update_streets')
686
def test_base_adresse_addresses_clean_cache(app, base_adresse, freezer,
687
                                            mock_api_adresse_data_gouv_fr_search):
688
    resp = app.get('/base-adresse/%s/addresses?q=plop' % base_adresse.slug)
689
    assert AddressCacheModel.objects.count() == 1
690

  
691
    freezer.move_to(datetime.timedelta(minutes=30))
692
    call_command('cron', 'hourly')
693
    assert AddressCacheModel.objects.count() == 1
694

  
695
    freezer.move_to(datetime.timedelta(minutes=30, seconds=1))
696
    call_command('cron', 'hourly')
697
    assert AddressCacheModel.objects.count() == 0
698

  
699
    resp = app.get('/base-adresse/%s/addresses?q=plop' % base_adresse.slug)
700
    assert AddressCacheModel.objects.count() == 1
701

  
702
    # asking for the address again resets the timestamp
703
    freezer.move_to(datetime.timedelta(hours=1, seconds=1))
704
    resp = app.get('/base-adresse/%s/addresses?q=plop' % base_adresse.slug)
705
    call_command('cron', 'hourly')
706
    assert AddressCacheModel.objects.count() == 1
707

  
708
    freezer.move_to(datetime.timedelta(hours=1, seconds=1))
709
    resp = app.get('/base-adresse/%s/addresses?id=%s' % (base_adresse.slug, '49007_6950_be54bd'))
710
    call_command('cron', 'hourly')
711
    assert AddressCacheModel.objects.count() == 1
712

  
713

  
714
@mock.patch('passerelle.utils.Request.get')
715
def test_base_adresse_addresses_data_change(mocked_get, app, base_adresse):
716
    endpoint = utils.generic_endpoint_url('base-adresse', 'addresses', slug=base_adresse.slug)
717
    mocked_get.return_value = utils.FakedResponse(content=FAKED_CONTENT, status_code=200)
718

  
719
    # one user selects an address
720
    resp = app.get(endpoint, params={'q': 'plop'}, status=200)
721
    data = resp.json['data'][0]
722
    address_id, address_text = data['id'], data['text']
723

  
724
    # another requests the same while upstream data has been updated
725
    new_content = json.loads(FAKED_CONTENT)
726
    new_content['features'][0]['properties']['label'] = 'changed'
727
    mocked_get.return_value = utils.FakedResponse(content=json.dumps(new_content), status_code=200)
728
    resp = app.get(endpoint, params={'q': 'plop'}, status=200)
729

  
730
    # first user saves the form, data should not have changed
731
    resp = app.get(endpoint, params={'id': address_id}, status=200)
732
    assert resp.json['data'][0]['text'] == address_text
733

  
734
    # when cache is cleared, we get the updated data
735
    AddressCacheModel.objects.all().delete()
736
    resp = app.get(endpoint, params={'q': 'plop'}, status=200)
737
    assert resp.json['data'][0]['text'] == 'changed'
607
-