Projet

Général

Profil

0001-plone-restapi-add-a-plone.restapi-connector-57258.patch

Nicolas Roche, 15 octobre 2021 21:14

Télécharger (88,3 ko)

Voir les différences:

Subject: [PATCH] plone-restapi: add a plone.restapi connector (#57258)

 passerelle/apps/plone_restapi/__init__.py     |   0
 passerelle/apps/plone_restapi/forms.py        |  28 +
 .../plone_restapi/migrations/0001_initial.py  | 152 +++++
 .../apps/plone_restapi/migrations/__init__.py |   0
 passerelle/apps/plone_restapi/models.py       | 382 +++++++++++
 passerelle/apps/plone_restapi/urls.py         |  31 +
 passerelle/apps/plone_restapi/views.py        |  44 ++
 passerelle/settings.py                        |   1 +
 passerelle/static/css/style.css               |   4 +
 tests/data/plone_restapi/fetch.json           | 262 ++++++++
 tests/data/plone_restapi/id_search.json       | 266 ++++++++
 tests/data/plone_restapi/q_search.json        | 629 ++++++++++++++++++
 tests/test_plone_restapi.py                   | 508 ++++++++++++++
 tests/utils.py                                |   4 +-
 14 files changed, 2310 insertions(+), 1 deletion(-)
 create mode 100644 passerelle/apps/plone_restapi/__init__.py
 create mode 100644 passerelle/apps/plone_restapi/forms.py
 create mode 100644 passerelle/apps/plone_restapi/migrations/0001_initial.py
 create mode 100644 passerelle/apps/plone_restapi/migrations/__init__.py
 create mode 100644 passerelle/apps/plone_restapi/models.py
 create mode 100644 passerelle/apps/plone_restapi/urls.py
 create mode 100644 passerelle/apps/plone_restapi/views.py
 create mode 100644 tests/data/plone_restapi/fetch.json
 create mode 100644 tests/data/plone_restapi/id_search.json
 create mode 100644 tests/data/plone_restapi/q_search.json
 create mode 100644 tests/test_plone_restapi.py
passerelle/apps/plone_restapi/forms.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 import forms
18

  
19
from passerelle.base.forms import BaseQueryFormMixin
20

  
21
from . import models
22

  
23

  
24
class QueryForm(BaseQueryFormMixin, forms.ModelForm):
25
    class Meta:
26
        model = models.Query
27
        fields = '__all__'
28
        exclude = ['resource']
passerelle/apps/plone_restapi/migrations/0001_initial.py
1
# Generated by Django 2.2.19 on 2021-10-15 10:15
2

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

  
6
import passerelle.utils.templates
7

  
8

  
9
class Migration(migrations.Migration):
10

  
11
    initial = True
12

  
13
    dependencies = [
14
        ('base', '0029_auto_20210202_1627'),
15
    ]
16

  
17
    operations = [
18
        migrations.CreateModel(
19
            name='PloneRestApi',
20
            fields=[
21
                (
22
                    'id',
23
                    models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
24
                ),
25
                ('title', models.CharField(max_length=50, verbose_name='Title')),
26
                ('slug', models.SlugField(unique=True, verbose_name='Identifier')),
27
                ('description', models.TextField(verbose_name='Description')),
28
                (
29
                    'service_url',
30
                    models.CharField(
31
                        help_text='ex: https://demo.plone.org', max_length=256, verbose_name='Site URL'
32
                    ),
33
                ),
34
                (
35
                    'token_ws_url',
36
                    models.CharField(
37
                        blank=True,
38
                        help_text='ex: https://IDP/idp/oidc/token/ or unset for anonymous acces',
39
                        max_length=256,
40
                        verbose_name='Token webservice URL',
41
                    ),
42
                ),
43
                (
44
                    'client_id',
45
                    models.CharField(
46
                        blank=True,
47
                        help_text='OIDC id of the connector',
48
                        max_length=128,
49
                        verbose_name='OIDC id',
50
                    ),
51
                ),
52
                (
53
                    'client_secret',
54
                    models.CharField(
55
                        blank=True,
56
                        help_text='Share secret secret for webservice call authentication',
57
                        max_length=128,
58
                        verbose_name='Shared secret',
59
                    ),
60
                ),
61
                ('username', models.CharField(blank=True, max_length=128, verbose_name='Username')),
62
                ('password', models.CharField(blank=True, max_length=128, verbose_name='Password')),
63
                (
64
                    'users',
65
                    models.ManyToManyField(
66
                        blank=True,
67
                        related_name='_plonerestapi_users_+',
68
                        related_query_name='+',
69
                        to='base.ApiUser',
70
                    ),
71
                ),
72
            ],
73
            options={
74
                'verbose_name': 'Plone REST API Web Service',
75
            },
76
        ),
77
        migrations.CreateModel(
78
            name='Query',
79
            fields=[
80
                (
81
                    'id',
82
                    models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
83
                ),
84
                ('name', models.CharField(max_length=128, verbose_name='Name')),
85
                ('slug', models.SlugField(max_length=128, verbose_name='Slug')),
86
                ('description', models.TextField(blank=True, verbose_name='Description')),
87
                (
88
                    'uri',
89
                    models.CharField(
90
                        blank=True, help_text='uri to query', max_length=128, verbose_name='Uri'
91
                    ),
92
                ),
93
                (
94
                    'text_template',
95
                    models.TextField(
96
                        blank=True,
97
                        help_text="Use Django's template syntax. Attributes can be accessed through {{ attributes.name }}",
98
                        validators=[passerelle.utils.templates.validate_template],
99
                        verbose_name='Text template',
100
                    ),
101
                ),
102
                (
103
                    'filter_expression',
104
                    models.TextField(
105
                        blank=True,
106
                        help_text='Specify more URL parameters (key=value) separated by lines',
107
                        verbose_name='filter',
108
                    ),
109
                ),
110
                (
111
                    'sort',
112
                    models.CharField(
113
                        blank=True,
114
                        help_text='Sorts results by the specified field',
115
                        max_length=256,
116
                        verbose_name='Sort field',
117
                    ),
118
                ),
119
                (
120
                    'order',
121
                    models.BooleanField(
122
                        default=True,
123
                        help_text='Unset to use descending sort order',
124
                        verbose_name='Ascending sort order',
125
                    ),
126
                ),
127
                (
128
                    'limit',
129
                    models.PositiveIntegerField(
130
                        default=10,
131
                        help_text='Number of results to return in a single call',
132
                        verbose_name='Limit',
133
                    ),
134
                ),
135
                (
136
                    'resource',
137
                    models.ForeignKey(
138
                        on_delete=django.db.models.deletion.CASCADE,
139
                        related_name='queries',
140
                        to='plone_restapi.PloneRestApi',
141
                        verbose_name='Resource',
142
                    ),
143
                ),
144
            ],
145
            options={
146
                'verbose_name': 'Query',
147
                'ordering': ['name'],
148
                'abstract': False,
149
                'unique_together': {('resource', 'name'), ('resource', 'slug')},
150
            },
151
        ),
152
    ]
passerelle/apps/plone_restapi/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 parse_qsl, urlsplit, urlunsplit
18

  
19
from django.core.cache import cache
20
from django.db import models
21
from django.shortcuts import get_object_or_404
22
from django.urls import reverse
23
from django.utils.translation import ugettext_lazy as _
24
from requests import RequestException
25

  
26
from passerelle.base.models import BaseQuery, BaseResource
27
from passerelle.compat import json_loads
28
from passerelle.utils.api import endpoint
29
from passerelle.utils.http_authenticators import HttpBearerAuth
30
from passerelle.utils.json import unflatten
31
from passerelle.utils.jsonresponse import APIError
32
from passerelle.utils.templates import render_to_string, validate_template
33

  
34

  
35
class ParameterTypeError(Exception):
36
    http_status = 400
37
    log_error = False
38

  
39

  
40
class PloneRestApi(BaseResource):
41
    service_url = models.CharField(
42
        _('Site URL'),
43
        max_length=256,
44
        blank=False,
45
        help_text=_('ex: https://demo.plone.org'),
46
    )
47
    token_ws_url = models.CharField(
48
        _('Token webservice URL'),
49
        max_length=256,
50
        blank=True,
51
        help_text=_('ex: https://IDP/idp/oidc/token/ or unset for anonymous acces'),
52
    )
53
    client_id = models.CharField(
54
        _('OIDC id'),
55
        max_length=128,
56
        blank=True,
57
        help_text=_('OIDC id of the connector'),
58
    )
59
    client_secret = models.CharField(
60
        _('Shared secret'),
61
        max_length=128,
62
        blank=True,
63
        help_text=_('Share secret secret for webservice call authentication'),
64
    )
65
    username = models.CharField(_('Username'), max_length=128, blank=True)
66
    password = models.CharField(_('Password'), max_length=128, blank=True)
67

  
68
    category = _('Data Sources')
69
    plone_keys_to_rename = ['@id', '@type', '@components']
70

  
71
    class Meta:
72
        verbose_name = _('Plone REST API Web Service')
73

  
74
    def export_json(self):
75
        data = super(PloneRestApi, self).export_json()
76
        data['queries'] = [query.export_json() for query in self.queries.all()]
77
        return data
78

  
79
    @classmethod
80
    def import_json_real(cls, overwrite, instance, data, **kwargs):
81
        data_queries = data.pop('queries', [])
82
        instance = super(PloneRestApi, cls).import_json_real(overwrite, instance, data, **kwargs)
83
        queries = []
84
        if instance and overwrite:
85
            Query.objects.filter(resource=instance).delete()
86
        for data_query in data_queries:
87
            query = Query.import_json(data_query)
88
            query.resource = instance
89
            queries.append(query)
90
        Query.objects.bulk_create(queries)
91
        return instance
92

  
93
    def adapt_id_and_type_plone_attributes(self, data):
94
        """Rename keys starting with '@' from plone response
95
        ex: '@id' is renammed into 'PLONE_id'"""
96
        if isinstance(data, list):
97
            for value in list(data):
98
                self.adapt_id_and_type_plone_attributes(value)
99
        elif isinstance(data, dict):
100
            for key, value in list(data.items()):
101
                self.adapt_id_and_type_plone_attributes(value)
102
                if key in self.plone_keys_to_rename and key[0] == '@':
103
                    data['PLONE_%s' % key[1:]] = value
104
                    del data[key]
105

  
106
    def adapt_record(self, text_template, record):
107
        self.adapt_id_and_type_plone_attributes(record)
108
        data = {}
109
        for key, value in record.items():
110
            # backup original id and text fields
111
            if key in ('id', 'text'):
112
                key = 'original_%s' % key
113
            data[key] = value
114
        data['id'] = record.get('UID')
115
        data['text'] = render_to_string(text_template, data).strip()
116
        return data
117

  
118
    def get_token(self, renew=False):
119
        token_key = 'plone-restapi-%s-token' % self.id
120
        if not renew and cache.get(token_key):
121
            return cache.get(token_key)
122
        payload = {
123
            'grant_type': 'password',
124
            'client_id': str(self.client_id),
125
            'client_secret': str(self.client_secret),
126
            'username': self.username,
127
            'password': self.password,
128
            'scope': ['openid'],
129
        }
130
        headers = {
131
            'Content-Type': 'application/x-www-form-urlencoded',
132
        }
133
        response = self.requests.post(self.token_ws_url, headers=headers, data=payload)
134
        if not response.status_code // 100 == 2:
135
            raise APIError(response.content)
136
        token = response.json().get('id_token')
137
        cache.set(token_key, token, 30)
138
        return token
139

  
140
    def request(self, uri='', uid='', method='GET', params=None, json=None):
141
        scheme, netloc, path, query, fragment = urlsplit(self.service_url)
142
        if uri:
143
            path += '/%s' % uri
144
        if uid:
145
            path += '/%s' % uid
146
        url = urlunsplit((scheme, netloc, path, '', fragment))
147
        headers = {'Accept': 'application/json'}
148
        auth = HttpBearerAuth(self.get_token()) if self.token_ws_url else None
149
        try:
150
            response = self.requests.request(
151
                method=method, url=url, headers=headers, params=params, json=json, auth=auth
152
            )
153
        except RequestException as e:
154
            raise APIError('PloneRestApi: %s' % e)
155
        json_response = None
156
        if response.status_code != 204:  # No Content
157
            try:
158
                json_response = response.json()
159
            except ValueError as e:
160
                raise APIError('PloneRestApi: bad JSON response')
161
        try:
162
            response.raise_for_status()
163
        except RequestException as e:
164
            raise APIError('PloneRestApi: %s "%s"' % (e, json_response))
165
        return json_response
166

  
167
    def call_search(
168
        self,
169
        uri='',
170
        text_template='',
171
        filter_expression='',
172
        sort=None,
173
        order=True,
174
        limit=None,
175
        id=None,
176
        q=None,
177
    ):
178
        query = urlsplit(self.service_url).query
179
        params = dict(parse_qsl(query))
180
        if id:
181
            params['UID'] = id
182
        else:
183
            if q is not None:
184
                params['SearchableText'] = q
185
            if sort:
186
                params['sort_on'] = sort
187
                if order:
188
                    params['sort_order'] = 'ascending'
189
                else:
190
                    params['sort_order'] = 'descending'
191
            if limit:
192
                params['b_size'] = limit
193
            params.update(parse_qsl(filter_expression))
194
        params['fullobjects'] = 'y'
195
        json_response = self.request(uri=uri, uid='@search', method='GET', params=params)
196

  
197
        result = []
198
        for record in json_response.get('items'):
199
            data = self.adapt_record(text_template, record)
200
            result.append(data)
201
        return result
202

  
203
    @endpoint(
204
        perm='can_access',
205
        description=_('Fetch'),
206
        parameters={
207
            'uri': {'description': _('Uri')},
208
            'uid': {'description': _('Uid')},
209
            'text_template': {'description': _('Text template')},
210
        },
211
        display_order=1,
212
    )
213
    def fetch(self, request, uid, uri='', text_template=''):
214
        json_response = self.request(uri=uri, uid=uid, method='GET')
215
        data = self.adapt_record(text_template, json_response)
216
        return {'data': data}
217

  
218
    @endpoint(
219
        perm='can_access',
220
        description=_('Creates'),
221
        parameters={
222
            'uri': {'description': _('Uri')},
223
        },
224
        methods=['post'],
225
        display_order=2,
226
    )
227
    def create(self, request, uri):
228
        try:
229
            post_data = json_loads(request.body)
230
        except ValueError as e:
231
            raise ParameterTypeError(str(e))
232
        post_data = unflatten(post_data)
233
        response = self.request(uri=uri, method='POST', json=post_data)
234
        return {'data': {'uid': response['UID'], 'created': True}}
235

  
236
    @endpoint(
237
        perm='can_access',
238
        description=_('Update'),
239
        parameters={
240
            'uri': {'description': _('Uri')},
241
            'uid': {'description': _('Uid')},
242
        },
243
        methods=['post'],
244
        display_order=3,
245
    )
246
    def update(self, request, uid, uri=''):
247
        try:
248
            post_data = json_loads(request.body)
249
        except ValueError as e:
250
            raise ParameterTypeError(str(e))
251
        post_data = unflatten(post_data)
252
        self.request(uri=uri, uid=uid, method='PATCH', json=post_data)
253
        return {'data': {'uid': uid, 'updated': True}}
254

  
255
    @endpoint(
256
        perm='can_access',
257
        description=_('Remove'),
258
        parameters={
259
            'uri': {'description': _('Uri')},
260
            'uid': {'description': _('Uid')},
261
        },
262
        methods=['delete'],
263
        display_order=4,
264
    )
265
    def remove(self, request, uid, uri=''):
266
        self.request(method='DELETE', uri=uri, uid=uid)
267
        return {'data': {'uid': uid, 'removed': True}}
268

  
269
    @endpoint(
270
        perm='can_access',
271
        description=_('Search'),
272
        parameters={
273
            'uri': {'description': _('Uri')},
274
            'text_template': {'description': _('Text template')},
275
            'sort': {'description': _('Sort field')},
276
            'order': {'description': _('Ascending sort order'), 'type': 'bool'},
277
            'limit': {'description': _('Maximum items')},
278
            'id': {'description': _('Record identifier')},
279
            'q': {'description': _('Full text query')},
280
        },
281
    )
282
    def search(
283
        self,
284
        request,
285
        uri='',
286
        text_template='',
287
        sort=None,
288
        order=True,
289
        limit=None,
290
        id=None,
291
        q=None,
292
        **kwargs,
293
    ):
294
        result = self.call_search(uri, text_template, '', sort, order, limit, id, q)
295
        return {'data': result}
296

  
297
    @endpoint(
298
        name='q',
299
        description=_('Query'),
300
        pattern=r'^(?P<query_slug>[\w:_-]+)/$',
301
        perm='can_access',
302
        show=False,
303
    )
304
    def q(self, request, query_slug, **kwargs):
305
        query = get_object_or_404(Query, resource=self, slug=query_slug)
306
        result = query.q(request, **kwargs)
307
        meta = {'label': query.name, 'description': query.description}
308
        return {'data': result, 'meta': meta}
309

  
310
    def create_query_url(self):
311
        return reverse('plone-restapi-query-new', kwargs={'slug': self.slug})
312

  
313

  
314
class Query(BaseQuery):
315
    resource = models.ForeignKey(
316
        to=PloneRestApi, related_name='queries', verbose_name=_('Resource'), on_delete=models.CASCADE
317
    )
318
    uri = models.CharField(
319
        verbose_name=_('Uri'),
320
        max_length=128,
321
        help_text=_('uri to query'),
322
        blank=True,
323
    )
324
    text_template = models.TextField(
325
        verbose_name=_('Text template'),
326
        help_text=_("Use Django's template syntax. Attributes can be accessed through {{ attributes.name }}"),
327
        validators=[validate_template],
328
        blank=True,
329
    )
330
    filter_expression = models.TextField(
331
        verbose_name=_('filter'),
332
        help_text=_('Specify more URL parameters (key=value) separated by lines'),
333
        blank=True,
334
    )
335
    sort = models.CharField(
336
        verbose_name=_('Sort field'),
337
        help_text=_('Sorts results by the specified field'),
338
        max_length=256,
339
        blank=True,
340
    )
341
    order = models.BooleanField(
342
        verbose_name=_('Ascending sort order'),
343
        help_text=_("Unset to use descending sort order"),
344
        default=True,
345
    )
346
    limit = models.PositiveIntegerField(
347
        default=10,
348
        verbose_name=_('Limit'),
349
        help_text=_('Number of results to return in a single call'),
350
    )
351

  
352
    delete_view = 'plone-restapi-query-delete'
353
    edit_view = 'plone-restapi-query-edit'
354

  
355
    def q(self, request, **kwargs):
356
        return self.resource.call_search(
357
            uri=self.uri,
358
            text_template=self.text_template,
359
            filter_expression='&'.join(
360
                [x.strip() for x in str(self.filter_expression).splitlines() if x.strip()]
361
            ),
362
            sort=self.sort,
363
            order=self.order,
364
            limit=self.limit,
365
            id=kwargs.get('id'),
366
            q=kwargs.get('q'),
367
        )
368

  
369
    def as_endpoint(self):
370
        endpoint = super(Query, self).as_endpoint(path=self.resource.q.endpoint_info.name)
371

  
372
        search_endpoint = self.resource.search.endpoint_info
373
        endpoint.func = search_endpoint.func
374
        endpoint.show_undocumented_params = False
375

  
376
        # Copy generic params descriptions from original endpoint
377
        # if they are not overloaded by the query
378
        for param in search_endpoint.parameters:
379
            if param in ('uri', 'text_template') and getattr(self, param):
380
                continue
381
            endpoint.parameters[param] = search_endpoint.parameters[param]
382
        return endpoint
passerelle/apps/plone_restapi/urls.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.conf.urls import url
18

  
19
from . import views
20

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

  
19
from passerelle.base.mixins import ResourceChildViewMixin
20

  
21
from . import models
22
from .forms import QueryForm
23

  
24

  
25
class QueryNew(ResourceChildViewMixin, CreateView):
26
    model = models.Query
27
    form_class = QueryForm
28
    template_name = "passerelle/manage/resource_child_form.html"
29

  
30
    def get_form_kwargs(self):
31
        kwargs = super(QueryNew, self).get_form_kwargs()
32
        kwargs['instance'] = self.model(resource=self.resource)
33
        return kwargs
34

  
35

  
36
class QueryEdit(ResourceChildViewMixin, UpdateView):
37
    model = models.Query
38
    form_class = QueryForm
39
    template_name = "passerelle/manage/resource_child_form.html"
40

  
41

  
42
class QueryDelete(ResourceChildViewMixin, DeleteView):
43
    model = models.Query
44
    template_name = "passerelle/manage/resource_child_confirm_delete.html"
passerelle/settings.py
154 154
    'passerelle.apps.okina',
155 155
    'passerelle.apps.opendatasoft',
156 156
    'passerelle.apps.opengis',
157 157
    'passerelle.apps.orange',
158 158
    'passerelle.apps.ovh',
159 159
    'passerelle.apps.oxyd',
160 160
    'passerelle.apps.phonecalls',
161 161
    'passerelle.apps.photon',
162
    'passerelle.apps.plone_restapi',
162 163
    'passerelle.apps.sector',
163 164
    'passerelle.apps.solis',
164 165
    'passerelle.apps.twilio',
165 166
    'passerelle.apps.vivaticket',
166 167
    # backoffice templates and static
167 168
    'gadjo',
168 169
)
169 170

  
passerelle/static/css/style.css
181 181
li.connector.dpark a::before {
182 182
	content: "\f1b9";  /* car */
183 183
}
184 184

  
185 185
li.connector.cryptor a::before {
186 186
	content: "\f023";  /* lock */
187 187
}
188 188

  
189
li.connector.plonerestapi a::before {
190
	content: "\f1c0"; /* database */
191
}
192

  
189 193
li.connector.opendatasoft a::before {
190 194
	content: "\f1c0"; /* database */
191 195
}
192 196

  
193 197

  
194 198
li.connector.status-down span.connector-name::after {
195 199
	font-family: FontAwesome;
196 200
	content: "\f00d"; /* times */
tests/data/plone_restapi/fetch.json
1
{
2
  "@components": {
3
    "actions": {
4
      "@id": "https://annuaire.preprod.imio.be/braine-l-alleud/c44f1b32f0ce436eb7a042ca8933b287/@actions"
5
    },
6
    "breadcrumbs": {
7
      "@id": "https://annuaire.preprod.imio.be/braine-l-alleud/c44f1b32f0ce436eb7a042ca8933b287/@breadcrumbs"
8
    },
9
    "contextnavigation": {
10
      "@id": "https://annuaire.preprod.imio.be/braine-l-alleud/c44f1b32f0ce436eb7a042ca8933b287/@contextnavigation"
11
    },
12
    "navigation": {
13
      "@id": "https://annuaire.preprod.imio.be/braine-l-alleud/c44f1b32f0ce436eb7a042ca8933b287/@navigation"
14
    },
15
    "types": {
16
      "@id": "https://annuaire.preprod.imio.be/braine-l-alleud/c44f1b32f0ce436eb7a042ca8933b287/@types"
17
    },
18
    "workflow": {
19
      "@id": "https://annuaire.preprod.imio.be/braine-l-alleud/c44f1b32f0ce436eb7a042ca8933b287/@workflow"
20
    }
21
  },
22
  "@id": "https://annuaire.preprod.imio.be/braine-l-alleud/c44f1b32f0ce436eb7a042ca8933b287",
23
  "@type": "imio.directory.Contact",
24
  "UID": "dccd85d12cf54b6899dff41e5a56ee7f",
25
  "allow_discussion": false,
26
  "city": "Braine-l'Alleud",
27
  "complement": null,
28
  "country": {
29
    "title": "Belgique",
30
    "token": "be"
31
  },
32
  "created": "2021-07-28T07:53:01+00:00",
33
  "description": "Ouvert du lundi au samedi toute l'ann\u00e9e.\r\n\r\nContact : Thierry Vou\u00e9",
34
  "exceptional_closure": [],
35
  "facilities": null,
36
  "geolocation": {
37
    "latitude": 50.4989185,
38
    "longitude": 4.7184485
39
  },
40
  "iam": [
41
    {
42
      "title": "Jeune",
43
      "token": "young"
44
    }
45
  ],
46
  "id": "c44f1b32f0ce436eb7a042ca8933b287",
47
  "image": null,
48
  "image_caption": null,
49
  "is_folderish": true,
50
  "is_geolocated": true,
51
  "items": [],
52
  "items_total": 0,
53
  "language": {
54
    "title": "Fran\u00e7ais",
55
    "token": "fr"
56
  },
57
  "layout": "view",
58
  "logo": {
59
    "content-type": "image/png",
60
    "download": "https://annuaire.preprod.imio.be/braine-l-alleud/c44f1b32f0ce436eb7a042ca8933b287/@@images/d4e7b99f-98c2-4c85-87fa-7fb0ebb31c16.png",
61
    "filename": "maison jeunes le prisme.png",
62
    "height": 1536,
63
    "scales": {
64
      "banner": {
65
        "download": "https://annuaire.preprod.imio.be/braine-l-alleud/c44f1b32f0ce436eb7a042ca8933b287/@@images/02ef9609-1182-4a3d-9ce6-eb0449309b55.png",
66
        "height": 590,
67
        "width": 1920
68
      },
69
      "extralarge": {
70
        "download": "https://annuaire.preprod.imio.be/braine-l-alleud/c44f1b32f0ce436eb7a042ca8933b287/@@images/5be772a5-dc46-417c-aab6-ae4352536a48.png",
71
        "height": 405,
72
        "width": 1320
73
      },
74
      "icon": {
75
        "download": "https://annuaire.preprod.imio.be/braine-l-alleud/c44f1b32f0ce436eb7a042ca8933b287/@@images/21e0bb31-8a45-4e42-a4ef-60f8070d7ef9.png",
76
        "height": 9,
77
        "width": 32
78
      },
79
      "large": {
80
        "download": "https://annuaire.preprod.imio.be/braine-l-alleud/c44f1b32f0ce436eb7a042ca8933b287/@@images/1cd53cba-e9ff-4abb-a43a-9e859c6959dc.png",
81
        "height": 236,
82
        "width": 768
83
      },
84
      "listing": {
85
        "download": "https://annuaire.preprod.imio.be/braine-l-alleud/c44f1b32f0ce436eb7a042ca8933b287/@@images/09c2223f-0fb0-4fcb-bcb2-2501af8543cd.png",
86
        "height": 4,
87
        "width": 16
88
      },
89
      "medium": {
90
        "download": "https://annuaire.preprod.imio.be/braine-l-alleud/c44f1b32f0ce436eb7a042ca8933b287/@@images/9db56c58-9cf9-4b72-afdf-1e2d167e7fee.png",
91
        "height": 184,
92
        "width": 600
93
      },
94
      "mini": {
95
        "download": "https://annuaire.preprod.imio.be/braine-l-alleud/c44f1b32f0ce436eb7a042ca8933b287/@@images/67a49d61-159d-4ca7-9e06-0de39838e5c9.png",
96
        "height": 61,
97
        "width": 200
98
      },
99
      "preview": {
100
        "download": "https://annuaire.preprod.imio.be/braine-l-alleud/c44f1b32f0ce436eb7a042ca8933b287/@@images/2ff94086-ce56-45cc-b293-fd796931dbe5.png",
101
        "height": 123,
102
        "width": 400
103
      },
104
      "thumb": {
105
        "download": "https://annuaire.preprod.imio.be/braine-l-alleud/c44f1b32f0ce436eb7a042ca8933b287/@@images/0ac5cdc9-c5c2-4aed-8e1d-9260311236f2.png",
106
        "height": 39,
107
        "width": 128
108
      },
109
      "tile": {
110
        "download": "https://annuaire.preprod.imio.be/braine-l-alleud/c44f1b32f0ce436eb7a042ca8933b287/@@images/e439acab-b379-46e4-b939-52f5a7ba67a6.png",
111
        "height": 19,
112
        "width": 64
113
      }
114
    },
115
    "size": 1268077,
116
    "width": 4995
117
  },
118
  "mails": [
119
    {
120
      "label": null,
121
      "mail_address": "info@leprisme.be",
122
      "type": "work"
123
    }
124
  ],
125
  "modified": "2021-10-01T17:07:32+00:00",
126
  "multi_schedule": [],
127
  "next_item": {
128
    "@id": "https://annuaire.preprod.imio.be/braine-l-alleud/c812043a3ed44e00815e342de34a61c9",
129
    "@type": "imio.directory.Contact",
130
    "description": "",
131
    "title": "Parc \u00e0 conteneurs de Braine-l'Alleud"
132
  },
133
  "number": "103",
134
  "parent": {
135
    "@id": "https://annuaire.preprod.imio.be/braine-l-alleud",
136
    "@type": "imio.directory.Entity",
137
    "description": "",
138
    "review_state": "published",
139
    "title": "Braine-l'Alleud"
140
  },
141
  "phones": [
142
    {
143
      "label": null,
144
      "number": "+3223870926",
145
      "type": "work"
146
    },
147
    {
148
      "label": null,
149
      "number": "+32475916819",
150
      "type": "cell"
151
    }
152
  ],
153
  "previous_item": {
154
    "@id": "https://annuaire.preprod.imio.be/braine-l-alleud/3d595a01fa814af09cf9aac35a11b9b0",
155
    "@type": "imio.directory.Contact",
156
    "description": "",
157
    "title": "Kinepolis Imagibraine"
158
  },
159
  "review_state": "published",
160
  "schedule": {
161
    "friday": {
162
      "afternoonend": "",
163
      "afternoonstart": "",
164
      "comment": "",
165
      "morningend": "17:00",
166
      "morningstart": "10:00"
167
    },
168
    "monday": {
169
      "afternoonend": "",
170
      "afternoonstart": "",
171
      "comment": "",
172
      "morningend": "17:00",
173
      "morningstart": "10:00"
174
    },
175
    "saturday": {
176
      "afternoonend": "",
177
      "afternoonstart": "",
178
      "comment": "",
179
      "morningend": "",
180
      "morningstart": ""
181
    },
182
    "sunday": {
183
      "afternoonend": "",
184
      "afternoonstart": "",
185
      "comment": "",
186
      "morningend": "",
187
      "morningstart": ""
188
    },
189
    "thursday": {
190
      "afternoonend": "",
191
      "afternoonstart": "",
192
      "comment": "",
193
      "morningend": "17:00",
194
      "morningstart": "10:00"
195
    },
196
    "tuesday": {
197
      "afternoonend": "",
198
      "afternoonstart": "",
199
      "comment": "",
200
      "morningend": "17:00",
201
      "morningstart": "10:00"
202
    },
203
    "wednesday": {
204
      "afternoonend": "",
205
      "afternoonstart": "",
206
      "comment": "",
207
      "morningend": "17:00",
208
      "morningstart": "10:00"
209
    }
210
  },
211
  "selected_entities": [
212
    {
213
      "title": "Braine-l'Alleud",
214
      "token": "f571b73a16f34832a5fdd3683533b3cc"
215
    }
216
  ],
217
  "street": "Avenue Alphonse Allard",
218
  "subjects": [
219
    "mj"
220
  ],
221
  "subtitle": "Maison de Jeunes de Braine-l'Alleud",
222
  "taxonomy_contact_category": [
223
    {
224
      "title": "Loisirs \u00bb Mouvements et associations \u00bb Jeunesse",
225
      "token": "oqa05qwd45"
226
    }
227
  ],
228
  "title": "Le Prisme",
229
  "topics": [
230
    {
231
      "title": "Activit\u00e9s et divertissement",
232
      "token": "entertainment"
233
    },
234
    {
235
      "title": "Culture",
236
      "token": "culture"
237
    },
238
    {
239
      "title": "Sports",
240
      "token": "sports"
241
    },
242
    {
243
      "title": "Participation citoyenne",
244
      "token": "citizen_participation"
245
    }
246
  ],
247
  "type": {
248
    "title": "Organisation (service administratif, commerce, profession lib\u00e9rale, club sportif, association, etc.)",
249
    "token": "organization"
250
  },
251
  "urls": [
252
    {
253
      "type": "website",
254
      "url": "http://www.leprisme.be/"
255
    }
256
  ],
257
  "vat_number": null,
258
  "version": "current",
259
  "working_copy": null,
260
  "working_copy_of": null,
261
  "zipcode": 1420
262
}
tests/data/plone_restapi/id_search.json
1
{
2
  "@id": "https://annuaire.preprod.imio.be/braine-l-alleud/@search?UID=23a32197d6c841259963b43b24747854&fullobjects=y",
3
  "items": [
4
    {
5
      "@components": {
6
        "actions": {
7
          "@id": "https://annuaire.preprod.imio.be/braine-l-alleud/3378d97243854ddfa90510f6ceb9fcdb/@actions"
8
        },
9
        "breadcrumbs": {
10
          "@id": "https://annuaire.preprod.imio.be/braine-l-alleud/3378d97243854ddfa90510f6ceb9fcdb/@breadcrumbs"
11
        },
12
        "contextnavigation": {
13
          "@id": "https://annuaire.preprod.imio.be/braine-l-alleud/3378d97243854ddfa90510f6ceb9fcdb/@contextnavigation"
14
        },
15
        "navigation": {
16
          "@id": "https://annuaire.preprod.imio.be/braine-l-alleud/3378d97243854ddfa90510f6ceb9fcdb/@navigation"
17
        },
18
        "types": {
19
          "@id": "https://annuaire.preprod.imio.be/braine-l-alleud/3378d97243854ddfa90510f6ceb9fcdb/@types"
20
        },
21
        "workflow": {
22
          "@id": "https://annuaire.preprod.imio.be/braine-l-alleud/3378d97243854ddfa90510f6ceb9fcdb/@workflow"
23
        }
24
      },
25
      "@id": "https://annuaire.preprod.imio.be/braine-l-alleud/3378d97243854ddfa90510f6ceb9fcdb",
26
      "@type": "imio.directory.Contact",
27
      "UID": "23a32197d6c841259963b43b24747854",
28
      "allow_discussion": false,
29
      "city": "Braine-l'Alleud",
30
      "complement": null,
31
      "country": {
32
        "title": "Belgique",
33
        "token": "be"
34
      },
35
      "created": "2021-07-28T07:10:02+00:00",
36
      "description": "Contact : Jean-Pascal Hinnekens (directeur)",
37
      "exceptional_closure": [],
38
      "facilities": null,
39
      "geolocation": {
40
        "latitude": 50.4989185,
41
        "longitude": 4.7184485
42
      },
43
      "iam": [
44
        {
45
          "title": "Jeune",
46
          "token": "young"
47
        },
48
        {
49
          "title": "Nouvel arrivant",
50
          "token": "newcomer"
51
        },
52
        {
53
          "title": "Parent",
54
          "token": "parent"
55
        }
56
      ],
57
      "id": "3378d97243854ddfa90510f6ceb9fcdb",
58
      "image": null,
59
      "image_caption": null,
60
      "is_folderish": true,
61
      "is_geolocated": true,
62
      "language": {
63
        "title": "Fran\u00e7ais",
64
        "token": "fr"
65
      },
66
      "layout": "view",
67
      "logo": {
68
        "content-type": "image/png",
69
        "download": "https://annuaire.preprod.imio.be/braine-l-alleud/3378d97243854ddfa90510f6ceb9fcdb/@@images/b5785773-138a-4907-9ec6-b29100f18e85.png",
70
        "filename": "acad\u00e9mie musique braine-l'alleud.png",
71
        "height": 591,
72
        "scales": {
73
          "banner": {
74
            "download": "https://annuaire.preprod.imio.be/braine-l-alleud/3378d97243854ddfa90510f6ceb9fcdb/@@images/64373ca2-151b-439d-a489-288c83a94de6.png",
75
            "height": 591,
76
            "width": 559
77
          },
78
          "extralarge": {
79
            "download": "https://annuaire.preprod.imio.be/braine-l-alleud/3378d97243854ddfa90510f6ceb9fcdb/@@images/cb8f259c-1255-4d5d-9b1a-fdace828f74c.png",
80
            "height": 591,
81
            "width": 559
82
          },
83
          "icon": {
84
            "download": "https://annuaire.preprod.imio.be/braine-l-alleud/3378d97243854ddfa90510f6ceb9fcdb/@@images/77276cc0-e251-450b-8a4c-968e1d5c4ac4.png",
85
            "height": 32,
86
            "width": 31
87
          },
88
          "large": {
89
            "download": "https://annuaire.preprod.imio.be/braine-l-alleud/3378d97243854ddfa90510f6ceb9fcdb/@@images/6ec18ddf-645a-4764-b7b4-fedfe7c1d9e0.png",
90
            "height": 591,
91
            "width": 559
92
          },
93
          "listing": {
94
            "download": "https://annuaire.preprod.imio.be/braine-l-alleud/3378d97243854ddfa90510f6ceb9fcdb/@@images/07b35265-e56f-4fa0-9717-b1982604f9b4.png",
95
            "height": 16,
96
            "width": 16
97
          },
98
          "medium": {
99
            "download": "https://annuaire.preprod.imio.be/braine-l-alleud/3378d97243854ddfa90510f6ceb9fcdb/@@images/4470f78e-19fb-41aa-b897-4f9b0f5f356e.png",
100
            "height": 591,
101
            "width": 559
102
          },
103
          "mini": {
104
            "download": "https://annuaire.preprod.imio.be/braine-l-alleud/3378d97243854ddfa90510f6ceb9fcdb/@@images/548c616b-d37b-43be-b931-42c17b462127.png",
105
            "height": 200,
106
            "width": 189
107
          },
108
          "preview": {
109
            "download": "https://annuaire.preprod.imio.be/braine-l-alleud/3378d97243854ddfa90510f6ceb9fcdb/@@images/84394ebe-2d4d-4894-b331-1636e93ccd38.png",
110
            "height": 400,
111
            "width": 379
112
          },
113
          "thumb": {
114
            "download": "https://annuaire.preprod.imio.be/braine-l-alleud/3378d97243854ddfa90510f6ceb9fcdb/@@images/087c5b17-5ae3-4e9d-a794-a95440e5aa43.png",
115
            "height": 128,
116
            "width": 121
117
          },
118
          "tile": {
119
            "download": "https://annuaire.preprod.imio.be/braine-l-alleud/3378d97243854ddfa90510f6ceb9fcdb/@@images/34278749-0375-4000-924f-b56993076bbd.png",
120
            "height": 64,
121
            "width": 61
122
          }
123
        },
124
        "size": 232832,
125
        "width": 559
126
      },
127
      "mails": [
128
        {
129
          "label": null,
130
          "mail_address": "academie.musique@braine-lalleud.be",
131
          "type": "work"
132
        }
133
      ],
134
      "modified": "2021-10-14T10:48:57+00:00",
135
      "multi_schedule": [],
136
      "next_item": {
137
        "@id": "https://annuaire.preprod.imio.be/braine-l-alleud/f82d2c079131433ea6ab20f9f7f49442",
138
        "@type": "imio.directory.Contact",
139
        "description": "\u00ab Accueil et Orientation Volontariat \u00bb guide et conseille les candidats volontaires.  Cette ASBL est pluraliste et poursuit les objectifs suivants :  - faciliter et encourager la pratique de volontariat aupr\u00e8s des associations locales, - informer et recruter des candidats en vue d\u2019un volontariat citoyen de qualit\u00e9.  Si vous d\u00e9sirez vous engager dans une action de volontariat, vous pouvez contacter le centre d\u2019orientation afin de prendre rendez-vous. Vous serez re\u00e7u par 2 volontaires et pourrez choisir les activit\u00e9s convenant le mieux \u00e0 vos aspirations, \u00e0 vos comp\u00e9tences et vos disponibilit\u00e9s.  Permanences le vendredi matin (uniquement sur rendez-vous) \u00e0 l\u2019H\u00f4tel communal (Maison des Associations)",
140
        "title": "Accueil et Orientation Volontariat (A.O.V.)"
141
      },
142
      "number": "49",
143
      "parent": {
144
        "@id": "https://annuaire.preprod.imio.be/braine-l-alleud",
145
        "@type": "imio.directory.Entity",
146
        "description": "",
147
        "review_state": "published",
148
        "title": "Braine-l'Alleud"
149
      },
150
      "phones": [
151
        {
152
          "label": null,
153
          "number": "+3228540720",
154
          "type": "work"
155
        },
156
        {
157
          "label": null,
158
          "number": "+3228540729",
159
          "type": "fax"
160
        }
161
      ],
162
      "previous_item": {
163
        "@id": "https://annuaire.preprod.imio.be/braine-l-alleud/30bc56007a5140358de0a5ad897b7a47",
164
        "@type": "imio.directory.Contact",
165
        "description": "Contact :  Vinciane Vrielinck",
166
        "title": "Cabinet du Bourgmestre de la Commune de Braine-l'Alleud"
167
      },
168
      "review_state": "published",
169
      "schedule": {
170
        "friday": {
171
          "afternoonend": "",
172
          "afternoonstart": "",
173
          "comment": "",
174
          "morningend": "",
175
          "morningstart": ""
176
        },
177
        "monday": {
178
          "afternoonend": "",
179
          "afternoonstart": "",
180
          "comment": "",
181
          "morningend": "",
182
          "morningstart": ""
183
        },
184
        "saturday": {
185
          "afternoonend": "",
186
          "afternoonstart": "",
187
          "comment": "",
188
          "morningend": "",
189
          "morningstart": ""
190
        },
191
        "sunday": {
192
          "afternoonend": "",
193
          "afternoonstart": "",
194
          "comment": "",
195
          "morningend": "",
196
          "morningstart": ""
197
        },
198
        "thursday": {
199
          "afternoonend": "",
200
          "afternoonstart": "",
201
          "comment": "",
202
          "morningend": "",
203
          "morningstart": ""
204
        },
205
        "tuesday": {
206
          "afternoonend": "",
207
          "afternoonstart": "",
208
          "comment": "",
209
          "morningend": "",
210
          "morningstart": ""
211
        },
212
        "wednesday": {
213
          "afternoonend": "",
214
          "afternoonstart": "",
215
          "comment": "",
216
          "morningend": "",
217
          "morningstart": ""
218
        }
219
      },
220
      "selected_entities": [
221
        {
222
          "title": "Braine-l'Alleud",
223
          "token": "f571b73a16f34832a5fdd3683533b3cc"
224
        }
225
      ],
226
      "street": "Rue du Ch\u00e2teau",
227
      "subjects": [
228
        "\u00e9cole"
229
      ],
230
      "subtitle": null,
231
      "taxonomy_contact_category": [
232
        {
233
          "title": "Loisirs \u00bb Cours et activit\u00e9s \u00bb Musique",
234
          "token": "3qaeiq8v2p"
235
        }
236
      ],
237
      "title": "Acad\u00e9mie de Musique de Braine-l'Alleud",
238
      "topics": [
239
        {
240
          "title": "Culture",
241
          "token": "culture"
242
        },
243
        {
244
          "title": "\u00c9ducation",
245
          "token": "education"
246
        }
247
      ],
248
      "type": {
249
        "title": "Organisation (service administratif, commerce, profession lib\u00e9rale, club sportif, association, etc.)",
250
        "token": "organization"
251
      },
252
      "urls": [
253
        {
254
          "type": "website",
255
          "url": "http://academie-de-musique.braine-lalleud.be/"
256
        }
257
      ],
258
      "vat_number": null,
259
      "version": "current",
260
      "working_copy": null,
261
      "working_copy_of": null,
262
      "zipcode": 1420
263
    }
264
  ],
265
  "items_total": 1
266
}
tests/data/plone_restapi/q_search.json
1
{
2
  "@id": "https://annuaire.preprod.imio.be/braine-l-alleud/@search?portal_type=imio.directory.Contact&review_state=published&fullobjects=y",
3
  "batching": {
4
    "@id": "https://annuaire.preprod.imio.be/braine-l-alleud/@search?b_size=3&portal_type=imio.directory.Contact&review_state=published&fullobjects=y",
5
    "first": "https://annuaire.preprod.imio.be/braine-l-alleud/@search?b_start=0&b_size=3&portal_type=imio.directory.Contact&review_state=published&fullobjects=y",
6
    "last": "https://annuaire.preprod.imio.be/braine-l-alleud/@search?b_start=261&b_size=3&portal_type=imio.directory.Contact&review_state=published&fullobjects=y",
7
    "next": "https://annuaire.preprod.imio.be/braine-l-alleud/@search?b_start=3&b_size=3&portal_type=imio.directory.Contact&review_state=published&fullobjects=y"
8
  },
9
  "items": [
10
    {
11
      "@components": {
12
        "actions": {
13
          "@id": "https://annuaire.preprod.imio.be/braine-l-alleud/30bc56007a5140358de0a5ad897b7a47/@actions"
14
        },
15
        "breadcrumbs": {
16
          "@id": "https://annuaire.preprod.imio.be/braine-l-alleud/30bc56007a5140358de0a5ad897b7a47/@breadcrumbs"
17
        },
18
        "contextnavigation": {
19
          "@id": "https://annuaire.preprod.imio.be/braine-l-alleud/30bc56007a5140358de0a5ad897b7a47/@contextnavigation"
20
        },
21
        "navigation": {
22
          "@id": "https://annuaire.preprod.imio.be/braine-l-alleud/30bc56007a5140358de0a5ad897b7a47/@navigation"
23
        },
24
        "types": {
25
          "@id": "https://annuaire.preprod.imio.be/braine-l-alleud/30bc56007a5140358de0a5ad897b7a47/@types"
26
        },
27
        "workflow": {
28
          "@id": "https://annuaire.preprod.imio.be/braine-l-alleud/30bc56007a5140358de0a5ad897b7a47/@workflow"
29
        }
30
      },
31
      "@id": "https://annuaire.preprod.imio.be/braine-l-alleud/30bc56007a5140358de0a5ad897b7a47",
32
      "@type": "imio.directory.Contact",
33
      "UID": "dea9d26baab944beb7e54d4024d35a33",
34
      "allow_discussion": false,
35
      "city": "Braine-l'Alleud",
36
      "complement": null,
37
      "country": {
38
        "title": "Belgique",
39
        "token": "be"
40
      },
41
      "created": "2021-07-28T07:23:24+00:00",
42
      "description": "Contact :  Vinciane Vrielinck",
43
      "exceptional_closure": [],
44
      "facilities": null,
45
      "geolocation": {
46
        "latitude": 50.4989185,
47
        "longitude": 4.7184485
48
      },
49
      "iam": [
50
        {
51
          "title": "Nouvel arrivant",
52
          "token": "newcomer"
53
        }
54
      ],
55
      "id": "30bc56007a5140358de0a5ad897b7a47",
56
      "image": null,
57
      "image_caption": null,
58
      "is_folderish": true,
59
      "is_geolocated": true,
60
      "language": {
61
        "title": "Fran\u00e7ais",
62
        "token": "fr"
63
      },
64
      "layout": "view",
65
      "logo": null,
66
      "mails": [
67
        {
68
          "label": null,
69
          "mail_address": "bourgmestre@braine-lalleud.be",
70
          "type": "work"
71
        }
72
      ],
73
      "modified": "2021-09-22T13:15:16+00:00",
74
      "multi_schedule": [],
75
      "next_item": {
76
        "@id": "https://annuaire.preprod.imio.be/braine-l-alleud/3378d97243854ddfa90510f6ceb9fcdb",
77
        "@type": "imio.directory.Contact",
78
        "description": "Contact : Jean-Pascal Hinnekens (directeur)",
79
        "title": "Acad\u00e9mie de Musique de Braine-l'Alleud"
80
      },
81
      "number": "1",
82
      "parent": {
83
        "@id": "https://annuaire.preprod.imio.be/braine-l-alleud",
84
        "@type": "imio.directory.Entity",
85
        "description": "",
86
        "review_state": "published",
87
        "title": "Braine-l'Alleud"
88
      },
89
      "phones": [
90
        {
91
          "label": null,
92
          "number": "+3228540500",
93
          "type": "work"
94
        }
95
      ],
96
      "previous_item": {},
97
      "review_state": "published",
98
      "schedule": {
99
        "friday": {
100
          "afternoonend": "",
101
          "afternoonstart": "",
102
          "comment": "",
103
          "morningend": "",
104
          "morningstart": ""
105
        },
106
        "monday": {
107
          "afternoonend": "",
108
          "afternoonstart": "",
109
          "comment": "",
110
          "morningend": "",
111
          "morningstart": ""
112
        },
113
        "saturday": {
114
          "afternoonend": "",
115
          "afternoonstart": "",
116
          "comment": "",
117
          "morningend": "",
118
          "morningstart": ""
119
        },
120
        "sunday": {
121
          "afternoonend": "",
122
          "afternoonstart": "",
123
          "comment": "",
124
          "morningend": "",
125
          "morningstart": ""
126
        },
127
        "thursday": {
128
          "afternoonend": "",
129
          "afternoonstart": "",
130
          "comment": "",
131
          "morningend": "",
132
          "morningstart": ""
133
        },
134
        "tuesday": {
135
          "afternoonend": "",
136
          "afternoonstart": "",
137
          "comment": "",
138
          "morningend": "",
139
          "morningstart": ""
140
        },
141
        "wednesday": {
142
          "afternoonend": "",
143
          "afternoonstart": "",
144
          "comment": "",
145
          "morningend": "",
146
          "morningstart": ""
147
        }
148
      },
149
      "selected_entities": [
150
        {
151
          "title": "Braine-l'Alleud",
152
          "token": "f571b73a16f34832a5fdd3683533b3cc"
153
        }
154
      ],
155
      "street": "Avenue du 21 Juillet",
156
      "subjects": [
157
        "scourneau",
158
        "mr"
159
      ],
160
      "subtitle": null,
161
      "taxonomy_contact_category": [
162
        {
163
          "title": "Service public \u00bb Administration communale",
164
          "token": "xhowidw6kd"
165
        }
166
      ],
167
      "title": "Cabinet du Bourgmestre de la Commune de Braine-l'Alleud",
168
      "topics": [
169
        {
170
          "title": "Politique",
171
          "token": "politics"
172
        }
173
      ],
174
      "type": {
175
        "title": "Organisation (service administratif, commerce, profession lib\u00e9rale, club sportif, association, etc.)",
176
        "token": "organization"
177
      },
178
      "urls": [],
179
      "vat_number": null,
180
      "version": "current",
181
      "working_copy": null,
182
      "working_copy_of": null,
183
      "zipcode": 1420
184
    },
185
    {
186
      "@components": {
187
        "actions": {
188
          "@id": "https://annuaire.preprod.imio.be/braine-l-alleud/3378d97243854ddfa90510f6ceb9fcdb/@actions"
189
        },
190
        "breadcrumbs": {
191
          "@id": "https://annuaire.preprod.imio.be/braine-l-alleud/3378d97243854ddfa90510f6ceb9fcdb/@breadcrumbs"
192
        },
193
        "contextnavigation": {
194
          "@id": "https://annuaire.preprod.imio.be/braine-l-alleud/3378d97243854ddfa90510f6ceb9fcdb/@contextnavigation"
195
        },
196
        "navigation": {
197
          "@id": "https://annuaire.preprod.imio.be/braine-l-alleud/3378d97243854ddfa90510f6ceb9fcdb/@navigation"
198
        },
199
        "types": {
200
          "@id": "https://annuaire.preprod.imio.be/braine-l-alleud/3378d97243854ddfa90510f6ceb9fcdb/@types"
201
        },
202
        "workflow": {
203
          "@id": "https://annuaire.preprod.imio.be/braine-l-alleud/3378d97243854ddfa90510f6ceb9fcdb/@workflow"
204
        }
205
      },
206
      "@id": "https://annuaire.preprod.imio.be/braine-l-alleud/3378d97243854ddfa90510f6ceb9fcdb",
207
      "@type": "imio.directory.Contact",
208
      "UID": "23a32197d6c841259963b43b24747854",
209
      "allow_discussion": false,
210
      "city": "Braine-l'Alleud",
211
      "complement": null,
212
      "country": {
213
        "title": "Belgique",
214
        "token": "be"
215
      },
216
      "created": "2021-07-28T07:10:02+00:00",
217
      "description": "Contact : Jean-Pascal Hinnekens (directeur)",
218
      "exceptional_closure": [],
219
      "facilities": null,
220
      "geolocation": {
221
        "latitude": 50.4989185,
222
        "longitude": 4.7184485
223
      },
224
      "iam": [
225
        {
226
          "title": "Jeune",
227
          "token": "young"
228
        },
229
        {
230
          "title": "Nouvel arrivant",
231
          "token": "newcomer"
232
        },
233
        {
234
          "title": "Parent",
235
          "token": "parent"
236
        }
237
      ],
238
      "id": "3378d97243854ddfa90510f6ceb9fcdb",
239
      "image": null,
240
      "image_caption": null,
241
      "is_folderish": true,
242
      "is_geolocated": true,
243
      "language": {
244
        "title": "Fran\u00e7ais",
245
        "token": "fr"
246
      },
247
      "layout": "view",
248
      "logo": {
249
        "content-type": "image/png",
250
        "download": "https://annuaire.preprod.imio.be/braine-l-alleud/3378d97243854ddfa90510f6ceb9fcdb/@@images/b5785773-138a-4907-9ec6-b29100f18e85.png",
251
        "filename": "acad\u00e9mie musique braine-l'alleud.png",
252
        "height": 591,
253
        "scales": {
254
          "banner": {
255
            "download": "https://annuaire.preprod.imio.be/braine-l-alleud/3378d97243854ddfa90510f6ceb9fcdb/@@images/64373ca2-151b-439d-a489-288c83a94de6.png",
256
            "height": 591,
257
            "width": 559
258
          },
259
          "extralarge": {
260
            "download": "https://annuaire.preprod.imio.be/braine-l-alleud/3378d97243854ddfa90510f6ceb9fcdb/@@images/cb8f259c-1255-4d5d-9b1a-fdace828f74c.png",
261
            "height": 591,
262
            "width": 559
263
          },
264
          "icon": {
265
            "download": "https://annuaire.preprod.imio.be/braine-l-alleud/3378d97243854ddfa90510f6ceb9fcdb/@@images/77276cc0-e251-450b-8a4c-968e1d5c4ac4.png",
266
            "height": 32,
267
            "width": 31
268
          },
269
          "large": {
270
            "download": "https://annuaire.preprod.imio.be/braine-l-alleud/3378d97243854ddfa90510f6ceb9fcdb/@@images/6ec18ddf-645a-4764-b7b4-fedfe7c1d9e0.png",
271
            "height": 591,
272
            "width": 559
273
          },
274
          "listing": {
275
            "download": "https://annuaire.preprod.imio.be/braine-l-alleud/3378d97243854ddfa90510f6ceb9fcdb/@@images/07b35265-e56f-4fa0-9717-b1982604f9b4.png",
276
            "height": 16,
277
            "width": 16
278
          },
279
          "medium": {
280
            "download": "https://annuaire.preprod.imio.be/braine-l-alleud/3378d97243854ddfa90510f6ceb9fcdb/@@images/4470f78e-19fb-41aa-b897-4f9b0f5f356e.png",
281
            "height": 591,
282
            "width": 559
283
          },
284
          "mini": {
285
            "download": "https://annuaire.preprod.imio.be/braine-l-alleud/3378d97243854ddfa90510f6ceb9fcdb/@@images/548c616b-d37b-43be-b931-42c17b462127.png",
286
            "height": 200,
287
            "width": 189
288
          },
289
          "preview": {
290
            "download": "https://annuaire.preprod.imio.be/braine-l-alleud/3378d97243854ddfa90510f6ceb9fcdb/@@images/84394ebe-2d4d-4894-b331-1636e93ccd38.png",
291
            "height": 400,
292
            "width": 379
293
          },
294
          "thumb": {
295
            "download": "https://annuaire.preprod.imio.be/braine-l-alleud/3378d97243854ddfa90510f6ceb9fcdb/@@images/087c5b17-5ae3-4e9d-a794-a95440e5aa43.png",
296
            "height": 128,
297
            "width": 121
298
          },
299
          "tile": {
300
            "download": "https://annuaire.preprod.imio.be/braine-l-alleud/3378d97243854ddfa90510f6ceb9fcdb/@@images/34278749-0375-4000-924f-b56993076bbd.png",
301
            "height": 64,
302
            "width": 61
303
          }
304
        },
305
        "size": 232832,
306
        "width": 559
307
      },
308
      "mails": [
309
        {
310
          "label": null,
311
          "mail_address": "academie.musique@braine-lalleud.be",
312
          "type": "work"
313
        }
314
      ],
315
      "modified": "2021-10-14T10:48:57+00:00",
316
      "multi_schedule": [],
317
      "next_item": {
318
        "@id": "https://annuaire.preprod.imio.be/braine-l-alleud/f82d2c079131433ea6ab20f9f7f49442",
319
        "@type": "imio.directory.Contact",
320
        "description": "\u00ab Accueil et Orientation Volontariat \u00bb guide et conseille les candidats volontaires.  Cette ASBL est pluraliste et poursuit les objectifs suivants :  - faciliter et encourager la pratique de volontariat aupr\u00e8s des associations locales, - informer et recruter des candidats en vue d\u2019un volontariat citoyen de qualit\u00e9.  Si vous d\u00e9sirez vous engager dans une action de volontariat, vous pouvez contacter le centre d\u2019orientation afin de prendre rendez-vous. Vous serez re\u00e7u par 2 volontaires et pourrez choisir les activit\u00e9s convenant le mieux \u00e0 vos aspirations, \u00e0 vos comp\u00e9tences et vos disponibilit\u00e9s.  Permanences le vendredi matin (uniquement sur rendez-vous) \u00e0 l\u2019H\u00f4tel communal (Maison des Associations)",
321
        "title": "Accueil et Orientation Volontariat (A.O.V.)"
322
      },
323
      "number": "49",
324
      "parent": {
325
        "@id": "https://annuaire.preprod.imio.be/braine-l-alleud",
326
        "@type": "imio.directory.Entity",
327
        "description": "",
328
        "review_state": "published",
329
        "title": "Braine-l'Alleud"
330
      },
331
      "phones": [
332
        {
333
          "label": null,
334
          "number": "+3228540720",
335
          "type": "work"
336
        },
337
        {
338
          "label": null,
339
          "number": "+3228540729",
340
          "type": "fax"
341
        }
342
      ],
343
      "previous_item": {
344
        "@id": "https://annuaire.preprod.imio.be/braine-l-alleud/30bc56007a5140358de0a5ad897b7a47",
345
        "@type": "imio.directory.Contact",
346
        "description": "Contact :  Vinciane Vrielinck",
347
        "title": "Cabinet du Bourgmestre de la Commune de Braine-l'Alleud"
348
      },
349
      "review_state": "published",
350
      "schedule": {
351
        "friday": {
352
          "afternoonend": "",
353
          "afternoonstart": "",
354
          "comment": "",
355
          "morningend": "",
356
          "morningstart": ""
357
        },
358
        "monday": {
359
          "afternoonend": "",
360
          "afternoonstart": "",
361
          "comment": "",
362
          "morningend": "",
363
          "morningstart": ""
364
        },
365
        "saturday": {
366
          "afternoonend": "",
367
          "afternoonstart": "",
368
          "comment": "",
369
          "morningend": "",
370
          "morningstart": ""
371
        },
372
        "sunday": {
373
          "afternoonend": "",
374
          "afternoonstart": "",
375
          "comment": "",
376
          "morningend": "",
377
          "morningstart": ""
378
        },
379
        "thursday": {
380
          "afternoonend": "",
381
          "afternoonstart": "",
382
          "comment": "",
383
          "morningend": "",
384
          "morningstart": ""
385
        },
386
        "tuesday": {
387
          "afternoonend": "",
388
          "afternoonstart": "",
389
          "comment": "",
390
          "morningend": "",
391
          "morningstart": ""
392
        },
393
        "wednesday": {
394
          "afternoonend": "",
395
          "afternoonstart": "",
396
          "comment": "",
397
          "morningend": "",
398
          "morningstart": ""
399
        }
400
      },
401
      "selected_entities": [
402
        {
403
          "title": "Braine-l'Alleud",
404
          "token": "f571b73a16f34832a5fdd3683533b3cc"
405
        }
406
      ],
407
      "street": "Rue du Ch\u00e2teau",
408
      "subjects": [
409
        "\u00e9cole"
410
      ],
411
      "subtitle": null,
412
      "taxonomy_contact_category": [
413
        {
414
          "title": "Loisirs \u00bb Cours et activit\u00e9s \u00bb Musique",
415
          "token": "3qaeiq8v2p"
416
        }
417
      ],
418
      "title": "Acad\u00e9mie de Musique de Braine-l'Alleud",
419
      "topics": [
420
        {
421
          "title": "Culture",
422
          "token": "culture"
423
        },
424
        {
425
          "title": "\u00c9ducation",
426
          "token": "education"
427
        }
428
      ],
429
      "type": {
430
        "title": "Organisation (service administratif, commerce, profession lib\u00e9rale, club sportif, association, etc.)",
431
        "token": "organization"
432
      },
433
      "urls": [
434
        {
435
          "type": "website",
436
          "url": "http://academie-de-musique.braine-lalleud.be/"
437
        }
438
      ],
439
      "vat_number": null,
440
      "version": "current",
441
      "working_copy": null,
442
      "working_copy_of": null,
443
      "zipcode": 1420
444
    },
445
    {
446
      "@components": {
447
        "actions": {
448
          "@id": "https://annuaire.preprod.imio.be/braine-l-alleud/f82d2c079131433ea6ab20f9f7f49442/@actions"
449
        },
450
        "breadcrumbs": {
451
          "@id": "https://annuaire.preprod.imio.be/braine-l-alleud/f82d2c079131433ea6ab20f9f7f49442/@breadcrumbs"
452
        },
453
        "contextnavigation": {
454
          "@id": "https://annuaire.preprod.imio.be/braine-l-alleud/f82d2c079131433ea6ab20f9f7f49442/@contextnavigation"
455
        },
456
        "navigation": {
457
          "@id": "https://annuaire.preprod.imio.be/braine-l-alleud/f82d2c079131433ea6ab20f9f7f49442/@navigation"
458
        },
459
        "types": {
460
          "@id": "https://annuaire.preprod.imio.be/braine-l-alleud/f82d2c079131433ea6ab20f9f7f49442/@types"
461
        },
462
        "workflow": {
463
          "@id": "https://annuaire.preprod.imio.be/braine-l-alleud/f82d2c079131433ea6ab20f9f7f49442/@workflow"
464
        }
465
      },
466
      "@id": "https://annuaire.preprod.imio.be/braine-l-alleud/f82d2c079131433ea6ab20f9f7f49442",
467
      "@type": "imio.directory.Contact",
468
      "UID": "f82d2c079131433ea6ab20f9f7f49442",
469
      "allow_discussion": false,
470
      "city": "Braine-l'Alleud",
471
      "complement": null,
472
      "country": {
473
        "title": "Belgique",
474
        "token": "be"
475
      },
476
      "created": "2021-08-20T09:27:46+00:00",
477
      "description": "\u00ab Accueil et Orientation Volontariat \u00bb guide et conseille les candidats volontaires.\r\n\r\nCette ASBL est pluraliste et poursuit les objectifs suivants :\r\n\r\n- faciliter et encourager la pratique de volontariat aupr\u00e8s des associations locales,\r\n- informer et recruter des candidats en vue d\u2019un volontariat citoyen de qualit\u00e9.\r\n\r\nSi vous d\u00e9sirez vous engager dans une action de volontariat, vous pouvez contacter le centre d\u2019orientation afin de prendre rendez-vous. Vous serez re\u00e7u par 2 volontaires et pourrez choisir les activit\u00e9s convenant le mieux \u00e0 vos aspirations, \u00e0 vos comp\u00e9tences et vos disponibilit\u00e9s.\r\n\r\nPermanences le vendredi matin (uniquement sur rendez-vous) \u00e0 l\u2019H\u00f4tel communal (Maison des Associations)",
478
      "exceptional_closure": [],
479
      "facilities": null,
480
      "geolocation": {
481
        "latitude": 50.4989185,
482
        "longitude": 4.7184485
483
      },
484
      "iam": [
485
        {
486
          "title": "Jeune",
487
          "token": "young"
488
        },
489
        {
490
          "title": "Nouvel arrivant",
491
          "token": "newcomer"
492
        }
493
      ],
494
      "id": "f82d2c079131433ea6ab20f9f7f49442",
495
      "image": null,
496
      "image_caption": null,
497
      "is_folderish": true,
498
      "is_geolocated": true,
499
      "language": {
500
        "title": "Fran\u00e7ais",
501
        "token": "fr"
502
      },
503
      "layout": "view",
504
      "logo": null,
505
      "mails": [],
506
      "modified": "2021-09-23T11:50:05+00:00",
507
      "multi_schedule": [],
508
      "next_item": {
509
        "@id": "https://annuaire.preprod.imio.be/braine-l-alleud/813f4b02118d498ab779ec8542315e66",
510
        "@type": "imio.directory.Contact",
511
        "description": "",
512
        "title": "Association des commer\u00e7ants et artisans de Braine-l\u2019Alleud"
513
      },
514
      "number": "3",
515
      "parent": {
516
        "@id": "https://annuaire.preprod.imio.be/braine-l-alleud",
517
        "@type": "imio.directory.Entity",
518
        "description": "",
519
        "review_state": "published",
520
        "title": "Braine-l'Alleud"
521
      },
522
      "phones": [
523
        {
524
          "label": null,
525
          "number": "+3223846945",
526
          "type": "work"
527
        }
528
      ],
529
      "previous_item": {
530
        "@id": "https://annuaire.preprod.imio.be/braine-l-alleud/3378d97243854ddfa90510f6ceb9fcdb",
531
        "@type": "imio.directory.Contact",
532
        "description": "Contact : Jean-Pascal Hinnekens (directeur)",
533
        "title": "Acad\u00e9mie de Musique de Braine-l'Alleud"
534
      },
535
      "review_state": "published",
536
      "schedule": {
537
        "friday": {
538
          "afternoonend": "",
539
          "afternoonstart": "",
540
          "comment": "",
541
          "morningend": "",
542
          "morningstart": ""
543
        },
544
        "monday": {
545
          "afternoonend": "",
546
          "afternoonstart": "",
547
          "comment": "",
548
          "morningend": "",
549
          "morningstart": ""
550
        },
551
        "saturday": {
552
          "afternoonend": "",
553
          "afternoonstart": "",
554
          "comment": "",
555
          "morningend": "",
556
          "morningstart": ""
557
        },
558
        "sunday": {
559
          "afternoonend": "",
560
          "afternoonstart": "",
561
          "comment": "",
562
          "morningend": "",
563
          "morningstart": ""
564
        },
565
        "thursday": {
566
          "afternoonend": "",
567
          "afternoonstart": "",
568
          "comment": "",
569
          "morningend": "",
570
          "morningstart": ""
571
        },
572
        "tuesday": {
573
          "afternoonend": "",
574
          "afternoonstart": "",
575
          "comment": "",
576
          "morningend": "",
577
          "morningstart": ""
578
        },
579
        "wednesday": {
580
          "afternoonend": "",
581
          "afternoonstart": "",
582
          "comment": "",
583
          "morningend": "",
584
          "morningstart": ""
585
        }
586
      },
587
      "selected_entities": [
588
        {
589
          "title": "Braine-l'Alleud",
590
          "token": "f571b73a16f34832a5fdd3683533b3cc"
591
        }
592
      ],
593
      "street": "Grand-Place Baudouin 1er",
594
      "subjects": [
595
        "b\u00e9n\u00e9volat"
596
      ],
597
      "subtitle": null,
598
      "taxonomy_contact_category": [
599
        {
600
          "title": "Loisirs \u00bb Mouvements et associations",
601
          "token": "13drlsiykl"
602
        }
603
      ],
604
      "title": "Accueil et Orientation Volontariat (A.O.V.)",
605
      "topics": [
606
        {
607
          "title": "Participation citoyenne",
608
          "token": "citizen_participation"
609
        }
610
      ],
611
      "type": {
612
        "title": "Organisation (service administratif, commerce, profession lib\u00e9rale, club sportif, association, etc.)",
613
        "token": "organization"
614
      },
615
      "urls": [
616
        {
617
          "type": "website",
618
          "url": "https://www.aovolontariat.be/"
619
        }
620
      ],
621
      "vat_number": null,
622
      "version": "current",
623
      "working_copy": null,
624
      "working_copy_of": null,
625
      "zipcode": 1420
626
    }
627
  ],
628
  "items_total": 264
629
}
tests/test_plone_restapi.py
1
# -*- coding: utf-8 -*-
2
# passerelle - uniform access to multiple data sources and services
3
# Copyright (C) 202  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 json
19
import os
20

  
21
import pytest
22
import utils
23
from requests.exceptions import ConnectionError
24
from test_manager import login
25

  
26
from passerelle.apps.plone_restapi.models import PloneRestApi, Query
27
from passerelle.utils import import_site
28
from passerelle.utils.jsonresponse import APIError
29

  
30
pytestmark = pytest.mark.django_db
31

  
32
TEST_BASE_DIR = os.path.join(os.path.dirname(__file__), 'data', 'plone_restapi')
33

  
34
TOKEN_RESPONSE = {
35
    'access_token': 'd319258e-48b9-4853-88e8-7a2ad6883c7f',
36
    'token_type': 'Bearer',
37
    'expires_in': 28800,
38
    'id_token': 'acd...def',
39
}
40

  
41
TOKEN_ERROR_RESPONSE = {
42
    "error": "access_denied",
43
    "error_description": "Mauvaises informations de connexion de l'utilisateur",
44
}
45

  
46

  
47
def json_get_data(filename):
48
    with open(os.path.join(TEST_BASE_DIR, "%s.json" % filename)) as fd:
49
        return json.load(fd)
50

  
51

  
52
@pytest.fixture
53
def connector():
54
    return utils.setup_access_rights(
55
        PloneRestApi.objects.create(
56
            slug='my_connector',
57
            service_url='http://www.example.net',
58
            token_ws_url='http://www.example.net/idp/oidc/token/',
59
            client_id='aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee',
60
            client_secret='11111111-2222-3333-4444-555555555555',
61
            username='jdoe',
62
            password='secret',
63
        )
64
    )
65

  
66

  
67
@pytest.fixture
68
def token(connector):
69
    with utils.mock_url(url=connector.token_ws_url, response=TOKEN_RESPONSE) as mocked:
70
        yield mocked
71

  
72

  
73
@pytest.fixture
74
def query(connector):
75
    return Query.objects.create(
76
        resource=connector,
77
        name='demo query',
78
        slug='my_query',
79
        description="Annuaire de Braine-l'Alleud",
80
        uri='braine-l-alleud',
81
        text_template='{{ title }} ({{ PLONE_type }})',
82
        filter_expression='''
83
portal_type=Document
84
review_state=published
85
''',
86
        sort='UID',
87
        order=False,
88
        limit=3,
89
    )
90

  
91

  
92
def test_views(db, admin_user, app, connector):
93
    app = login(app)
94
    resp = app.get('/plone-restapi/my_connector/', status=200)
95
    resp = resp.click('New Query')
96
    resp.form['name'] = 'my query'
97
    resp.form['slug'] = 'my-query'
98
    resp.form['uri'] = 'my-uri'
99
    resp = resp.form.submit()
100
    resp = resp.follow()
101
    assert resp.html.find('div', {'id': 'queries'}).ul.li.a.text == 'my query'
102

  
103

  
104
def test_views_query_unicity(admin_user, app, connector, query):
105
    connector2 = PloneRestApi.objects.create(
106
        slug='my_connector2',
107
    )
108
    Query.objects.create(
109
        resource=connector2,
110
        slug='foo-bar',
111
        name='Foo Bar',
112
    )
113

  
114
    # create
115
    app = login(app)
116
    resp = app.get('/manage/plone-restapi/%s/query/new/' % connector.slug)
117
    resp.form['slug'] = query.slug
118
    resp.form['name'] = 'Foo Bar'
119
    resp = resp.form.submit()
120
    assert resp.status_code == 200
121
    assert 'A query with this slug already exists' in resp.text
122
    assert Query.objects.filter(resource=connector).count() == 1
123
    resp.form['slug'] = 'foo-bar'
124
    resp.form['name'] = query.name
125
    resp = resp.form.submit()
126
    assert resp.status_code == 200
127
    assert 'A query with this name already exists' in resp.text
128
    assert Query.objects.filter(resource=connector).count() == 1
129
    resp.form['slug'] = 'foo-bar'
130
    resp.form['name'] = 'Foo Bar'
131
    resp = resp.form.submit()
132
    assert resp.status_code == 302
133
    assert Query.objects.filter(resource=connector).count() == 2
134
    new_query = Query.objects.latest('pk')
135
    assert new_query.resource == connector
136
    assert new_query.slug == 'foo-bar'
137
    assert new_query.name == 'Foo Bar'
138

  
139
    # update
140
    resp = app.get('/manage/plone-restapi/%s/query/%s/' % (connector.slug, new_query.pk))
141
    resp.form['slug'] = query.slug
142
    resp.form['name'] = 'Foo Bar'
143
    resp = resp.form.submit()
144
    assert resp.status_code == 200
145
    assert 'A query with this slug already exists' in resp.text
146
    resp.form['slug'] = 'foo-bar'
147
    resp.form['name'] = query.name
148
    resp = resp.form.submit()
149
    assert resp.status_code == 200
150
    assert 'A query with this name already exists' in resp.text
151
    resp.form['slug'] = 'foo-bar'
152
    resp.form['name'] = 'Foo Bar'
153
    resp.form['uri'] = 'fr'
154
    resp = resp.form.submit()
155
    assert resp.status_code == 302
156
    query = Query.objects.get(resource=connector, slug='foo-bar')
157
    assert query.uri == 'fr'
158

  
159

  
160
def test_export_import(query):
161
    assert PloneRestApi.objects.count() == 1
162
    assert Query.objects.count() == 1
163
    serialization = {'resources': [query.resource.export_json()]}
164
    PloneRestApi.objects.all().delete()
165
    assert PloneRestApi.objects.count() == 0
166
    assert Query.objects.count() == 0
167
    import_site(serialization)
168
    assert PloneRestApi.objects.count() == 1
169
    assert str(PloneRestApi.objects.get().client_id) == 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
170
    assert Query.objects.count() == 1
171

  
172

  
173
def test_adapt_id_and_type_plone_attributes(connector):
174
    plone_response = {
175
        '@type': '@value',
176
        '@dict': {
177
            '@array': [
178
                {
179
                    '@id': '123',
180
                    '@type': '@value',
181
                }
182
            ]
183
        },
184
    }
185
    connector.adapt_id_and_type_plone_attributes(plone_response)
186
    assert plone_response == {
187
        'PLONE_type': '@value',
188
        '@dict': {'@array': [{'PLONE_id': '123', 'PLONE_type': '@value'}]},
189
    }
190

  
191

  
192
def test_adapt_record(connector, token):
193
    data = {
194
        '@id': 'plone id',
195
        'UID': 'plone uid',
196
        'id': 'foo',
197
        'text': 'bar',
198
    }
199
    template = '{{ PLONE_id }}, {{ id }}, {{original_id }}, {{ original_text }}'
200
    assert connector.adapt_record(template, data) == {
201
        'PLONE_id': 'plone id',
202
        'UID': 'plone uid',
203
        'id': 'plone uid',
204
        'text': 'plone id, plone uid, foo, bar',
205
        'original_id': 'foo',
206
        'original_text': 'bar',
207
    }
208

  
209

  
210
def test_get_token(app, connector):
211
    with pytest.raises(APIError):
212
        with utils.mock_url(url=connector.token_ws_url, response=TOKEN_ERROR_RESPONSE, status_code=404):
213
            connector.get_token()
214
    with utils.mock_url(url=connector.token_ws_url, response=TOKEN_RESPONSE) as mocked:
215
        result = connector.get_token()
216
        assert mocked.handlers[0].call['count'] == 1
217
        assert 'secret' in mocked.handlers[0].call['requests'][0].body
218
        assert result == 'acd...def'
219

  
220
        # make sure the token from cache is used
221
        connector.get_token()
222
        assert mocked.handlers[0].call['count'] == 1
223
        connector.get_token(True)
224
        assert mocked.handlers[0].call['count'] == 2
225

  
226

  
227
def test_fetch(app, connector, token):
228
    endpoint = utils.generic_endpoint_url('plone-restapi', 'fetch', slug=connector.slug)
229
    assert endpoint == '/plone-restapi/my_connector/fetch'
230
    url = connector.service_url + '/braine-l-alleud/dccd85d12cf54b6899dff41e5a56ee7f'
231
    params = {
232
        'uid': 'dccd85d12cf54b6899dff41e5a56ee7f',
233
        'uri': 'braine-l-alleud',
234
        'text_template': '{{ title }} ({{ topics.0.title }})',
235
    }
236
    with utils.mock_url(url=url, response=json_get_data('fetch')):
237
        resp = app.get(endpoint, params=params)
238
    assert not resp.json['err']
239
    assert resp.json['data']['id'] == 'dccd85d12cf54b6899dff41e5a56ee7f'
240
    assert resp.json['data']['text'] == 'Le Prisme (Activités et divertissement)'
241
    assert token.handlers[0].call['count'] == 1
242

  
243

  
244
def test_request_anonymously(app, connector, token):
245
    connector.token_ws_url = ''
246
    connector.save()
247
    endpoint = utils.generic_endpoint_url('plone-restapi', 'fetch', slug=connector.slug)
248
    assert endpoint == '/plone-restapi/my_connector/fetch'
249
    url = connector.service_url + '/braine-l-alleud/dccd85d12cf54b6899dff41e5a56ee7f'
250
    params = {
251
        'uid': 'dccd85d12cf54b6899dff41e5a56ee7f',
252
        'uri': 'braine-l-alleud',
253
        'text_template': '{{ title }} ({{ topics.0.title }})',
254
    }
255
    with utils.mock_url(url=url, response=json_get_data('fetch')):
256
        resp = app.get(endpoint, params=params)
257
    assert not resp.json['err']
258
    assert resp.json['data']['id'] == 'dccd85d12cf54b6899dff41e5a56ee7f'
259
    assert resp.json['data']['text'] == 'Le Prisme (Activités et divertissement)'
260
    assert token.handlers[0].call['count'] == 0
261

  
262

  
263
@pytest.mark.parametrize(
264
    'exception, status_code, response, err_desc',
265
    [
266
        [ConnectionError('plop'), None, None, 'plop'],
267
        [None, 200, 'not json', 'bad JSON response'],
268
        [None, 404, {'message': 'Resource not found: ...', 'type': 'NotFound'}, '404 Client Error'],
269
    ],
270
)
271
def test_request_error(app, connector, token, exception, status_code, response, err_desc):
272
    endpoint = utils.generic_endpoint_url('plone-restapi', 'fetch', slug=connector.slug)
273
    assert endpoint == '/plone-restapi/my_connector/fetch'
274
    url = connector.service_url + '/braine-l-alleud/plop'
275
    params = {
276
        'uid': 'plop',
277
        'uri': 'braine-l-alleud',
278
        'text_template': '{{ title }} ({{ PLONE_type }})',
279
    }
280
    with utils.mock_url(url=url, response=response, status_code=status_code, exception=exception):
281
        resp = app.get(endpoint, params=params)
282
    assert resp.json['err']
283
    assert err_desc in resp.json['err_desc']
284

  
285

  
286
def test_create(app, connector, token):
287
    endpoint = utils.generic_endpoint_url('plone-restapi', 'create', slug=connector.slug)
288
    assert endpoint == '/plone-restapi/my_connector/create'
289
    url = connector.service_url + '/braine-l-alleud'
290
    payload = {
291
        '@type': 'imio.directory.Contact',
292
        'title': "Test Entr'ouvert",
293
        'type': 'organization',
294
        'schedule': {},
295
        'topics/0/title': 'Tourisme',
296
        'topics/0/token': 'tourism',
297
    }
298
    with utils.mock_url(url=url, response=json_get_data('fetch'), status_code=201) as mocked:
299
        resp = app.post_json(endpoint + '?uri=braine-l-alleud', params=payload)
300
        body = json.loads(mocked.handlers[0].call['requests'][1].body)
301
        assert body['topics'] == [{'title': 'Tourisme', 'token': 'tourism'}]
302
    assert not resp.json['err']
303
    assert resp.json['data'] == {'uid': 'dccd85d12cf54b6899dff41e5a56ee7f', 'created': True}
304

  
305

  
306
def test_create_wrong_payload(app, connector, token):
307
    endpoint = utils.generic_endpoint_url('plone-restapi', 'create', slug=connector.slug)
308
    assert endpoint == '/plone-restapi/my_connector/create'
309
    url = connector.service_url + '/braine-l-alleud'
310
    payload = 'not json'
311
    resp = app.post(endpoint + '?uri=braine-l-alleud', params=payload, status=400)
312
    assert resp.json['err']
313
    assert resp.json['err_desc'] == 'Expecting value: line 1 column 1 (char 0)'
314
    assert resp.json['err_class'] == 'passerelle.apps.plone_restapi.models.ParameterTypeError'
315

  
316

  
317
def test_update(app, connector, token):
318
    endpoint = utils.generic_endpoint_url('plone-restapi', 'update', slug=connector.slug)
319
    assert endpoint == '/plone-restapi/my_connector/update'
320
    url = connector.service_url + '/braine-l-alleud/dccd85d12cf54b6899dff41e5a56ee7f'
321
    query_string = '?uri=braine-l-alleud&uid=dccd85d12cf54b6899dff41e5a56ee7f'
322
    payload = {
323
        'title': 'Test update',
324
        'topics/0/token': 'social',
325
    }
326
    with utils.mock_url(url=url, response='', status_code=204) as mocked:
327
        resp = app.post_json(endpoint + query_string, params=payload)
328
        body = json.loads(mocked.handlers[0].call['requests'][1].body)
329
        assert body['topics'] == [{'token': 'social'}]
330
    assert not resp.json['err']
331
    assert resp.json['data'] == {'uid': 'dccd85d12cf54b6899dff41e5a56ee7f', 'updated': True}
332

  
333

  
334
def test_update_wrong_payload(app, connector, token):
335
    endpoint = utils.generic_endpoint_url('plone-restapi', 'update', slug=connector.slug)
336
    assert endpoint == '/plone-restapi/my_connector/update'
337
    url = connector.service_url + '/braine-l-alleud/dccd85d12cf54b6899dff41e5a56ee7f'
338
    query_string = '?uri=braine-l-alleud&uid=dccd85d12cf54b6899dff41e5a56ee7f'
339
    payload = 'not json'
340
    resp = app.post(endpoint + query_string, params=payload, status=400)
341
    assert resp.json['err']
342
    assert resp.json['err_desc'] == 'Expecting value: line 1 column 1 (char 0)'
343
    assert resp.json['err_class'] == 'passerelle.apps.plone_restapi.models.ParameterTypeError'
344

  
345

  
346
def test_remove(app, connector, token):
347
    endpoint = utils.generic_endpoint_url('plone-restapi', 'remove', slug=connector.slug)
348
    assert endpoint == '/plone-restapi/my_connector/remove'
349
    url = connector.service_url + '/braine-l-alleud/dccd85d12cf54b6899dff41e5a56ee7f'
350
    query_string = '?uri=braine-l-alleud&uid=dccd85d12cf54b6899dff41e5a56ee7f'
351
    with utils.mock_url(url=url, response='', status_code=204):
352
        resp = app.delete(endpoint + query_string)
353
    assert resp.json['data'] == {'uid': 'dccd85d12cf54b6899dff41e5a56ee7f', 'removed': True}
354
    assert not resp.json['err']
355

  
356

  
357
def test_search(app, connector, token):
358
    endpoint = utils.generic_endpoint_url('plone-restapi', 'search', slug=connector.slug)
359
    assert endpoint == '/plone-restapi/my_connector/search'
360
    url = connector.service_url + '/braine-l-alleud/@search'
361
    params = {
362
        'uri': 'braine-l-alleud',
363
        'text_template': '{{ title }} ({{ PLONE_type }})',
364
        'sort': 'UID',
365
        'order': False,
366
        'limit': 3,
367
    }
368
    qs = {}
369
    with utils.mock_url(url=url, response=json_get_data('q_search'), qs=qs):
370
        resp = app.get(endpoint, params=params)
371
    assert token.handlers[0].call['count'] == 1
372
    assert qs == {'sort_on': 'UID', 'sort_order': 'descending', 'b_size': '3', 'fullobjects': 'y'}
373
    assert not resp.json['err']
374
    assert len(resp.json['data']) == 3
375
    assert [(x['id'], x['text']) for x in resp.json['data']] == [
376
        (
377
            'dea9d26baab944beb7e54d4024d35a33',
378
            "Cabinet du Bourgmestre de la Commune de Braine-l'Alleud (imio.directory.Contact)",
379
        ),
380
        (
381
            '23a32197d6c841259963b43b24747854',
382
            "Académie de Musique de Braine-l'Alleud (imio.directory.Contact)",
383
        ),
384
        (
385
            'f82d2c079131433ea6ab20f9f7f49442',
386
            'Accueil et Orientation Volontariat (A.O.V.) (imio.directory.Contact)',
387
        ),
388
    ]
389

  
390

  
391
def test_search_using_q(app, connector, token):
392
    endpoint = utils.generic_endpoint_url('plone-restapi', 'search', slug=connector.slug)
393
    assert endpoint == '/plone-restapi/my_connector/search'
394
    url = connector.service_url + '/braine-l-alleud/@search'
395
    params = {
396
        'uri': 'braine-l-alleud',
397
        'text_template': '{{ title }} ({{ PLONE_type }})',
398
        'sort': 'title',
399
        'order': True,
400
        'limit': '3',
401
        'q': 'Página dentro',
402
    }
403
    qs = {}
404
    with utils.mock_url(url=url, response=json_get_data('q_search'), qs=qs):
405
        resp = app.get(endpoint, params=params)
406
    assert qs == {
407
        'SearchableText': 'Página dentro',
408
        'sort_on': 'title',
409
        'sort_order': 'ascending',
410
        'b_size': '3',
411
        'fullobjects': 'y',
412
    }
413
    assert not resp.json['err']
414
    assert len(resp.json['data']) == 3
415
    assert [(x['id'], x['text']) for x in resp.json['data']] == [
416
        (
417
            'dea9d26baab944beb7e54d4024d35a33',
418
            "Cabinet du Bourgmestre de la Commune de Braine-l'Alleud (imio.directory.Contact)",
419
        ),
420
        (
421
            '23a32197d6c841259963b43b24747854',
422
            "Académie de Musique de Braine-l'Alleud (imio.directory.Contact)",
423
        ),
424
        (
425
            'f82d2c079131433ea6ab20f9f7f49442',
426
            'Accueil et Orientation Volontariat (A.O.V.) (imio.directory.Contact)',
427
        ),
428
    ]
429

  
430

  
431
def test_search_using_id(app, connector, token):
432
    endpoint = utils.generic_endpoint_url('plone-restapi', 'search', slug=connector.slug)
433
    assert endpoint == '/plone-restapi/my_connector/search'
434
    url = connector.service_url + '/braine-l-alleud/@search'
435
    params = {
436
        'uri': 'braine-l-alleud',
437
        'text_template': '{{ title }} ({{ PLONE_type }})',
438
        'id': '9fbb2afd499e465983434f974fce8404',
439
    }
440
    qs = {}
441
    with utils.mock_url(url=url, response=json_get_data('id_search'), qs=qs):
442
        resp = app.get(endpoint, params=params)
443
    assert qs == {'UID': '9fbb2afd499e465983434f974fce8404', 'fullobjects': 'y'}
444
    assert len(resp.json['data']) == 1
445
    assert resp.json['data'][0]['text'] == "Académie de Musique de Braine-l'Alleud (imio.directory.Contact)"
446

  
447

  
448
def test_query_q(app, query, token):
449
    endpoint = '/plone-restapi/my_connector/q/my_query/'
450
    url = query.resource.service_url + '/braine-l-alleud/@search'
451
    params = {
452
        'limit': 3,
453
    }
454
    qs = {}
455
    with utils.mock_url(url=url, response=json_get_data('q_search'), qs=qs):
456
        resp = app.get(endpoint, params=params)
457
        assert qs == {
458
            'sort_on': 'UID',
459
            'sort_order': 'descending',
460
            'b_size': '3',
461
            'portal_type': 'Document',
462
            'review_state': 'published',
463
            'fullobjects': 'y',
464
        }
465
    assert not resp.json['err']
466
    assert len(resp.json['data']) == 3
467
    assert resp.json['meta'] == {'label': 'demo query', 'description': "Annuaire de Braine-l'Alleud"}
468

  
469

  
470
def test_query_q_using_q(app, query, token):
471
    endpoint = '/plone-restapi/my_connector/q/my_query/'
472
    url = query.resource.service_url + '/braine-l-alleud/@search'
473
    params = {
474
        'q': 'Página dentro',
475
    }
476
    qs = {}
477
    with utils.mock_url(url=url, response=json_get_data('q_search'), qs=qs):
478
        resp = app.get(endpoint, params=params)
479
        assert qs == {
480
            'SearchableText': 'Página dentro',
481
            'sort_on': 'UID',
482
            'sort_order': 'descending',
483
            'b_size': '3',
484
            'portal_type': 'Document',
485
            'review_state': 'published',
486
            'fullobjects': 'y',
487
        }
488
    assert not resp.json['err']
489
    assert len(resp.json['data']) == 3
490
    assert resp.json['meta'] == {'label': 'demo query', 'description': "Annuaire de Braine-l'Alleud"}
491

  
492

  
493
def test_query_q_using_id(app, query, token):
494
    endpoint = '/plone-restapi/my_connector/q/my_query/'
495
    url = query.resource.service_url + '/braine-l-alleud/@search'
496
    params = {
497
        'id': '9fbb2afd499e465983434f974fce8404',
498
    }
499
    qs = {}
500
    with utils.mock_url(url=url, response=json_get_data('id_search'), qs=qs):
501
        resp = app.get(endpoint, params=params)
502
    assert qs == {
503
        'UID': '9fbb2afd499e465983434f974fce8404',
504
        'fullobjects': 'y',
505
    }
506
    assert len(resp.json['data']) == 1
507
    assert resp.json['data'][0]['text'] == "Académie de Musique de Braine-l'Alleud (imio.directory.Contact)"
508
    assert resp.json['meta'] == {'label': 'demo query', 'description': "Annuaire de Braine-l'Alleud"}
tests/utils.py
23 23

  
24 24
class FakedResponse(mock.Mock):
25 25
    headers = {}
26 26

  
27 27
    def json(self):
28 28
        return json_loads(self.content)
29 29

  
30 30

  
31
def mock_url(url=None, response='', status_code=200, headers=None, reason=None, exception=None):
31
def mock_url(url=None, response='', status_code=200, headers=None, reason=None, exception=None, qs=None):
32 32
    urlmatch_kwargs = {}
33 33
    if url:
34 34
        parsed = urlparse.urlparse(url)
35 35
        if parsed.netloc:
36 36
            urlmatch_kwargs['netloc'] = parsed.netloc
37 37
        if parsed.path:
38 38
            urlmatch_kwargs['path'] = parsed.path
39 39

  
40 40
    if not isinstance(response, str):
41 41
        response = json.dumps(response)
42 42

  
43 43
    @httmock.remember_called
44 44
    @httmock.urlmatch(**urlmatch_kwargs)
45 45
    def mocked(url, request):
46
        if qs is not None:
47
            qs.update(urlparse.parse_qsl(url.query))
46 48
        if exception:
47 49
            raise exception
48 50
        return httmock.response(status_code, response, headers, reason, request=request)
49 51

  
50 52
    return httmock.HTTMock(mocked)
51 53

  
52 54

  
53 55
def make_resource(model_class, **kwargs):
54
-