Projet

Général

Profil

0002-add-sectorization-system-56001.patch

Thomas Noël, 15 août 2021 22:48

Télécharger (18,5 ko)

Voir les différences:

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

 passerelle/address/__init__.py                |   0
 passerelle/address/models.py                  | 103 ++++++++
 .../migrations/0025_baseadresse_sectors.py    |  19 ++
 passerelle/apps/base_adresse/models.py        |  16 +-
 passerelle/settings.py                        |   1 +
 tests/test_address.py                         | 239 ++++++++++++++++++
 tests/test_cron.py                            |   2 +-
 7 files changed, 373 insertions(+), 7 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 sectorize(self, address):
45
        """
46
        add 'sectors' entry in address.
47
        address is a Nominatim formatted dict and should contain 'street_id' and
48
        'house_number' in its 'address' dict.
49
        """
50
        if not self.sectors.exists():
51
            return
52
        address['sectors'] = {}
53

  
54
        street_id = address['address'].get('street_id')
55
        if not street_id:
56
            address['sectorization_error'] = 'missing street_id in address'
57
            return
58

  
59
        try:
60
            house_number = int(address['address'].get('house_number'))
61
        except (TypeError, ValueError):
62
            house_number = None
63

  
64
        query = Sectorization.objects.filter(sector__resource__in=self.sectors.all(), street_id=street_id)
65
        if house_number is not None:
66
            query = query.filter(min_housenumber__lte=house_number, max_housenumber__gte=house_number)
67
            parity = PARITY_ODD if house_number % 2 else PARITY_EVEN
68
            query = query.filter(Q(parity=PARITY_ALL) | Q(parity=parity))
69
        else:
70
            query = query.filter(parity=PARITY_ALL, min_housenumber=0, max_housenumber=MAX_HOUSENUMBER)
71
        for sectorization in query.reverse():
72
            address['sectors'][sectorization.sector.resource.slug] = {
73
                'id': sectorization.sector.slug,
74
                'text': sectorization.sector.title,
75
            }
76

  
77
    @endpoint(
78
        name='sectors',
79
        description=_('List related Sectorizations'),
80
        parameters={
81
            'id': {'description': _('Sector Identifier (slug)')},
82
            'q': {'description': _('Filter by Sector Title ou Identifier')},
83
        },
84
    )
85
    def sectors_(self, request, q=None, id=None):
86
        query = self.sectors.all()
87
        if id is not None:
88
            query = query.filter(slug=id)
89
        elif q is not None:
90
            query = query.filter(Q(slug__icontains=q) | Q(title__icontains=q))
91
        return {
92
            'data': [
93
                {
94
                    'id': resource.slug,
95
                    'text': resource.title,
96
                    'description': resource.description,
97
                    'sectors_url': urljoin(
98
                        settings.SITE_BASE_URL, '%ssectors/' % resource.get_absolute_url()
99
                    ),
100
                }
101
                for resource in query
102
            ]
103
        }
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',
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.exceptions import ValidationError
23
from django.core.files import File
24
from django.core.management import call_command
25
from django.urls import reverse
26
from django.utils.encoding import force_str, force_text
27
from django.utils.six import StringIO
28
from django.utils.timezone import now
29

  
30
from passerelle.apps.base_adresse.models import BaseAdresse
31
from passerelle.apps.sector.models import SectorResource
32

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

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

  
177

  
178
@pytest.fixture
179
def sector(db):
180
    return utils.setup_access_rights(
181
        SectorResource.objects.create(
182
            slug='ecole',
183
            title='Secteur scolaire',
184
            description='desc',
185
            csv_file=File(StringIO(CSV), 'sectorization.csv'),
186
        )
187
    )
188

  
189

  
190
@pytest.fixture
191
def base_adresse(db):
192
    return utils.setup_access_rights(BaseAdresse.objects.create(slug='base-adresse', zipcode='75114'))
193

  
194

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

  
205
    base_adresse.sectors.add(sector)
206

  
207
    addresses = app.get(url, params={'q': 'foo'}, status=200).json
208
    assert len(addresses) == 6
209
    for address in addresses:
210
        assert address['sectors'] == {}  # sector is empty
211

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

  
224
    url = utils.generic_endpoint_url('base-adresse', 'sectors', slug=base_adresse.slug)
225
    for params in (None, {'id': 'ecole'}, {'q': 'scolaire'}):
226
        sectors = app.get(url, params=params, status=200).json
227
        assert sectors['err'] == 0
228
        assert sectors['data'] == [
229
            {
230
                'id': 'ecole',
231
                'text': 'Secteur scolaire',
232
                'sectors_url': 'http://localhost/sector/ecole/sectors/',
233
                'description': 'desc',
234
            }
235
        ]
236
    for params in ({'id': 'foo'}, {'q': 'foo'}):
237
        sectors = app.get(url, params=params, status=200).json
238
        assert sectors['err'] == 0
239
        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')
24
-