Projet

Général

Profil

0001-add-address-sectorization-system-base-56001.patch

Thomas Noël, 05 août 2021 15:54

Télécharger (17,2 ko)

Voir les différences:

Subject: [PATCH] add address sectorization system base (#56001)

 passerelle/address/__init__.py                |   0
 passerelle/address/admin.py                   |  30 +++
 passerelle/address/migrations/0001_initial.py |  78 ++++++++
 .../migrations/0002_auto_20210804_1601.py     |  25 +++
 passerelle/address/migrations/__init__.py     |   0
 passerelle/address/models.py                  | 176 ++++++++++++++++++
 .../0025_baseadresse_sector_types.py          |  21 +++
 passerelle/apps/base_adresse/models.py        |  16 +-
 passerelle/settings.py                        |   1 +
 tests/test_cron.py                            |   2 +-
 10 files changed, 342 insertions(+), 7 deletions(-)
 create mode 100644 passerelle/address/__init__.py
 create mode 100644 passerelle/address/admin.py
 create mode 100644 passerelle/address/migrations/0001_initial.py
 create mode 100644 passerelle/address/migrations/0002_auto_20210804_1601.py
 create mode 100644 passerelle/address/migrations/__init__.py
 create mode 100644 passerelle/address/models.py
 create mode 100644 passerelle/apps/base_adresse/migrations/0025_baseadresse_sector_types.py
passerelle/address/admin.py
1
from django.contrib import admin
2

  
3
from passerelle.address.models import Sector, Sectorization, SectorType
4

  
5

  
6
class SectorTypeAdmin(admin.ModelAdmin):
7
    prepopulated_fields = {'slug': ('name',)}
8

  
9

  
10
class SectorAdmin(admin.ModelAdmin):
11
    prepopulated_fields = {'slug': ('name',)}
12
    list_display = ('name', 'sector_type', 'slug')
13

  
14

  
15
class SectorizationAdmin(admin.ModelAdmin):
16
    list_display = (
17
        'id',
18
        'street_id',
19
        'parity',
20
        'min_housenumber',
21
        'max_housenumber',
22
        'sector',
23
        'sector_type',
24
    )
25
    list_filter = ('sector__sector_type',)
26

  
27

  
28
admin.site.register(SectorType, SectorTypeAdmin)
29
admin.site.register(Sector, SectorAdmin)
30
admin.site.register(Sectorization, SectorizationAdmin)
passerelle/address/migrations/0001_initial.py
1
# -*- coding: utf-8 -*-
2
# Generated by Django 1.11.18 on 2021-08-03 10:07
3
from __future__ import unicode_literals
4

  
5
import django.db.models.deletion
6
from django.db import migrations, models
7

  
8

  
9
class Migration(migrations.Migration):
10

  
11
    initial = True
12

  
13
    dependencies = []
14

  
15
    operations = [
16
        migrations.CreateModel(
17
            name='Sector',
18
            fields=[
19
                (
20
                    'id',
21
                    models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
22
                ),
23
                ('name', models.CharField(max_length=256, verbose_name='Name')),
24
                ('slug', models.CharField(max_length=128, verbose_name='slug')),
25
            ],
26
        ),
27
        migrations.CreateModel(
28
            name='Sectorization',
29
            fields=[
30
                (
31
                    'id',
32
                    models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
33
                ),
34
                ('street_id', models.CharField(max_length=64, verbose_name='Street Identifier')),
35
                (
36
                    'parity',
37
                    models.CharField(
38
                        choices=[('P', 'even numbers'), ('I', 'odd numbers'), ('N', 'all numbers')],
39
                        default='N',
40
                        max_length=1,
41
                        verbose_name='Parity of numbers',
42
                    ),
43
                ),
44
                (
45
                    'min_housenumber',
46
                    models.PositiveIntegerField(default=0, verbose_name='Minimal house number'),
47
                ),
48
                (
49
                    'max_housenumber',
50
                    models.PositiveIntegerField(default=99999999, verbose_name='Maximal house number'),
51
                ),
52
                (
53
                    'sector',
54
                    models.ForeignKey(
55
                        on_delete=django.db.models.deletion.CASCADE,
56
                        to='address.Sector',
57
                        verbose_name='Sector',
58
                    ),
59
                ),
60
            ],
61
        ),
62
        migrations.CreateModel(
63
            name='SectorType',
64
            fields=[
65
                (
66
                    'id',
67
                    models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
68
                ),
69
                ('name', models.CharField(max_length=256, verbose_name='Name')),
70
                ('slug', models.CharField(max_length=128, verbose_name='slug')),
71
            ],
72
        ),
73
        migrations.AddField(
74
            model_name='sector',
75
            name='sector_type',
76
            field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='address.SectorType'),
77
        ),
78
    ]
