Projet

Général

Profil

0002-add-sectorization-system-56001.patch

Thomas Noël, 01 septembre 2021 12:28

Télécharger (23,2 ko)

Voir les différences:

Subject: [PATCH 2/2] add sectorization system (#56001)

 passerelle/address/__init__.py                |   0
 passerelle/address/models.py                  | 120 +++++++++
 .../migrations/0025_baseadresse_sectors.py    |  19 ++
 passerelle/apps/base_adresse/models.py        |  16 +-
 passerelle/settings.py                        |   1 +
 passerelle/utils/__init__.py                  |   8 +-
 tests/test_address.py                         | 236 ++++++++++++++++++
 tests/test_cron.py                            |   2 +-
 tests/test_import_export.py                   |  43 +++-
 9 files changed, 434 insertions(+), 11 deletions(-)
 create mode 100644 passerelle/address/__init__.py
 create mode 100644 passerelle/address/models.py
 create mode 100644 passerelle/apps/base_adresse/migrations/0025_baseadresse_sectors.py
 create mode 100644 tests/test_address.py
passerelle/address/models.py
1
# passerelle - uniform access to multiple data sources and services
2
# Copyright (C) 2021  Entr'ouvert
3
#
4
# This program is free software: you can redistribute it and/or modify it
5
# under the terms of the GNU Affero General Public License as published
6
# by the Free Software Foundation, either version 3 of the License, or
7
# (at your option) any later version.
8
#
9
# This program is distributed in the hope that it will be useful,
10
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12
# GNU Affero General Public License for more details.
13
#
14
# You should have received a copy of the GNU Affero General Public License
15
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
16

  
17
from urllib.parse import urljoin
18

  
19
from django.conf import settings
20
from django.db import models
21
from django.db.models import Q
22
from django.utils.translation import ugettext_lazy as _
23

  
24
from passerelle.apps.sector.models import (
25
    MAX_HOUSENUMBER,
26
    PARITY_ALL,
27
    PARITY_EVEN,
28
    PARITY_ODD,
29
    Sectorization,
30
    SectorResource,
31
)
32
from passerelle.base.models import BaseResource
33
from passerelle.utils.api import endpoint
34

  
35

  
36
class AddressResource(BaseResource):
37
    sectors = models.ManyToManyField(SectorResource, blank=True, related_name='resources')
38

  
39
    category = _('Geographic information system')
40

  
41
    class Meta:
42
        abstract = True
43

  
44
    def export_json(self):
45
        d = super().export_json()
46
        d['sectors'] = [sector.slug for sector in self.sectors.all()]
47
        return d
48

  
49
    @classmethod
50
    def import_json_real(cls, overwrite, instance, d, **kwargs):
51
        sectors = d.pop('sectors', [])
52
        instance = super().import_json_real(overwrite, instance, d, **kwargs)
53
        if instance and overwrite:
54
            instance.sectors.clear()
55
        for slug in sectors:
56
            sector = SectorResource.objects.filter(slug=slug).first()
57
            if sector:
58
                instance.sectors.add(sector)
59
        return instance
60

  
61
    def sectorize(self, address):
62
        """
63
        add 'sectors' entry in address.
64
        address is a Nominatim formatted dict and should contain 'street_id' and
65
        'house_number' in its 'address' dict.
66
        """
67
        if not self.sectors.exists():
68
            return
69
        address['sectors'] = {}
70

  
71
        street_id = address['address'].get('street_id')
72
        if not street_id:
73
            address['sectorization_error'] = 'missing street_id in address'
74
            return
75

  
76
        try:
77
            house_number = int(address['address'].get('house_number'))
78
        except (TypeError, ValueError):
79
            house_number = None
80

  
81
        query = Sectorization.objects.filter(sector__resource__in=self.sectors.all(), street_id=street_id)
82
        if house_number is not None:
83
            query = query.filter(min_housenumber__lte=house_number, max_housenumber__gte=house_number)
84
            parity = PARITY_ODD if house_number % 2 else PARITY_EVEN
85
            query = query.filter(Q(parity=PARITY_ALL) | Q(parity=parity))
86
        else:
87
            query = query.filter(parity=PARITY_ALL, min_housenumber=0, max_housenumber=MAX_HOUSENUMBER)
88
        for sectorization in query.reverse():
89
            address['sectors'][sectorization.sector.resource.slug] = {
90
                'id': sectorization.sector.slug,
91
                'text': sectorization.sector.title,
92
            }
93

  
94
    @endpoint(
95
        name='sectors',
96
        description=_('List related Sectorizations'),
97
        parameters={
98
            'id': {'description': _('Sector Identifier (slug)')},
99
            'q': {'description': _('Filter by Sector Title ou Identifier')},
100
        },
101
    )
102
    def sectors_(self, request, q=None, id=None):
103
        query = self.sectors.all()
104
        if id is not None:
105
            query = query.filter(slug=id)
106
        elif q is not None:
107
            query = query.filter(Q(slug__icontains=q) | Q(title__icontains=q))
108
        return {
109
            'data': [
110
                {
111
                    'id': resource.slug,
112
                    'text': resource.title,
113
                    'description': resource.description,
114
                    'sectors_url': urljoin(
115
                        settings.SITE_BASE_URL, '%ssectors/' % resource.get_absolute_url()
116
                    ),
117
                }
118
                for resource in query
119
            ]
120
        }
passerelle/apps/base_adresse/migrations/0025_baseadresse_sectors.py
1
# Generated by Django 2.2.19 on 2021-08-15 20:24
2

  
3
from django.db import migrations, models
4

  
5

  
6
class Migration(migrations.Migration):
7

  
8
    dependencies = [
9
        ('sector', '0001_initial'),
10
        ('base_adresse', '0024_resource_in_models_alter'),
11
    ]
12

  
13
    operations = [
14
        migrations.AddField(
15
            model_name='baseadresse',
16
            name='sectors',
17
            field=models.ManyToManyField(blank=True, related_name='resources', to='sector.SectorResource'),
18
        ),
19
    ]
passerelle/apps/base_adresse/models.py
12 12
from requests import RequestException
13 13
from requests.exceptions import ConnectionError
14 14

  
15
from passerelle.base.models import BaseResource
15
from passerelle.address.models import AddressResource
16 16
from passerelle.compat import json_loads
17 17
from passerelle.utils.api import endpoint
18 18
from passerelle.utils.conversion import simplify
19 19
from passerelle.utils.jsonresponse import APIError
20 20

  
21 21

  
22
class BaseAdresse(BaseResource):
22
class BaseAdresse(AddressResource):
23 23
    service_url = models.CharField(
24 24
        max_length=128,
25 25
        blank=False,
......
36 36
        help_text=_('Base Adresse API Geo URL'),
37 37
    )
38 38

  
39
    category = _('Geographic information system')
40

  
41 39
    api_description = _(
42 40
        'The geocoding endpoints are a partial view of '
43 41
        '<a href="https://wiki.openstreetmap.org/wiki/Nominatim">Nominatim</a> own API; '
......
68 66
    class Meta:
69 67
        verbose_name = _('Base Adresse Web Service')
70 68

  
71
    @staticmethod
72
    def format_address_data(data):
69
    def sectorize(self, address):
70
        ban_id = address.get('ban_id') or ''
71
        address['address']['street_id'] = '_'.join(ban_id.split('_', 2)[0:2])
72
        super().sectorize(address)
73

  
74
    def format_address_data(self, data):
73 75
        result = {}
74 76
        result['lon'] = str(data['geometry']['coordinates'][0])
75 77
        result['lat'] = str(data['geometry']['coordinates'][1])
......
90 92
                result['ban_id'] = value
91 93
                result['id'] = '%s~%s~%s' % (value, result['lat'], result['lon'])
92 94
        result['id'] = '%s~%s' % (result['id'], result['text'])
95
        self.sectorize(result)
93 96
        return result
94 97

  
95 98
    @endpoint(
......
176 179
        except AddressCacheModel.DoesNotExist:
177 180
            pass
178 181
        else:
182
            self.sectorize(address.data)  # if sectors have been updated since caching
179 183
            address.update_timestamp()
180 184
            return {'data': [address.data]}
181 185
        # Use search with label as q and lat/lon as geographic hint
passerelle/settings.py
117 117
    'django.contrib.postgres',
118 118
    # base app
119 119
    'passerelle.base',
120
    'passerelle.address',
120 121
    'passerelle.sms',
121 122
    # connectors
122 123
    'passerelle.apps.actesweb',
passerelle/utils/__init__.py
384 384
            for apiuser in d.get('apiusers', []):
385 385
                ApiUser.import_json(apiuser, overwrite=overwrite)
386 386

  
387
        for resource in d.get('resources', []):
388
            BaseResource.import_json(resource, overwrite=overwrite, import_users=import_users)
387
        resources = d.get('resources', [])
388
        # import SectorResource first, as AddressResource may need them
389
        for res in [r for r in resources if r['resource_type'] == 'sector.sectorresource']:
390
            BaseResource.import_json(res, overwrite=overwrite, import_users=import_users)
391
        for res in [r for r in resources if r['resource_type'] != 'sector.sectorresource']:
392
            BaseResource.import_json(res, overwrite=overwrite, import_users=import_users)
389 393

  
390 394

  
391 395
def batch(iterable, size):
tests/test_address.py
1
# passerelle - uniform access to multiple data sources and services
2
# Copyright (C) 2021 Entr'ouvert
3
#
4
# This program is free software: you can redistribute it and/or modify it
5
# under the terms of the GNU Affero General Public License as published
6
# by the Free Software Foundation, either version 3 of the License, or
7
# (at your option) any later version.
8
#
9
# This program is distributed in the hope that it will be useful,
10
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12
# GNU Affero General Public License for more details.
13
#
14
# You should have received a copy of the GNU Affero General Public License
15
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
16

  
17
import json
18

  
19
import mock
20
import pytest
21
import utils
22
from django.core.files import File
23
from django.utils.six import StringIO
24

  
25
from passerelle.apps.base_adresse.models import BaseAdresse
26
from passerelle.apps.sector.models import SectorResource
27

  
28
BAN = {
29
    "attribution": "BAN",
30
    "features": [
31
        {
32
            "geometry": {"coordinates": [2.323365, 48.833702], "type": "Point"},
33
            "properties": {
34
                "city": "Paris",
35
                "citycode": "75114",
36
                "context": "75, Paris, Île-de-France",
37
                "district": "Paris 14e Arrondissement",
38
                "housenumber": "169",
39
                "id": "75114_1912_00169",
40
                "importance": 0.77751,
41
                "label": "169 Rue du Château 75014 Paris",
42
                "name": "169 Rue du Château",
43
                "postcode": "75014",
44
                "score": 0.9797736363636363,
45
                "street": "Rue du Château",
46
                "type": "housenumber",
47
                "x": 650331.55,
48
                "y": 6859506.98,
49
            },
50
            "type": "Feature",
51
        },
52
        {
53
            "geometry": {"coordinates": [2.323365, 48.833702], "type": "Point"},
54
            "properties": {
55
                "city": "Paris",
56
                "citycode": "75114",
57
                "context": "75, Paris, Île-de-France",
58
                "district": "Paris 14e Arrondissement",
59
                "housenumber": "167",
60
                "id": "75114_1912_00167",
61
                "importance": 0.77751,
62
                "label": "167 Rue du Château 75014 Paris",
63
                "name": "167 Rue du Château",
64
                "postcode": "75014",
65
                "score": 0.9797736363636363,
66
                "street": "Rue du Château",
67
                "type": "housenumber",
68
                "x": 650331.55,
69
                "y": 6859506.98,
70
            },
71
            "type": "Feature",
72
        },
73
        {
74
            "geometry": {"coordinates": [2.323365, 48.833702], "type": "Point"},
75
            "properties": {
76
                "city": "Paris",
77
                "citycode": "75114",
78
                "context": "75, Paris, Île-de-France",
79
                "district": "Paris 14e Arrondissement",
80
                "housenumber": "170",
81
                "id": "75114_1912_00169",
82
                "importance": 0.77751,
83
                "label": "170 Rue du Château 75014 Paris",
84
                "name": "170 Rue du Château",
85
                "postcode": "75014",
86
                "score": 0.9797736363636363,
87
                "street": "Rue du Château",
88
                "type": "housenumber",
89
                "x": 650331.55,
90
                "y": 6859506.98,
91
            },
92
            "type": "Feature",
93
        },
94
        # no house number
95
        {
96
            "geometry": {"coordinates": [2.323365, 48.833702], "type": "Point"},
97
            "properties": {
98
                "city": "Paris",
99
                "citycode": "75114",
100
                "context": "75, Paris, Île-de-France",
101
                "district": "Paris 14e Arrondissement",
102
                "id": "75114_1912_00169",
103
                "importance": 0.77751,
104
                "label": "XX Rue du Château 75014 Paris",
105
                "name": "Rue du Château",
106
                "postcode": "75014",
107
                "score": 0.9797736363636363,
108
                "street": "Rue du Château",
109
                "type": "housenumber",
110
                "x": 650331.55,
111
                "y": 6859506.98,
112
            },
113
            "type": "Feature",
114
        },
115
        {
116
            "geometry": {"coordinates": [2.323365, 48.833702], "type": "Point"},
117
            "properties": {
118
                "city": "Paris",
119
                "citycode": "75114",
120
                "context": "75, Paris, Île-de-France",
121
                "district": "Paris 14e Arrondissement",
122
                "id": "75114_1913_00000",
123
                "importance": 0.77751,
124
                "label": "YY Rue du Château 75014 Paris",
125
                "name": "Rue du Château",
126
                "postcode": "75014",
127
                "score": 0.9797736363636363,
128
                "street": "Rue du Château",
129
                "type": "housenumber",
130
                "x": 650331.55,
131
                "y": 6859506.98,
132
            },
133
            "type": "Feature",
134
        },
135
        # empty id (no street_id)
136
        {
137
            "geometry": {"coordinates": [2.323365, 48.833702], "type": "Point"},
138
            "properties": {
139
                "city": "Paris",
140
                "citycode": "75114",
141
                "context": "75, Paris, Île-de-France",
142
                "district": "Paris 14e Arrondissement",
143
                "housenumber": "169",
144
                "id": "",  # empty id => no street_id
145
                "importance": 0.77751,
146
                "label": "169 Rue du Château 75014 Paris",
147
                "name": "169 Rue du Château",
148
                "postcode": "75014",
149
                "score": 0.9797736363636363,
150
                "street": "Rue du Château",
151
                "type": "housenumber",
152
                "x": 650331.55,
153
                "y": 6859506.98,
154
            },
155
            "type": "Feature",
156
        },
157
    ],
158
    "licence": "ETALAB-2.0",
159
    "limit": 5,
160
    "query": "169 rue du chateau, 75014 Paris",
161
    "type": "FeatureCollection",
162
    "version": "draft",
163
}
164

  
165
CSV = """street_id,parity,min_housenumber,max_housenumber,sector_id,sector_name
166
75114_1912,P,,,gs-moulin,Groupe Scolaire Moulin
167
75114_1912,I,0,167,gs-zola,Groupe Scolaire Zola
168
75114_1912,I,168,999999,ecole-hugo,École Hugo
169
75114_1913,,,,ecole-pascal,École Pascal
170
"""
171

  
172

  
173
@pytest.fixture
174
def sector(db):
175
    return utils.setup_access_rights(
176
        SectorResource.objects.create(
177
            slug='ecole',
178
            title='Secteur scolaire',
179
            description='desc',
180
            csv_file=File(StringIO(CSV), 'sectorization.csv'),
181
        )
182
    )
183

  
184

  
185
@pytest.fixture
186
def base_adresse(db):
187
    return utils.setup_access_rights(BaseAdresse.objects.create(slug='base-adresse', zipcode='75114'))
188

  
189

  
190
@mock.patch('passerelle.utils.Request.get')
191
def test_sectorization(mocked_get, app, base_adresse, sector):
192
    assert base_adresse.sectors.count() == 0
193
    mocked_get.return_value = utils.FakedResponse(content=json.dumps(BAN), status_code=200)
194
    url = utils.generic_endpoint_url('base-adresse', 'search', slug=base_adresse.slug)
195
    addresses = app.get(url, params={'q': 'foo'}, status=200).json
196
    assert len(addresses) == 6
197
    for address in addresses:
198
        assert 'sectors' not in address
199

  
200
    base_adresse.sectors.add(sector)
201

  
202
    # empty sector
203
    sector.sector_set.all().delete()
204
    addresses = app.get(url, params={'q': 'foo'}, status=200).json
205
    assert len(addresses) == 6
206
    for address in addresses:
207
        assert address['sectors'] == {}  # sector is empty
208

  
209
    sector.save()  # reload CSV data in sector
210
    addresses = app.get(url, params={'q': 'foo'}, status=200).json
211
    assert addresses[0]['sectors'] == {'ecole': {'id': 'ecole-hugo', 'text': 'École Hugo'}}
212
    assert addresses[1]['sectors'] == {'ecole': {'id': 'gs-zola', 'text': 'Groupe Scolaire Zola'}}
213
    assert addresses[2]['sectors'] == {'ecole': {'id': 'gs-moulin', 'text': 'Groupe Scolaire Moulin'}}
214
    # addresses without housenumber
215
    assert addresses[3]['sectors'] == {}
216
    assert addresses[4]['sectors'] == {'ecole': {'id': 'ecole-pascal', 'text': 'École Pascal'}}
217
    # bad address format, no street_id
218
    assert addresses[5]['sectors'] == {}
219
    assert addresses[5]['sectorization_error'] == 'missing street_id in address'
220

  
221
    url = utils.generic_endpoint_url('base-adresse', 'sectors', slug=base_adresse.slug)
222
    for params in (None, {'id': 'ecole'}, {'q': 'scolaire'}):
223
        sectors = app.get(url, params=params, status=200).json
224
        assert sectors['err'] == 0
225
        assert sectors['data'] == [
226
            {
227
                'id': 'ecole',
228
                'text': 'Secteur scolaire',
229
                'sectors_url': 'http://localhost/sector/ecole/sectors/',
230
                'description': 'desc',
231
            }
232
        ]
233
    for params in ({'id': 'foo'}, {'q': 'foo'}):
234
        sectors = app.get(url, params=params, status=200).json
235
        assert sectors['err'] == 0
236
        assert sectors['data'] == []
tests/test_cron.py
17 17
    connector = BaseAdresse.objects.create(slug='base-adresse')
18 18
    excep = Exception('hello')
19 19
    with mock.patch(
20
        'passerelle.apps.base_adresse.models.BaseResource.hourly', new=mock.Mock(side_effect=excep)
20
        'passerelle.apps.base_adresse.models.AddressResource.hourly', new=mock.Mock(side_effect=excep)
21 21
    ):
22 22
        with pytest.raises(CommandError):
23 23
            call_command('cron', 'hourly')
tests/test_import_export.py
15 15
from django.utils.six import BytesIO, StringIO
16 16

  
17 17
from passerelle.apps.base_adresse.models import BaseAdresse
18
from passerelle.apps.bdp.models import Bdp
19
from passerelle.apps.csvdatasource.models import CsvDataSource, Query
18 20
from passerelle.apps.ovh.models import OVHSMSGateway
21
from passerelle.apps.sector.models import SectorResource
19 22
from passerelle.base.models import AccessRight, ApiUser
20 23
from passerelle.compat import json_loads
21 24
from passerelle.utils import export_site, import_site
......
44 47

  
45 48
data_bom = force_text(data, 'utf-8').encode('utf-8-sig')
46 49

  
47
from passerelle.apps.bdp.models import Bdp
48
from passerelle.apps.csvdatasource.models import CsvDataSource, Query
50
SECTOR_CSV = """street_id,parity,min_housenumber,max_housenumber,sector_id,sector_name
51
38 75114_1912,P,,, gs-moulin, Groupe Scolaire Moulin"""
49 52

  
50 53
pytestmark = pytest.mark.django_db
51 54

  
......
113 116
    Query.objects.all().delete()
114 117
    CsvDataSource.objects.all().delete()
115 118
    ApiUser.objects.all().delete()
119
    BaseAdresse.objects.all().delete()
120
    SectorResource.objects.all().delete()
116 121

  
117 122

  
118 123
def test_export_csvdatasource(app, setup, filetype):
......
238 243

  
239 244
def test_export_base_adresse():
240 245
    ba = BaseAdresse.objects.create(
246
        slug='ba',
241 247
        service_url='https://api-adresse.data.gouv.fr/',
242 248
        api_geo_url='https://geo.api.gouv.fr/',
243 249
        zipcode='75013',
......
248 254
    new_ba = BaseAdresse.import_json(ba_export, overwrite=True)
249 255
    assert ba == new_ba
250 256

  
257
    # test sectors relationships
258

  
259
    assert new_ba.sectors.count() == 0
260
    assert ba_export['sectors'] == []
261

  
262
    sector = SectorResource.objects.create(
263
        slug='sector1',
264
        title='sector 1',
265
        csv_file=File(StringIO(SECTOR_CSV), 'sectorization.csv'),
266
    )
267
    ba.sectors.add(sector)
268
    ba_export = ba.export_json()
269
    assert ba_export['sectors'] == ['sector1']
270
    new_ba = BaseAdresse.import_json(ba_export, overwrite=True)
271
    assert new_ba.sectors.count() == 1
272
    assert new_ba.sectors.get() == sector
273

  
274
    ba_export['sectors'] == ['sector1', 'sec-no-exist']
275
    new_ba = BaseAdresse.import_json(ba_export, overwrite=True)
276
    assert new_ba.sectors.count() == 1
277
    assert new_ba.sectors.get() == sector
278

  
279
    # export all site: handle dependencies
280
    export = export_site()
281
    clear()
282
    assert SectorResource.objects.count() == BaseAdresse.objects.count() == 0
283
    ba = [d for d in export['resources'] if d['slug'] == 'ba'][0]
284
    sector1 = [d for d in export['resources'] if d['slug'] == 'sector1'][0]
285
    export['resources'] = [ba, sector1]  # import must be clever: create sector1 before ba
286
    import_site(export, import_users=True)
287
    ba = BaseAdresse.objects.get()
288
    assert ba.sectors.get() == SectorResource.objects.get()
289

  
251 290

  
252 291
def test_export_ovh():
253 292
    ovh = OVHSMSGateway.objects.create(
254
-