Projet

Général

Profil

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

Nicolas Roche, 18 mai 2020 09:34

Télécharger (24,4 ko)

Voir les différences:

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

 passerelle/apps/opendatasoft/__init__.py      |   0
 .../opendatasoft/migrations/0001_initial.py   |  55 ++++
 .../apps/opendatasoft/migrations/__init__.py  |   0
 passerelle/apps/opendatasoft/models.py        | 144 +++++++++
 passerelle/apps/opendatasoft/urls.py          |  28 ++
 passerelle/apps/opendatasoft/views.py         |  50 ++++
 passerelle/settings.py                        |   1 +
 passerelle/static/css/style.css               |   5 +
 tests/test_opendatasoft.py                    | 275 ++++++++++++++++++
 9 files changed, 558 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 passerelle/apps/opendatasoft/urls.py
 create mode 100644 passerelle/apps/opendatasoft/views.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-05-15 17:28
3
from __future__ import unicode_literals
4

  
5
from django.db import migrations, models
6
import django.db.models.deletion
7
import passerelle.utils.templates
8

  
9

  
10
class Migration(migrations.Migration):
11

  
12
    initial = True
13

  
14
    dependencies = [
15
        ('base', '0020_auto_20200515_1923'),
16
    ]
17

  
18
    operations = [
19
        migrations.CreateModel(
20
            name='OpenDataSoft',
21
            fields=[
22
                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
23
                ('title', models.CharField(max_length=50, verbose_name='Title')),
24
                ('slug', models.SlugField(unique=True, verbose_name='Identifier')),
25
                ('description', models.TextField(verbose_name='Description')),
26
                ('service_url', models.CharField(help_text='OpenData Web Service URL', max_length=256, verbose_name='Service URL')),
27
                ('api_key', models.CharField(blank=True, help_text='API key used as credentials', max_length=128, verbose_name='API key')),
28
                ('users', models.ManyToManyField(blank=True, related_name='_opendatasoft_users_+', related_query_name='+', to='base.ApiUser')),
29
            ],
30
            options={
31
                'verbose_name': 'OpenDataSoft Web Service',
32
            },
33
        ),
34
        migrations.CreateModel(
35
            name='Query',
36
            fields=[
37
                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
38
                ('name', models.CharField(max_length=128, verbose_name='Name')),
39
                ('slug', models.SlugField(max_length=128, verbose_name='Slug')),
40
                ('description', models.TextField(blank=True, verbose_name='Description')),
41
                ('dataset', models.CharField(help_text='dataset to query', max_length=128, verbose_name='Dataset')),
42
                ('text_template', models.TextField(blank=True, help_text="Use Django's template syntax. Attributes can be accessed through {{ attributes.name }}", validators=[passerelle.utils.templates.validate_template], verbose_name='Text template')),
43
                ('resource', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='queries', to='opendatasoft.OpenDataSoft', verbose_name='Resource')),
44
            ],
45
            options={
46
                'verbose_name': 'Query',
47
                'ordering': ['name'],
48
                'abstract': False,
49
            },
50
        ),
51
        migrations.AlterUniqueTogether(
52
            name='query',
53
            unique_together=set([('resource', 'slug'), ('resource', 'name')]),
54
        ),
55
    ]
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.shortcuts import get_object_or_404
19
from django.template import Context, Template
20
from django.core.urlresolvers import reverse
21
from django.utils.encoding import force_text
22
from django.utils.six.moves.urllib import parse as urlparse
23
from django.utils.translation import ugettext_lazy as _
24

  
25
from passerelle.utils.templates import validate_template
26
from passerelle.base.models import BaseResource, BaseQuery
27
from passerelle.utils.api import endpoint
28

  
29

  
30
class OpenDataSoft(BaseResource):
31
    service_url = models.CharField(
32
        _('Service URL'),
33
        max_length=256, blank=False,
34
        help_text=_('OpenData Web Service URL'),
35
    )
36
    api_key = models.CharField(
37
        _('API key'),
38
        max_length=128, blank=True,
39
        help_text=_('API key used as credentials'),
40
    )
41

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

  
45
    class Meta:
