Projet

Général

Profil

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

Thomas Noël, 06 août 2021 17:00

Télécharger (19,7 ko)

Voir les différences:

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

 passerelle/address/__init__.py                |   0
 passerelle/address/models.py                  |  96 +++++++++++++++
 .../migrations/0025_baseadresse_sectors.py    |  19 +++
 passerelle/apps/base_adresse/models.py        |  16 ++-
 passerelle/apps/sector/__init__.py            |   0
 passerelle/apps/sector/admin.py               |  42 +++++++
 .../apps/sector/migrations/0001_initial.py    |  99 +++++++++++++++
 passerelle/apps/sector/migrations/__init__.py |   0
 passerelle/apps/sector/models.py              | 115 ++++++++++++++++++
 passerelle/settings.py                        |   2 +
 tests/test_cron.py                            |   2 +-
 11 files changed, 384 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 passerelle/apps/sector/__init__.py
 create mode 100644 passerelle/apps/sector/admin.py
 create mode 100644 passerelle/apps/sector/migrations/0001_initial.py
 create mode 100644 passerelle/apps/sector/migrations/__init__.py
 create mode 100644 passerelle/apps/sector/models.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 Sectorization, SectorResource
25
from passerelle.base.models import BaseResource
26
from passerelle.utils.api import endpoint
27

  
28

  
29
class AddressResource(BaseResource):
30
    sectors = models.ManyToManyField(SectorResource, blank=True, related_name='resources')
31

  
32
    category = _('Geographic information system')
33

  
34
    class Meta:
35
        abstract = True
36

  
37
    def sectorize(self, address):
38
        """
39
        add 'sectors' entry in address.
40
        address is a Nominatim formatted dict and should contain 'street_id' and
41
        'house_number' in its 'address' dict.
42
        """
43
        if not self.sectors.exists():
44
            return
45
        address['sectors'] = {}
46

  
47
        street_id = address['address'].get('street_id')
48
        if not street_id:
49
            address['sectorization_error'] = 'missing street_id in address'
50
            return
51

  
52
        try:
53
            house_number = int(address['address'].get('house_number'))
54
        except (TypeError, ValueError):
55
            house_number = None
56

  
57
        query = Sectorization.objects.filter(sector__resource__in=self.sectors.all(), street_id=street_id)
58
        if house_number is not None:
59
            query = query.filter(min_housenumber__lte=house_number, max_housenumber__gte=house_number)
60
            parity = 'I' if house_number % 2 else 'P'
61
            query = query.filter(Q(parity='N') | Q(parity=parity))
62
        else:
63
            query = query.filter(parity='N', min_housenumber=0, max_housenumber=self.MAX_HOUSENUMBER)
64
        for sectorization in query.reverse():
65
            address['sectors'][sectorization.sector.resource.slug] = {
66
                'id': sectorization.sector.slug,
67
                'text': sectorization.sector.name,
68
            }
69

  
70
    @endpoint(
71
        name='sectors',
72
        description=_('List related Sectorizations'),
73
        parameters={
74
            'id': {'description': _('Sector Identifier (slug)')},
75
            'q': {'description': _('Filter by Sector Title ou Identifier')},
76
        },
77
    )
78
    def sectors_(self, request, q=None, id=None):
79
        query = self.sectors.all()
80
        if id is not None:
81
            query = query.filter(slug=id)
82
        elif q is not None:
83
            query = query.filter(Q(slug__icontains=q) | Q(title__icontains=q))
84
        return {
85
            'data': [
86
                {
87
                    'id': resource.slug,
88
                    'text': resource.title,
89
                    'description': resource.description,
90
                    'sectors_url': urljoin(
91
                        settings.SITE_BASE_URL, '%ssectors/' % resource.get_absolute_url()
92
                    ),
93
                }
94
                for resource in query
95
            ]
96
        }
passerelle/apps/base_adresse/migrations/0025_baseadresse_sectors.py
1
# Generated by Django 2.2.19 on 2021-08-06 13:36
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/apps/sector/admin.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.contrib import admin
18

  
19
from passerelle.apps.sector.models import Sector, Sectorization
20

  
21

  
22
class SectorAdmin(admin.ModelAdmin):
23
    prepopulated_fields = {'slug': ('title',)}
24
    list_display = ('title', 'slug', 'resource')
25
    list_filter = ('resource',)
26

  
27

  
28
class SectorizationAdmin(admin.ModelAdmin):
29
    list_display = (
30
        'id',
31
        'street_id',
32
        'parity',
33
        'min_housenumber',
34
        'max_housenumber',
35
        'sector',
36
        'resource',
37
    )
38
    list_filter = ('sector__resource',)
39

  
40

  
41
admin.site.register(Sector, SectorAdmin)
42
admin.site.register(Sectorization, SectorizationAdmin)
passerelle/apps/sector/migrations/0001_initial.py
1
# Generated by Django 2.2.19 on 2021-08-06 13:31
2

  
3
import django.db.models.deletion
4
from django.db import migrations, models
5

  
6

  
7
class Migration(migrations.Migration):
8

  
9
    initial = True
10

  
11
    dependencies = [
12
        ('base', '0029_auto_20210202_1627'),
13
    ]
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
                ('title', models.CharField(max_length=256, verbose_name='Title')),
24
                ('slug', models.CharField(max_length=128, verbose_name='Identifier')),
25
            ],
26
            options={
27
                'ordering': ['resource', 'slug'],
28
            },
29
        ),
