Projet

Général

Profil

0001-opendatasoft-add-opendatasoft-connector-40979.patch

Nicolas Roche, 12 avril 2020 13:47

Télécharger (15,9 ko)

Voir les différences:

Subject: [PATCH] opendatasoft: add opendatasoft connector (#40979)

 passerelle/apps/opendatasoft/__init__.py      |   0
 .../opendatasoft/migrations/0001_initial.py   |  35 ++++
 .../apps/opendatasoft/migrations/__init__.py  |   0
 passerelle/apps/opendatasoft/models.py        | 103 +++++++++
 passerelle/settings.py                        |   1 +
 passerelle/static/css/style.css               |   5 +
 tests/test_opendatasoft.py                    | 198 ++++++++++++++++++
 7 files changed, 342 insertions(+)
 create mode 100644 passerelle/apps/opendatasoft/__init__.py
 create mode 100644 passerelle/apps/opendatasoft/migrations/0001_initial.py
 create mode 100644 passerelle/apps/opendatasoft/migrations/__init__.py
 create mode 100644 passerelle/apps/opendatasoft/models.py
 create mode 100644 tests/test_opendatasoft.py
passerelle/apps/opendatasoft/migrations/0001_initial.py
1
# -*- coding: utf-8 -*-
2
# Generated by Django 1.11.18 on 2020-04-12 11:33
3
from __future__ import unicode_literals
4

  
5
from django.db import migrations, models
6

  
7

  
8
class Migration(migrations.Migration):
9

  
10
    initial = True
11

  
12
    dependencies = [
13
        ('base', '0018_smslog'),
14
    ]
15

  
16
    operations = [
17
        migrations.CreateModel(
18
            name='OpenDataSoft',
19
            fields=[
20
                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
21
                ('title', models.CharField(max_length=50, verbose_name='Title')),
22
                ('slug', models.SlugField(unique=True, verbose_name='Identifier')),
23
                ('description', models.TextField(verbose_name='Description')),
24
                ('service_url', models.CharField(default='https://examples.opendatasoft.com', help_text='OpenData Adresse Web Service URL', max_length=256, verbose_name='Service URL')),
25
                ('api_key', models.CharField(blank=True, default='', help_text='API key used as credentials', max_length=128, verbose_name='API key')),
26
                ('dataset', models.CharField(default='world-heritage-unesco-list', help_text='dataset to query', max_length=128, verbose_name='Dataset')),
27
                ('text_value_template', models.CharField(default='{{name_fr}} en/au {{country_fr}}', help_text='String composed with item key variables ({{key}})', max_length=256, verbose_name='Template for "text" key value')),
28
                ('max_rows', models.PositiveSmallIntegerField(default=15, help_text='Maximum number of items returned', verbose_name='Maximum items')),
29
                ('users', models.ManyToManyField(blank=True, related_name='_opendatasoft_users_+', related_query_name='+', to='base.ApiUser')),
30
            ],
31
            options={
32
                'verbose_name': 'OpenDataSoft Web Service',
33
            },
34
        ),
35
    ]
passerelle/apps/opendatasoft/models.py
1
# passerelle - uniform access to multiple data sources and services
2
# Copyright (C) 2020  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.db import models
18
from django.template import Context, Template
19
from django.utils.encoding import force_text
20
from django.utils.six.moves.urllib import parse as urlparse
21
from django.utils.translation import ugettext_lazy as _
22

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

  
26

  
27
class OpenDataSoft(BaseResource):
28
    service_url = models.CharField(
29
        _('Service URL'),
30
        max_length=256, blank=False,
31
        help_text=_('OpenData Adresse Web Service URL'),
32
        default='https://examples.opendatasoft.com',
33
    )
34
    api_key = models.CharField(
35
        _('API key'),
36
        max_length=128, blank=True,
37
        help_text=_('API key used as credentials'),
38
        default='',
39
    )
40
    dataset = models.CharField(
41
        _('Dataset'),
42
        max_length=128, blank=False,
43
        default='world-heritage-unesco-list',
44
        help_text=_('dataset to query'),
45
    )
46
    text_value_template = models.CharField(
47
        _('Template for "text" key value'),
48
        max_length=256, blank=False,
49
        default='{{name_fr}} en/au {{country_fr}}',
50
        help_text=_('String composed with item key variables ({{key}})'),
51
    )
52
    max_rows = models.PositiveSmallIntegerField(
53
        _('Maximum items'),
54
        default=15,
55
        help_text=_('Maximum number of items returned'))
56

  
57
    category = _('Data Sources')
58
    documentation_url = 'https://doc-publik.entrouvert.com/admin-fonctionnel/parametrage-avance/connecteur-opendadasoft/'
59

  
60
    class Meta:
61
        verbose_name = _('OpenDataSoft Web Service')
62

  
63
    @endpoint(
64
        perm='can_access',
65
        description=_('Search'),
66
        parameters={
67
            'id': {'description': _('Record identifier')},
68
            'q': {'description': _('Full text query'), 'example_value': "plage"},
69
    })
70
    def search(self, request, id=None, q=None, **kwargs):
71
        scheme, netloc, path, params, query, fragment = urlparse.urlparse(self.service_url)
72
        path = urlparse.urljoin(path, 'api/records/1.0/search/')
73
        url = urlparse.urlunparse((scheme, netloc, path, params, query, fragment))
74

  
75
        if id is not None:
76
            query = 'recordid:%s' % id
77
            row = 1
78
        else:
79
            query = q
80
            row = self.max_rows
81
        params = {
82
            'dataset': self.dataset,
83
            'q': query,
84
            'rows': row,
85
        }
86
        if self.api_key:
87
            params.update({'apikey': self.api_key})
88

  
89
        result_response = self.requests.get(url, params=params)
90

  
91
        result = []
92
        for record in result_response.json().get('records'):
93
            data = {}
94
            data['id'] = record.get('recordid')
95

  
96
            context = {}
97
            for key, value in record.get('fields').items():
98
                context[key] = force_text(value)
99
            template = Template(self.text_value_template)
100
            data['text'] = template.render(Context(context)).strip()
101

  
102
            result.append(data)
103
        return {'data': result}
passerelle/settings.py
149 149
    'passerelle.apps.opengis',
150 150
    'passerelle.apps.orange',
151 151
    'passerelle.apps.ovh',
152 152
    'passerelle.apps.oxyd',
153 153
    'passerelle.apps.pastell',
154 154
    'passerelle.apps.phonecalls',
155 155
    'passerelle.apps.solis',
156 156
    'passerelle.apps.vivaticket',
157
    'passerelle.apps.opendatasoft',
157 158
    # backoffice templates and static
158 159
    'gadjo',
159 160
)
160 161

  
161 162
# disable some applications for now
162 163
PASSERELLE_APP_BDP_ENABLED = False
163 164
PASSERELLE_APP_GDC_ENABLED = False
164 165
PASSERELLE_APP_PASTELL_ENABLED = False
passerelle/static/css/style.css
176 176
li.connector.dpark a::before {
177 177
	content: "\f1b9";  /* car */
178 178
}
179 179

  
180 180
li.connector.cryptor a::before {
181 181
	content: "\f023";  /* lock */
182 182
}
183 183

  
184
li.connector.opendatasoft a::before {
185
	content: "\f1ad"; /* building */
186
}
187

  
188

  
184 189
li.connector.status-down span.connector-name::after {
185 190
	font-family: FontAwesome;
186 191
	content: "\f00d"; /* times */
187 192
	color: #CD2026;
188 193
	padding-left: 1ex;
189 194
	margin-left: 1ex;
190 195
	border-left: 1px solid #003388;
191 196
}
tests/test_opendatasoft.py
1
# -*- coding: utf-8 -*-
2
# passerelle - uniform access to multiple data sources and services
3
# Copyright (C) 2020  Entr'ouvert
4
#
5
# This program is free software: you can redistribute it and/or modify it
6
# under the terms of the GNU Affero General Public License as published
7
# by the Free Software Foundation, either version 3 of the License, or
8
# (at your option) any later version.
9
#
10
# This program is distributed in the hope that it will be useful,
11
# but WITHOUT ANY WARRANTY; without even the implied warranty of
12
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13
# GNU Affero General Public License for more details.
14
#
15
# You should have received a copy of the GNU Affero General Public License
16
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
17

  
18
import mock
19
import json
20
import pytest
21

  
22
import utils
23

  
24
from passerelle.apps.opendatasoft.models import OpenDataSoft
25

  
26
FAKED_CONTENT_Q_SEARCH = json.dumps({
27
    "nhits": 76,
28
    "parameters": {
29
        "dataset": "referentiel-adresse-test",
30
        "format": "json",
31
        "q": "rue de l'aubepine",
32
        "rows": 3,
33
        "timezone": "UTC"
34
    },
35
    "records": [
36
        {
37
            "datasetid": "referentiel-adresse-test",
38
            "fields": {
39
                "adresse_complete": "33 RUE DE L'AUBEPINE STRASBOURG",
40
                "date_exprt": "2019-10-23",
41
                "geo_point": [
42
                    48.6060963542,
43
                    7.76978279836
44
                ],
45
                "nom_commun": "Strasbourg",
46
                "nom_rue": "RUE DE L'AUBEPINE",
47
                "num_com": 482,
48
                "numero": "33",
49
                "source": u"Ville et Eurométropole de Strasbourg"
50
            },
51
            "geometry": {
52
                "coordinates": [
53
                    7.76978279836,
54
                    48.6060963542
55
                ],
56
                "type": "Point"
57
            },
58
            "record_timestamp": "2019-12-02T14:15:08.376000+00:00",
59
            "recordid": "e00cf6161e52a4c8fe510b2b74d4952036cb3473"
60
        },
61
        {
62
            "datasetid": "referentiel-adresse-test",
63
            "fields": {
64
                "adresse_complete": "19 RUE DE L'AUBEPINE LIPSHEIM",
65
                "date_exprt": "2019-10-23",
66
                "geo_point": [
67
                    48.4920620548,
68
                    7.66177412454
69
                ],
70
                "nom_commun": "Lipsheim",
71
                "nom_rue": "RUE DE L'AUBEPINE",
72
                "num_com": 268,
73
                "numero": "19",
74
                "source": u"Ville et Eurométropole de Strasbourg"
75
            },
76
            "geometry": {
77
                "coordinates": [
78
                    7.66177412454,
79
                    48.4920620548
80
                ],
81
                "type": "Point"
82
            },
83
            "record_timestamp": "2019-12-02T14:15:08.376000+00:00",
84
            "recordid": "7cafcd5c692773e8b863587b2d38d6be82e023d8"
85
        },
86
        {
87
            "datasetid": "referentiel-adresse-test",
88
            "fields": {
89
                "adresse_complete": "29 RUE DE L'AUBEPINE STRASBOURG",
90
                "date_exprt": "2019-10-23",
91
                "geo_point": [
92
                    48.6056497224,
93
                    7.76988497729
94
                ],
95
                "nom_commun": "Strasbourg",
96
                "nom_rue": "RUE DE L'AUBEPINE",
97
                "num_com": 482,
98
                "numero": "29",
99
                "source": u"Ville et Eurométropole de Strasbourg"
100
            },
101
            "geometry": {
102
                "coordinates": [
103
                    7.76988497729,
104
                    48.6056497224
105
                ],
106
                "type": "Point"
107
            },
108
            "record_timestamp": "2019-12-02T14:15:08.376000+00:00",
109
            "recordid": "0984a5e1745701f71c91af73ce764e1f7132e0ff"
110
        }
111
    ]
112
})
113

  
114
FAKED_CONTENT_ID_SEARCH = json.dumps({
115
    "nhits": 1,
116
    "parameters": {
117
        "dataset": "referentiel-adresse-test",
118
        "format": "json",
119
        "q": "recordid:7cafcd5c692773e8b863587b2d38d6be82e023d8",
120
        "rows": 1,
121
        "timezone": "UTC"
122
    },
123
    "records": [
124
        {
125
            "datasetid": "referentiel-adresse-test",
126
            "fields": {
127
                "adresse_complete": "19 RUE DE L'AUBEPINE LIPSHEIM",
128
                "date_exprt": "2019-10-23",
129
                "geo_point": [
130
                    48.4920620548,
131
                    7.66177412454
132
                ],
133
                "nom_commun": "Lipsheim",
134
                "nom_rue": "RUE DE L'AUBEPINE",
135
                "num_com": 268,
136
                "numero": "19",
137
                u"source": "Ville et Eurométropole de Strasbourg"
138
            },
139
            "geometry": {
140
                "coordinates": [
141
                    7.66177412454,
142
                    48.4920620548
143
                ],
144
                "type": "Point"
145
            },
146
            "record_timestamp": "2019-12-02T14:15:08.376000+00:00",
147
            "recordid": "7cafcd5c692773e8b863587b2d38d6be82e023d8"
148
        }
149
    ]
150
})
151

  
152

  
153
@pytest.fixture
154
def connector(db):
155
    return utils.setup_access_rights(
156
        OpenDataSoft.objects.create(
157
            slug='my_connector',
158
            dataset='referentiel-adresse-test',
159
            text_value_template='{{numero}} {{nom_rue}} {{nom_commun}}',
160
            max_rows=3
161
        ))