46
        verbose_name = _('OpenDataSoft Web Service')
47

  
48
    @endpoint(
49
        perm='can_access',
50
        description=_('Search'),
51
        parameters={
52
            'dataset': {'description': _('Dataset')},
53
            'text_template': {'description': _('Text template')},
54
            'id': {'description': _('Record identifier')},
55
            'q': {'description': _('Full text query')},
56
            'limit': {'description': _('Maximum items')},
57
        })
58
    def search(self, request, dataset=None, text_template='', id=None, q=None, limit=None, **kwargs):
59
        scheme, netloc, path, params, query, fragment = urlparse.urlparse(self.service_url)
60
        path = urlparse.urljoin(path, 'api/records/1.0/search/')
61
        url = urlparse.urlunparse((scheme, netloc, path, params, query, fragment))
62

  
63
        if id is not None:
64
            query = 'recordid:%s' % id
65
        else:
66
            query = q
67
        params = {
68
            'dataset': dataset,
69
            'q': query,
70
        }
71
        if self.api_key:
72
            params.update({'apikey': self.api_key})
73
        if limit:
74
            params.update({'rows': limit})
75

  
76
        result_response = self.requests.get(url, params=params)
77

  
78
        result = []
79
        for record in result_response.json().get('records'):
80
            data = {}
81
            data['id'] = record.get('recordid')
82

  
83
            context = {}
84
            for key, value in record.get('fields').items():
85
                context[key] = force_text(value)
86
            template = Template(text_template)
87
            data['text'] = template.render(Context(context)).strip()
88

  
89
            result.append(data)
90
        return {'data': result}
91

  
92
    @endpoint(name='q',
93
              description=_('Query'),
94
              pattern=r'^(?P<query_slug>[\w:_-]+)/$',
95
              perm='can_access',
96
              show=False)
97
    def q(self, request, query_slug, **kwargs):
98
        query = get_object_or_404(Query, resource=self, slug=query_slug)
99
        return query.q(request, **kwargs)
100

  
101
    def create_query_url(self):
102
        return reverse('opendatasoft-query-new', kwargs={'slug': self.slug})
103

  
104

  
105
class Query(BaseQuery):
106
    resource = models.ForeignKey(
107
        to=OpenDataSoft,
108
        related_name='queries',
109
        verbose_name=_('Resource'))
110
    dataset = models.CharField(
111
        _('Dataset'),
112
        max_length=128, blank=False,
113
        help_text=_('dataset to query'),
114
    )
115
    text_template = models.TextField(
116
        verbose_name=_('Text template'),
117
        help_text=_(
118
            "Use Django's template syntax. Attributes can be accessed through {{ attributes.name }}"
119
        ),
120
        validators=[validate_template],
121
        blank=True
122
    )
123

  
124
    delete_view = 'opendatasoft-query-delete'
125
    edit_view = 'opendatasoft-query-edit'
126

  
127
    def q(self, request, **kwargs):
128
        return self.resource.search(
129
            request, dataset=self.dataset, text_template=self.text_template, **kwargs)
130

  
131
    def as_endpoint(self):
132
        endpoint = super(Query, self).as_endpoint(path=self.resource.q.endpoint_info.name)
133

  
134
        search_endpoint = self.resource.search.endpoint_info
135
        endpoint.func = search_endpoint.func
136
        endpoint.show_undocumented_params = False
137

  
138
        # Copy generic params descriptions from original endpoint
139
        # if they are not overloaded by the query
140
        for param in search_endpoint.parameters:
141
            if param in ('dataset', 'text_template') and getattr(self, param):
142
                continue
143
            endpoint.parameters[param] = search_endpoint.parameters[param]
144
        return endpoint