passerelle/address/migrations/0002_auto_20210804_1601.py
1
# -*- coding: utf-8 -*-
2
# Generated by Django 1.11.18 on 2021-08-04 14:01
3
from __future__ import unicode_literals
4

  
5
from django.db import migrations, models
6

  
7

  
8
class Migration(migrations.Migration):
9

  
10
    dependencies = [
11
        ('address', '0001_initial'),
12
    ]
13

  
14
    operations = [
15
        migrations.AlterField(
16
            model_name='sectorization',
17
            name='parity',
18
            field=models.CharField(
19
                choices=[('P', 'even'), ('I', 'odd'), ('N', 'all')],
20
                default='N',
21
                max_length=1,
22
                verbose_name='Parity of numbers',
23
            ),
24
        ),
25
    ]
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 django.core.exceptions import ValidationError
18
from django.db import models
19
from django.db.models import Q
20
from django.utils.translation import ugettext_lazy as _
21

  
22
from passerelle.base.models import BaseResource
23
from passerelle.utils.api import endpoint
24

  
25
PARITY_CHOICES = (
26
    # stored in french: Pair / Impair / Neutre
27
    ('P', _('even')),
28
    ('I', _('odd')),
29
    ('N', _('all')),
30
)
31

  
32
# Sectorization system, street by street
33

  
34

  
35
class SectorType(models.Model):
36
    name = models.CharField(max_length=256, verbose_name=_('Name'))
37
    slug = models.CharField(max_length=128, verbose_name=_('slug'))
38

  
39
    def __str__(self):
40
        return '%s [%s]' % (self.name, self.slug)
41

  
42

  
43
class Sector(models.Model):
44
    sector_type = models.ForeignKey(SectorType, models.CASCADE)
45
    name = models.CharField(max_length=256, verbose_name=_('Name'))
46
    slug = models.CharField(max_length=128, verbose_name=_('slug'))
47

  
48
    def __str__(self):
49
        return '%s > %s [%s]' % (self.sector_type, self.name, self.slug)
50

  
51

  
52
class Sectorization(models.Model):
53
    MAX_HOUSENUMBER = 999_999
54

  
55
    sector = models.ForeignKey(Sector, on_delete=models.CASCADE, verbose_name=_('Sector'))
56
    street_id = models.CharField(max_length=64, verbose_name=_('Street Identifier'))
57
    parity = models.CharField(
58
        max_length=1, choices=PARITY_CHOICES, default='N', verbose_name=_('Parity of numbers')
59
    )
60
    min_housenumber = models.PositiveIntegerField(default=0, verbose_name=_('Minimal house number'))
61
    max_housenumber = models.PositiveIntegerField(
62
        default=MAX_HOUSENUMBER, verbose_name=_('Maximal house number')
63
    )
64

  
65
    class Meta:
66
        ordering = ['street_id', 'min_housenumber', 'parity']
67

  
68
    def __str__(self):
69
        return '%s, parity:%s, min:%s, max:%s → %s' % (
70
            self.street_id,
71
            self.parity,
72
            self.min_housenumber,
73
            self.max_housenumber,
74
            self.sector,
75
        )
76

  
77
    def clean(self):
78
        if not self.max_housenumber or self.max_housenumber > self.MAX_HOUSENUMBER:
79
            self.max_housenumber = self.MAX_HOUSENUMBER
80
        if self.min_housenumber > self.max_housenumber:
81
            raise ValidationError(_('Minimal house number may not be lesser than maximal house number.'))
82

  
83
    @property