162

  
163

  
164
@mock.patch('passerelle.utils.Request.get')
165
def test_search_using_q(mocked_get, app, connector):
166
    endpoint = utils.generic_endpoint_url('opendatasoft', 'search', slug=connector.slug)
167
    assert endpoint == '/opendatasoft/my_connector/search'
168
    mocked_get.return_value = utils.FakedResponse(content=FAKED_CONTENT_Q_SEARCH, status_code=200)
169
    resp = app.get(endpoint, params={'q': "rue de l'aubepine"}, status=200)
170
    assert not resp.json['err']
171
    assert len(resp.json['data']) == 3
172
    # order is keept
173
    assert resp.json['data'][0] == {
174
        'id': 'e00cf6161e52a4c8fe510b2b74d4952036cb3473',
175
        'text': "33 RUE DE L&#39;AUBEPINE Strasbourg"  # text is quoted
176
    }
177
    assert resp.json['data'][1] == {
178
        'id': '7cafcd5c692773e8b863587b2d38d6be82e023d8',
179
        'text': "19 RUE DE L&#39;AUBEPINE Lipsheim"
180
    }
181
    assert resp.json['data'][2] == {
182
        'id': '0984a5e1745701f71c91af73ce764e1f7132e0ff',
183
        'text': "29 RUE DE L&#39;AUBEPINE Strasbourg"
184
    }
185

  
186

  
187
@mock.patch('passerelle.utils.Request.get')
188
def test_search_using_id(mocked_get, app, connector):
189
    endpoint = utils.generic_endpoint_url('opendatasoft', 'search', slug=connector.slug)
190
    assert endpoint == '/opendatasoft/my_connector/search'
191
    mocked_get.return_value = utils.FakedResponse(content=FAKED_CONTENT_ID_SEARCH, status_code=200)
192
    resp = app.get(endpoint, params={'id': '7cafcd5c692773e8b863587b2d38d6be82e023d8'}, status=200)
193
    assert resp.json == {
194
        'err': 0,
195
        'data': [{
196
            'id': '7cafcd5c692773e8b863587b2d38d6be82e023d8',
197
            'text': "19 RUE DE L&#39;AUBEPINE Lipsheim"
198
        }]}
0
-