passerelle/apps/opendatasoft/urls.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.conf.urls import url
18

  
19
from . import views
20

  
21
management_urlpatterns = [
22
    url(r'^(?P<slug>[\w,-]+)/query/new/$',
23
        views.QueryNew.as_view(), name='opendatasoft-query-new'),
24
    url(r'^(?P<slug>[\w,-]+)/query/(?P<pk>\d+)/$',
25
        views.QueryEdit.as_view(), name='opendatasoft-query-edit'),
26
    url(r'^(?P<slug>[\w,-]+)/query/(?P<pk>\d+)/delete/$',
27
        views.QueryDelete.as_view(), name='opendatasoft-query-delete'),
28
]
passerelle/apps/opendatasoft/views.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 import forms
18
from django.views.generic import UpdateView, CreateView, DeleteView
19

  
20
from passerelle.base.mixins import ResourceChildViewMixin
21

  
22
from . import models
23

  
24

  
25
class QueryForm(forms.ModelForm):
26
    class Meta:
27
        model = models.Query
28
        fields = '__all__'
29
        exclude = ['resource']
30

  
31

  
32
class QueryNew(ResourceChildViewMixin, CreateView):
33
    model = models.Query
34
    form_class = QueryForm
35
    template_name = "passerelle/manage/resource_child_form.html"
36

  
37
    def form_valid(self, form):
38
        form.instance.resource = self.resource
39
        return super(QueryNew, self).form_valid(form)
40

  
41

  
42
class QueryEdit(ResourceChildViewMixin, UpdateView):
43
    model = models.Query
44
    form_class = QueryForm
45
    template_name = "passerelle/manage/resource_child_form.html"
46

  
47

  
48
class QueryDelete(ResourceChildViewMixin, DeleteView):
49
    model = models.Query
50
    template_name = "passerelle/manage/resource_child_confirm_delete.html"
passerelle/settings.py
142 142
    'passerelle.apps.gdc',
143 143
    'passerelle.apps.gesbac',
144 144
    'passerelle.apps.jsondatastore',
145 145
    'passerelle.apps.sp_fr',
146 146
    'passerelle.apps.mdel',
147 147
    'passerelle.apps.mdel_ddpacs',
148 148
    'passerelle.apps.mobyt',
149 149
    'passerelle.apps.okina',
150
    'passerelle.apps.opendatasoft',
150 151
    'passerelle.apps.opengis',
151 152
    'passerelle.apps.orange',
152 153
    'passerelle.apps.ovh',
153 154
    'passerelle.apps.oxyd',
154 155
    'passerelle.apps.pastell',
155 156
    'passerelle.apps.phonecalls',
156 157
    'passerelle.apps.solis',
157 158
    'passerelle.apps.vivaticket',
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: "\f1c0"; /* database */
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, Query
25

  
26
from test_manager import login, admin_user
27

  
28

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

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

  
155

  
156
@pytest.fixture
157
def connector(db):
158
    return utils.setup_access_rights(OpenDataSoft.objects.create(
159
        slug='my_connector',
160
        api_key='my_secret',
161
    ))
162

  
163

  
164
@pytest.fixture
165
def query(connector):
166
    return Query.objects.create(
167
        resource=connector,
168
        name='Référenciel adresses de test',
169
        slug='my_query',
170
        description='Rechercher une adresse',
171
        dataset='referentiel-adresse-test',
172
        text_template='{{numero}} {{nom_rue|safe}} {{nom_commun}}',
173
    )
174

  
175

  
176
def test_views(db, admin_user, app, connector):
177
    app = login(app)
178
    resp = app.get('/opendatasoft/my_connector/', status=200)
179
    resp = resp.click('New Query')
180
    resp.form['name'] = 'my query'
181
    resp.form['slug'] = 'my-query'
182
    resp.form['dataset'] = 'my-dataset'
183
    resp = resp.form.submit()
184
    resp = resp.follow()
185
    assert [x.text for x in resp.html.find_all('span', {'class': 'description'})]
186

  
187

  
188
@mock.patch('passerelle.utils.Request.get')
189
def test_search_using_q(mocked_get, app, connector):
190
    endpoint = utils.generic_endpoint_url('opendatasoft', 'search', slug=connector.slug)
191
    assert endpoint == '/opendatasoft/my_connector/search'
192
    params = {
193
        'dataset': 'referentiel-adresse-test',
194
        'text_template': '{{numero}} {{nom_rue|safe}} {{nom_commun}}',
195
        'q': "rue de l'aubepine",
196
        'rows': 3,
197
    }