30
        migrations.CreateModel(
31
            name='SectorResource',
32
            fields=[
33
                (
34
                    'id',
35
                    models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
36
                ),
37
                ('title', models.CharField(max_length=50, verbose_name='Title')),
38
                ('slug', models.SlugField(unique=True, verbose_name='Identifier')),
39
                ('description', models.TextField(verbose_name='Description')),
40
                (
41
                    'users',
42
                    models.ManyToManyField(
43
                        blank=True,
44
                        related_name='_sectorresource_users_+',
45
                        related_query_name='+',
46
                        to='base.ApiUser',
47
                    ),
48
                ),
49
            ],
50
            options={
51
                'abstract': False,
52
            },
53
        ),
54
        migrations.CreateModel(
55
            name='Sectorization',
56
            fields=[
57
                (
58
                    'id',
59
                    models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
60
                ),
61
                ('street_id', models.CharField(max_length=64, verbose_name='Street Identifier')),
62
                (
63
                    'parity',
64
                    models.CharField(
65
                        choices=[('P', 'even'), ('I', 'odd'), ('N', 'all')],
66
                        default='N',
67
                        max_length=1,
68
                        verbose_name='Parity of numbers',
69
                    ),
70
                ),
71
                (
72
                    'min_housenumber',
73
                    models.PositiveIntegerField(default=0, verbose_name='Minimal house number'),
74
                ),
75
                (
76
                    'max_housenumber',
77
                    models.PositiveIntegerField(default=999999, verbose_name='Maximal house number'),
78
                ),
79
                (
80
                    'sector',
81
                    models.ForeignKey(
82
                        on_delete=django.db.models.deletion.CASCADE, to='sector.Sector', verbose_name='Sector'
83
                    ),
84
                ),
85
            ],
86
            options={
87
                'ordering': ['street_id', 'min_housenumber', 'parity'],
88
            },
89
        ),
90
        migrations.AddField(
91
            model_name='sector',
92
            name='resource',
93
            field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='sector.SectorResource'),
94
        ),
95
        migrations.AlterUniqueTogether(
96
            name='sector',
97
            unique_together={('resource', 'slug')},
98
        ),
99
    ]
passerelle/apps/sector/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

  
33
class SectorResource(BaseResource):
34
    category = _('Geographic information system')
35

  
36
    def __str__(self):
37
        return '%s [%s]' % (self.title, self.slug)
38

  
39
    @endpoint(
40
        name='sectors',
41
        description=_('List of Sectors'),
42
        parameters={
43
            'id': {'description': _('Sector identifier (slug)')},
44
            'q': {'description': _('Filter by Sector Title or Identifier')},
45
            'street_id': {'description': _('Get sectors for this Street identifier')},
46
            'house_number': {'description': _('Get sectors by this House Number. Requires a street_id')},
47
        },
48
    )
49
    def sectors(self, request, q=None, id=None):
50
        query = self.sector_set.all()
51
        if id is not None:
52
            query = query.filter(slug=id)
53
        elif q is not None:
54
            query = query.filter(Q(slug__icontains=q) | Q(title__icontains=q))
55
        return {
56
            'data': [
57
                {
58
                    'id': sector.slug,
59
                    'text': sector.title,
60
                }
61
                for sector in query
62
            ]
63
        }
64

  
65
    # TODO endpoints:
66
    # csv export/import of all Sectorization
67

  
68

  
69
class Sector(models.Model):
70
    resource = models.ForeignKey(SectorResource, on_delete=models.CASCADE)
71
    title = models.CharField(max_length=256, verbose_name=_('Title'))
72
    slug = models.CharField(max_length=128, verbose_name=_('Identifier'))
73

  
74
    class Meta:
75
        ordering = ['resource', 'slug']
76
        unique_together = ('resource', 'slug')
77

  
78
    def __str__(self):
79
        return '%s > %s [%s]' % (self.resource, self.title, self.slug)
80

  
81

  
82
class Sectorization(models.Model):
83
    MAX_HOUSENUMBER = 999_999
84

  
85
    sector = models.ForeignKey(Sector, on_delete=models.CASCADE, verbose_name=_('Sector'))
86
    street_id = models.CharField(max_length=64, verbose_name=_('Street Identifier'))
87
    parity = models.CharField(
88
        max_length=1, choices=PARITY_CHOICES, default='N', verbose_name=_('Parity of numbers')
89
    )
90
    min_housenumber = models.PositiveIntegerField(default=0, verbose_name=_('Minimal house number'))
91
    max_housenumber = models.PositiveIntegerField(
92
        default=MAX_HOUSENUMBER, verbose_name=_('Maximal house number')
93
    )
94

  
95
    class Meta:
96
        ordering = ['street_id', 'min_housenumber', 'parity']
97

  
98
    def __str__(self):
99
        return '%s, parity:%s, min:%s, max:%s → %s' % (
100
            self.street_id,
101
            self.parity,
102
            self.min_housenumber,
103
            self.max_housenumber,
104
            self.sector,
105
        )
106

  
107
    @property
108
    def resource(self):
109
        return self.sector.resource
110

  
111
    def clean(self):
112
        if not self.max_housenumber or self.max_housenumber > self.MAX_HOUSENUMBER:
113
            self.max_housenumber = self.MAX_HOUSENUMBER
114
        if self.min_housenumber > self.max_housenumber:
115
            raise ValidationError(_('Minimal house number may not be lesser than maximal house number.'))
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',
......
158 159
    'passerelle.apps.oxyd',
159 160
    'passerelle.apps.phonecalls',
160 161
    'passerelle.apps.photon',
162
    'passerelle.apps.sector',
161 163
    'passerelle.apps.solis',
162 164
    'passerelle.apps.twilio',
163 165
    'passerelle.apps.vivaticket',
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
-