84
    def sector_type(self):
85
        return self.sector.sector_type
86

  
87

  
88
class AddressResource(BaseResource):
89
    sector_types = models.ManyToManyField(SectorType, blank=True, related_name='sector_types')
90

  
91
    category = _('Geographic information system')
92

  
93
    class Meta:
94
        abstract = True
95

  
96
    def sectorize(self, address):
97
        """
98
        add 'sectors' entry in address.
99
        address is a Nominatim formatted dict and should contain 'street_id' and
100
        'house_number' in its 'address' dict.
101
        """
102
        if not self.sector_types.exists():
103
            return
104
        address['sectors'] = {}
105

  
106
        street_id = address['address'].get('street_id')
107
        if not street_id:
108
            address['sectorization_error'] = 'missing street_id in address'
109
            return
110

  
111
        try:
112
            house_number = int(address['address'].get('house_number'))
113
        except (TypeError, ValueError):
114
            house_number = None
115

  
116
        query = Sectorization.objects.filter(
117
            sector__sector_type__in=self.sector_types.all(), street_id=street_id
118
        )
119
        if house_number is not None:
120
            query = query.filter(min_housenumber__lte=house_number, max_housenumber__gte=house_number)
121
            parity = 'I' if house_number % 2 else 'P'
122
            query = query.filter(Q(parity='N') | Q(parity=parity))
123
        else:
124
            query = query.filter(parity='N', min_housenumber=0, max_housenumber=self.MAX_HOUSENUMBER)
125
        for sectorization in query.reverse():
126
            address['sectors'][sectorization.sector.sector_type.slug] = {
127
                'id': sectorization.sector.slug,
128
                'text': sectorization.sector.name,
129
            }
130

  
131
    # data sources endpoint
132
    @endpoint(
133
        name='sector-types',
134
        description=_('Sector types'),
135
        parameters={
136
            'id': {'description': _('Sector Type identifier (slug)')},
137
            'q': {'description': _("Sector Type name")},
138
        },
139
    )
140
    def sector_types_(self, request, q=None, id=None):
141
        query = self.sector_types
142
        if id is not None:
143
            query = query.filter(slug=id)
144
        elif q is not None:
145
            query = query.filter(Q(slug__icontains=q) | Q(name__icontains=q))
146
        else:
147
            query = query.all()
148
        return {'data': [{'id': st.slug, 'text': st.name} for st in query]}
149

  
150
    @endpoint(
151
        name='sectors',
152
        description=_('Sectors'),
153
        parameters={
154
            'id': {'description': _('Sector identifier (slug)')},
155
            'q': {'description': _("Sector name")},
156
            'sector_type': {'description': _('Filter by Sector Type identifier (slug)')},
157
        },
158
    )
159
    def sectors(self, request, q=None, id=None, sector_type=None):
160
        query = Sector.objects.filter(sector_type__in=self.sector_types.all())
161
        if id is not None:
162
            query = query.filter(slug=id)
163
        elif q is not None:
164
            query = query.filter(Q(slug__icontains=q) | Q(name__icontains=q))
165
        else:
166
            query = query.all()
167
        return {
168
            'data': [
169
                {
170
                    'id': sect.slug,
171
                    'text': sect.name,
172
                    'sector_type': {'id': sect.sector_type.slug, 'text': sect.sector_type.name},
173
                }
174
                for sect in query
175
            ]
176
        }
passerelle/apps/base_adresse/migrations/0025_baseadresse_sector_types.py
1
# -*- coding: utf-8 -*-
2
# Generated by Django 1.11.18 on 2021-08-04 14:02
3
from __future__ import unicode_literals
4

  
5
from django.db import migrations, models
6

  
7

  
8
class Migration(migrations.Migration):
9

  
10
    dependencies = [
11
        ('address', '0002_auto_20210804_1601'),
12
        ('base_adresse', '0024_resource_in_models_alter'),
13
    ]
14

  
15
    operations = [
16
        migrations.AddField(
17
            model_name='baseadresse',
18
            name='sector_types',
19
            field=models.ManyToManyField(blank=True, related_name='sector_types', to='address.SectorType'),
20
        ),
21
    ]
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_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
-