198
    mocked_get.return_value = utils.FakedResponse(content=FAKED_CONTENT_Q_SEARCH, status_code=200)
199
    resp = app.get(endpoint, params=params, status=200)
200
    assert not resp.json['err']
201
    assert len(resp.json['data']) == 3
202
    # order is keept
203
    assert resp.json['data'][0] == {
204
        'id': 'e00cf6161e52a4c8fe510b2b74d4952036cb3473',
205
        'text': "33 RUE DE L'AUBEPINE Strasbourg"
206
    }
207
    assert resp.json['data'][1] == {
208
        'id': '7cafcd5c692773e8b863587b2d38d6be82e023d8',
209
        'text': "19 RUE DE L'AUBEPINE Lipsheim"
210
    }
211
    assert resp.json['data'][2] == {
212
        'id': '0984a5e1745701f71c91af73ce764e1f7132e0ff',
213
        'text': "29 RUE DE L'AUBEPINE Strasbourg"
214
    }
215

  
216

  
217
@mock.patch('passerelle.utils.Request.get')
218
def test_search_using_id(mocked_get, app, connector):
219
    endpoint = utils.generic_endpoint_url('opendatasoft', 'search', slug=connector.slug)
220
    assert endpoint == '/opendatasoft/my_connector/search'
221
    params = {
222
        'dataset': 'referentiel-adresse-test',
223
        'text_template': '{{numero}} {{nom_rue|safe}} {{nom_commun}}',
224
        'id': '7cafcd5c692773e8b863587b2d38d6be82e023d8',
225
    }
226
    mocked_get.return_value = utils.FakedResponse(content=FAKED_CONTENT_ID_SEARCH, status_code=200)
227
    resp = app.get(endpoint, params=params, status=200)
228
    assert resp.json == {
229
        'err': 0,
230
        'data': [{
231
            'id': '7cafcd5c692773e8b863587b2d38d6be82e023d8',
232
            'text': "19 RUE DE L'AUBEPINE Lipsheim"
233
        }]}
234

  
235

  
236
@mock.patch('passerelle.utils.Request.get')
237
def test_query_q_using_q(mocked_get, app, query):
238
    endpoint = '/opendatasoft/my_connector/q/my_query/'
239
    params = {
240
        'q': "rue de l'aubepine",
241
        'rows': 3,
242
    }
243
    mocked_get.return_value = utils.FakedResponse(content=FAKED_CONTENT_Q_SEARCH, status_code=200)
244
    resp = app.get(endpoint, params=params, status=200)
245
    assert not resp.json['err']
246
    assert len(resp.json['data']) == 3
247
    # order is keept
248
    assert resp.json['data'][0] == {
249
        'id': 'e00cf6161e52a4c8fe510b2b74d4952036cb3473',
250
        'text': "33 RUE DE L'AUBEPINE Strasbourg"
251
    }
252
    assert resp.json['data'][1] == {
253
        'id': '7cafcd5c692773e8b863587b2d38d6be82e023d8',
254
        'text': "19 RUE DE L'AUBEPINE Lipsheim"
255
    }
256
    assert resp.json['data'][2] == {
257
        'id': '0984a5e1745701f71c91af73ce764e1f7132e0ff',
258
        'text': "29 RUE DE L'AUBEPINE Strasbourg"
259
    }
260

  
261

  
262
@mock.patch('passerelle.utils.Request.get')
263
def test_query_q_using_id(mocked_get, app, query):
264
    endpoint = '/opendatasoft/my_connector/q/my_query/'
265
    params = {
266
        'id': '7cafcd5c692773e8b863587b2d38d6be82e023d8',
267
    }
268
    mocked_get.return_value = utils.FakedResponse(content=FAKED_CONTENT_ID_SEARCH, status_code=200)
269
    resp = app.get(endpoint, params=params, status=200)
270
    assert resp.json == {
271
        'err': 0,
272
        'data': [{
273
            'id': '7cafcd5c692773e8b863587b2d38d6be82e023d8',
274
            'text': "19 RUE DE L'AUBEPINE Lipsheim"
275
        }]}
0
-