Projet

Général

Profil

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

Nicolas Roche, 15 octobre 2021 16:18

Télécharger (64,8 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       | 381 ++++++++++++
 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/id_search.json       |  89 +++
 tests/data/plone_restapi/q_search.json        | 240 ++++++++
 tests/test_plone_restapi.py                   | 550 ++++++++++++++++++
 tests/utils.py                                |   4 +-
 13 files changed, 1523 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/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

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

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

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

  
92
    @classmethod
93
    def normalize_keys(cls, data):
94
        """ Rename keys from @foo to portal_foo """
95
        if isinstance(data, list):
96
            for value in list(data):
97
                cls.normalize_keys(value)
98
        elif isinstance(data, dict):
99
            for key, value in list(data.items()):
100
                cls.normalize_keys(value)
101
                if key[0] == '@':
102
                    data['portal_%s' % key[1:]] = value
103
                    del data[key]
104

  
105
    @classmethod
106
    def normalize_record(cls, text_template, record):
107
        cls.normalize_keys(record)
108
        data = {}
109
        for key, value in record.items():
110
            if key in ('id', 'text'):
111
                key = 'original_%s' % key
112
            data[key] = value
113
        data['id'] = record.get('UID')
114
        data['text'] = render_to_string(text_template, data).strip()
115
        return data
116

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

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

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

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

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

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

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

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

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

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

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

  
312

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

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

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

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

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

  
375
        # Copy generic params descriptions from original endpoint
376
        # if they are not overloaded by the query
377
        for param in search_endpoint.parameters:
378
            if param in ('uri', 'text_template') and getattr(self, param):
379
                continue
380
            endpoint.parameters[param] = search_endpoint.parameters[param]
381
        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/id_search.json
1
{
2
  "@id": "https://demo.plone.org/es/@search?UID=19f0cd24fec847c09744fbc85aace167",
3
  "items": [
4
    {
5
      "@components": {
6
        "actions": {
7
          "@id": "https://demo.plone.org/es/frontpage/@actions"
8
        },
9
        "breadcrumbs": {
10
          "@id": "https://demo.plone.org/es/frontpage/@breadcrumbs"
11
        },
12
        "contextnavigation": {
13
          "@id": "https://demo.plone.org/es/frontpage/@contextnavigation"
14
        },
15
        "navigation": {
16
          "@id": "https://demo.plone.org/es/frontpage/@navigation"
17
        },
18
        "translations": {
19
          "@id": "https://demo.plone.org/es/frontpage/@translations"
20
        },
21
        "types": {
22
          "@id": "https://demo.plone.org/es/frontpage/@types"
23
        },
24
        "workflow": {
25
          "@id": "https://demo.plone.org/es/frontpage/@workflow"
26
        }
27
      },
28
      "@id": "https://demo.plone.org/es/frontpage",
29
      "@type": "Document",
30
      "UID": "19f0cd24fec847c09744fbc85aace167",
31
      "allow_discussion": false,
32
      "blocks": {},
33
      "blocks_layout": {
34
        "items": []
35
      },
36
      "changeNote": "",
37
      "contributors": [],
38
      "created": "2021-09-23T20:05:00+00:00",
39
      "creators": [
40
        "admin"
41
      ],
42
      "description": "El Sistema de Gesti\u00f3n de Contenido de Fuentes Abiertas",
43
      "effective": null,
44
      "exclude_from_nav": false,
45
      "expires": null,
46
      "id": "frontpage",
47
      "is_folderish": false,
48
      "language": {
49
        "title": "Espa\u00f1ol",
50
        "token": "es"
51
      },
52
      "layout": "document_view",
53
      "modified": "2021-09-23T20:05:00+00:00",
54
      "next_item": {
55
        "@id": "https://demo.plone.org/es/demo",
56
        "@type": "Folder",
57
        "description": "Vestibulum dignissim erat id eros mollis vitae tempus leo ultricies. Cras dapibus suscipit consectetur. Integer tincidunt feugiat tristique. Sed et arcu risus. Nam venenatis, tortor ac tincidunt amet.",
58
        "title": "Demo"
59
      },
60
      "parent": {
61
        "@id": "https://demo.plone.org/es",
62
        "@type": "LRF",
63
        "description": "",
64
        "review_state": "published",
65
        "title": "Espa\u00f1ol"
66
      },
67
      "previous_item": {
68
        "@id": "https://demo.plone.org/es/recursos",
69
        "@type": "LIF",
70
        "description": "",
71
        "title": "Recursos"
72
      },
73
      "relatedItems": [],
74
      "review_state": "published",
75
      "rights": "",
76
      "subjects": [],
77
      "table_of_contents": null,
78
      "text": {
79
        "content-type": "text/html",
80
        "data": "<p>\u00a1Edita esta p\u00e1gina y prueba Plone ahora!</p>",
81
        "encoding": "utf-8"
82
      },
83
      "title": "Bienvenido a Plone",
84
      "version": "current",
85
      "versioning_enabled": true
86
    }
87
  ],
88
  "items_total": 1
89
}
tests/data/plone_restapi/q_search.json
1
{
2
  "@id": "https://demo.plone.org/es/@search?bsize=3&portal_type=Document&review_state=published",
3
  "items": [
4
    {
5
      "@components": {
6
        "actions": {
7
          "@id": "https://demo.plone.org/es/demo/una-carpeta/una-pagina-dentro-de-una-carpeta/@actions"
8
        },
9
        "breadcrumbs": {
10
          "@id": "https://demo.plone.org/es/demo/una-carpeta/una-pagina-dentro-de-una-carpeta/@breadcrumbs"
11
        },
12
        "contextnavigation": {
13
          "@id": "https://demo.plone.org/es/demo/una-carpeta/una-pagina-dentro-de-una-carpeta/@contextnavigation"
14
        },
15
        "navigation": {
16
          "@id": "https://demo.plone.org/es/demo/una-carpeta/una-pagina-dentro-de-una-carpeta/@navigation"
17
        },
18
        "translations": {
19
          "@id": "https://demo.plone.org/es/demo/una-carpeta/una-pagina-dentro-de-una-carpeta/@translations"
20
        },
21
        "types": {
22
          "@id": "https://demo.plone.org/es/demo/una-carpeta/una-pagina-dentro-de-una-carpeta/@types"
23
        },
24
        "workflow": {
25
          "@id": "https://demo.plone.org/es/demo/una-carpeta/una-pagina-dentro-de-una-carpeta/@workflow"
26
        }
27
      },
28
      "@id": "https://demo.plone.org/es/demo/una-carpeta/una-pagina-dentro-de-una-carpeta",
29
      "@type": "Document",
30
      "UID": "593cf5489fce493a95b59bb4f3ef9ee1",
31
      "allow_discussion": false,
32
      "blocks": {},
33
      "blocks_layout": {
34
        "items": []
35
      },
36
      "changeNote": "",
37
      "contributors": [],
38
      "created": "2018-08-27T11:20:58+00:00",
39
      "creators": [
40
        "admin"
41
      ],
42
      "description": "Fusce dapibus, tellus ac cursus commodo, tortor mauris condimentum nibh, ut fermentum massa justo sit amet risus. Aenean eu leo quam. Pellentesque ornare sem lacinia quam venenatis vestibulum.",
43
      "effective": "2018-08-27T13:21:10",
44
      "exclude_from_nav": false,
45
      "expires": null,
46
      "id": "una-pagina-dentro-de-una-carpeta",
47
      "is_folderish": false,
48
      "language": {
49
        "title": "Espa\u00f1ol",
50
        "token": "es"
51
      },
52
      "layout": "document_view",
53
      "modified": "2021-09-23T20:05:01+00:00",
54
      "next_item": {},
55
      "parent": {
56
        "@id": "https://demo.plone.org/es/demo/una-carpeta",
57
        "@type": "Folder",
58
        "description": "",
59
        "review_state": "published",
60
        "title": "Una carpeta"
61
      },
62
      "previous_item": {},
63
      "relatedItems": [],
64
      "review_state": "published",
65
      "rights": null,
66
      "subjects": [],
67
      "table_of_contents": false,
68
      "text": {
69
        "content-type": "text/html",
70
        "data": "Aenean eu leo quam. Pellentesque ornare sem lacinia quam venenatis vestibulum. Cras mattis consectetur purus sit amet fermentum. Fusce dapibus, tellus ac cursus commodo, tortor mauris condimentum nibh, ut fermentum massa justo sit amet risus. Cras justo odio, dapibus ac facilisis in, egestas eget quam. Integer posuere erat a ante venenatis dapibus posuere velit aliquet. Praesent commodo cursus magna, vel scelerisque nisl consectetur et. Maecenas faucibus mollis interdum.&#13;\n&#13;\nPraesent commodo cursus magna, vel scelerisque nisl consectetur et. Nulla vitae elit libero, a pharetra augue. Maecenas sed diam eget risus varius blandit sit amet non magna. Donec ullamcorper nulla non metus auctor fringilla. Vestibulum id ligula porta felis euismod semper. Cras mattis consectetur purus sit amet fermentum.&#13;\n&#13;\nNullam quis risus eget urna mollis ornare vel eu leo. Donec ullamcorper nulla non metus auctor fringilla. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla vitae elit libero, a pharetra augue. Fusce dapibus, tellus ac cursus commodo, tortor mauris condimentum nibh, ut fermentum massa justo sit amet risus. Nullam id dolor id nibh ultricies vehicula ut id elit. Morbi leo risus, porta ac consectetur ac, vestibulum at eros.",
71
        "encoding": "utf-8"
72
      },
73
      "title": "Una P\u00e1gina dentro de una carpeta",
74
      "version": "current",
75
      "versioning_enabled": true
76
    },
77
    {
78
      "@components": {
79
        "actions": {
80
          "@id": "https://demo.plone.org/es/frontpage/@actions"
81
        },
82
        "breadcrumbs": {
83
          "@id": "https://demo.plone.org/es/frontpage/@breadcrumbs"
84
        },
85
        "contextnavigation": {
86
          "@id": "https://demo.plone.org/es/frontpage/@contextnavigation"
87
        },
88
        "navigation": {
89
          "@id": "https://demo.plone.org/es/frontpage/@navigation"
90
        },
91
        "translations": {
92
          "@id": "https://demo.plone.org/es/frontpage/@translations"
93
        },
94
        "types": {
95
          "@id": "https://demo.plone.org/es/frontpage/@types"
96
        },
97
        "workflow": {
98
          "@id": "https://demo.plone.org/es/frontpage/@workflow"
99
        }
100
      },
101
      "@id": "https://demo.plone.org/es/frontpage",
102
      "@type": "Document",
103
      "UID": "19f0cd24fec847c09744fbc85aace167",
104
      "allow_discussion": false,
105
      "blocks": {},
106
      "blocks_layout": {
107
        "items": []
108
      },
109
      "changeNote": "",
110
      "contributors": [],
111
      "created": "2021-09-23T20:05:00+00:00",
112
      "creators": [
113
        "admin"
114
      ],
115
      "description": "El Sistema de Gesti\u00f3n de Contenido de Fuentes Abiertas",
116
      "effective": null,
117
      "exclude_from_nav": false,
118
      "expires": null,
119
      "id": "frontpage",
120
      "is_folderish": false,
121
      "language": {
122
        "title": "Espa\u00f1ol",
123
        "token": "es"
124
      },
125
      "layout": "document_view",
126
      "modified": "2021-09-23T20:05:00+00:00",
127
      "next_item": {
128
        "@id": "https://demo.plone.org/es/demo",
129
        "@type": "Folder",
130
        "description": "Vestibulum dignissim erat id eros mollis vitae tempus leo ultricies. Cras dapibus suscipit consectetur. Integer tincidunt feugiat tristique. Sed et arcu risus. Nam venenatis, tortor ac tincidunt amet.",
131
        "title": "Demo"
132
      },
133
      "parent": {
134
        "@id": "https://demo.plone.org/es",
135
        "@type": "LRF",
136
        "description": "",
137
        "review_state": "published",
138
        "title": "Espa\u00f1ol"
139
      },
140
      "previous_item": {
141
        "@id": "https://demo.plone.org/es/recursos",
142
        "@type": "LIF",
143
        "description": "",
144
        "title": "Recursos"
145
      },
146
      "relatedItems": [],
147
      "review_state": "published",
148
      "rights": "",
149
      "subjects": [],
150
      "table_of_contents": null,
151
      "text": {
152
        "content-type": "text/html",
153
        "data": "<p>\u00a1Edita esta p\u00e1gina y prueba Plone ahora!</p>",
154
        "encoding": "utf-8"
155
      },
156
      "title": "Bienvenido a Plone",
157
      "version": "current",
158
      "versioning_enabled": true
159
    },
160
    {
161
      "@components": {
162
        "actions": {
163
          "@id": "https://demo.plone.org/es/demo/una-pagina/@actions"
164
        },
165
        "breadcrumbs": {
166
          "@id": "https://demo.plone.org/es/demo/una-pagina/@breadcrumbs"
167
        },
168
        "contextnavigation": {
169
          "@id": "https://demo.plone.org/es/demo/una-pagina/@contextnavigation"
170
        },
171
        "navigation": {
172
          "@id": "https://demo.plone.org/es/demo/una-pagina/@navigation"
173
        },
174
        "translations": {
175
          "@id": "https://demo.plone.org/es/demo/una-pagina/@translations"
176
        },
177
        "types": {
178
          "@id": "https://demo.plone.org/es/demo/una-pagina/@types"
179
        },
180
        "workflow": {
181
          "@id": "https://demo.plone.org/es/demo/una-pagina/@workflow"
182
        }
183
      },
184
      "@id": "https://demo.plone.org/es/demo/una-pagina",
185
      "@type": "Document",
186
      "UID": "13f909b522a94443823e187ea9ebab0b",
187
      "allow_discussion": false,
188
      "blocks": {},
189
      "blocks_layout": {
190
        "items": []
191
      },
192
      "changeNote": "",
193
      "contributors": [],
194
      "created": "2018-08-27T11:12:41+00:00",
195
      "creators": [
196
        "admin"
197
      ],
198
      "description": "Aenean dictum auctor elit, in volutpat ipsum venenatis at. Quisque lobortis augue et enim venenatis interdum. In egestas, est at condimentum ultrices, tortor enim malesuada nulla; vel sagittis nullam.",
199
      "effective": "2018-08-27T13:16:45",
200
      "exclude_from_nav": false,
201
      "expires": null,
202
      "id": "una-pagina",
203
      "is_folderish": false,
204
      "language": {
205
        "title": "Espa\u00f1ol",
206
        "token": "es"
207
      },
208
      "layout": "document_view",
209
      "modified": "2021-09-23T20:05:00+00:00",
210
      "next_item": {
211
        "@id": "https://demo.plone.org/es/demo/un-evento",
212
        "@type": "Event",
213
        "description": "Fusce dapibus, tellus ac cursus commodo, tortor mauris condimentum nibh, ut fermentum massa justo sit amet risus. Maecenas sed diam eget risus varius blandit sit amet non magna.",
214
        "title": "Un Evento"
215
      },
216
      "parent": {
217
        "@id": "https://demo.plone.org/es/demo",
218
        "@type": "Folder",
219
        "description": "Vestibulum dignissim erat id eros mollis vitae tempus leo ultricies. Cras dapibus suscipit consectetur. Integer tincidunt feugiat tristique. Sed et arcu risus. Nam venenatis, tortor ac tincidunt amet.",
220
        "review_state": "published",
221
        "title": "Demo"
222
      },
223
      "previous_item": {},
224
      "relatedItems": [],
225
      "review_state": "published",
226
      "rights": null,
227
      "subjects": [],
228
      "table_of_contents": false,
229
      "text": {
230
        "content-type": "text/html",
231
        "data": "Fusce vel ante vel dolor feugiat vulputate? Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque luctus pretium tellus, id porttitor erat volutpat et. Donec vehicula accumsan ornare. Mauris quis vulputate dui. Duis convallis congue ornare. Vivamus cursus vestibulum neque at fermentum. Mauris sed velit in enim scelerisque luctus ac vel mauris. Etiam imperdiet tempor lorem, quis ultrices quam blandit ultricies. Ut sodales lacinia purus hendrerit lobortis. Pellentesque blandit; sem at aliquam pulvinar, felis diam tincidunt nisi, ac varius ligula eros eget tortor.&#13;\n&#13;\nVivamus leo ipsum&#13;\nDictum sed luctus elementum, ornare quis justo? Nam sagittis mattis turpis, eu varius sapien pulvinar non. Etiam in enim eget odio cursus condimentum! Nullam porta, quam ut sagittis auctor, dui urna ullamcorper urna, semper facilisis purus velit nec leo. Nam libero sem, auctor vitae pretium sit amet, posuere eget arcu.&#13;\n&#13;\nMaecenas ultrices&#13;\nNeque in porttitor scelerisque, nunc nunc semper libero, ac dapibus leo lectus et dui. Praesent sem urna, malesuada in volutpat ac, tincidunt sit amet dolor. Ut dignissim ante vel sem semper venenatis? Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. In hac habitasse platea dictumst. Aenean vitae lectus leo; non bibendum purus.&#13;\n&#13;\nQuisque sit amet aliquam elit&#13;\nAenean odio urna, congue eu sollicitudin ac&#13;\nInterdum ac lorem&#13;\nPellentesque habitant morbi tristique&#13;\nAliquam mattis purus vel nunc tempor sed tempus turpis imperdiet. Pellentesque tincidunt gravida eros at adipiscing. Sed ut tempus nibh. Suspendisse euismod, metus sed lobortis luctus, odio nulla malesuada turpis, in aliquam elit lorem id ante? Pellentesque a elementum dui! Morbi id tellus eget lacus sollicitudin dignissim. Praesent venenatis pellentesque dolor, nec vestibulum felis consectetur non? Sed facilisis, velit vel auctor aliquam, lorem mauris euismod libero, non pellentesque urna mauris vel sem. Aenean eget diam at sem auctor lacinia nec non nisl. Integer sodales fringilla vulputate. Duis massa ante, aliquet id interdum at; placerat nec magna. Ut eleifend sem ut mi elementum eget pellentesque urna dignissim!",
232
        "encoding": "utf-8"
233
      },
234
      "title": "Una p\u00e1gina",
235
      "version": "current",
236
      "versioning_enabled": true
237
    }
238
  ],
239
  "items_total": 3
240
}
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
# test data comes from https://demo.plone.org/en
48
def json_get_data(filename):
49
    with open(os.path.join(TEST_BASE_DIR, "%s.json" % filename)) as fd:
50
        return json.load(fd)
51

  
52

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

  
67

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

  
73

  
74
@pytest.fixture
75
def query(connector):
76
    return Query.objects.create(
77
        resource=connector,
78
        name='demo query',
79
        slug='my_query',
80
        description='Spanish published documents',
81
        uri='es',
82
        text_template='{{ title }} ({{ portal_type }})',
83
        filter_expression='''
84
portal_type=Document
85
review_state=published
86
''',
87
        sort='UID',
88
        order=False,
89
        limit=3,
90
    )
91

  
92

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

  
104

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

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

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

  
160

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

  
173

  
174
def test_normalize_keys():
175
    plone_response = {
176
        '@type': 'Document',
177
        '@dict': {
178
            '@array': [
179
                {
180
                    '@key1': 'value1',
181
                    '@key2': 'value2',
182
                },
183
                '@value_to_keep',
184
            ]
185
        },
186
    }
187
    PloneRestApi.normalize_keys(plone_response)
188
    assert plone_response == {
189
        'portal_type': 'Document',
190
        'portal_dict': {
191
            'portal_array': [{'portal_key1': 'value1', 'portal_key2': 'value2'}, '@value_to_keep']
192
        },
193
    }
194

  
195

  
196
def test_normalize_record():
197
    data = {
198
        'UID': 'abc',
199
        'id': '123',
200
        'text': 'foo',
201
        'portal_dict': {'portal_array': [{'portal_key': 'value'}]},
202
    }
203
    template = '{{ id }} {{original_id }} {{ original_text }} {{ portal_dict.portal_array.0.portal_key }}'
204
    assert PloneRestApi.normalize_record(template, data) == {
205
        'UID': 'abc',
206
        'original_id': '123',
207
        'original_text': 'foo',
208
        'portal_dict': {'portal_array': [{'portal_key': 'value'}]},
209
        'id': 'abc',
210
        'text': 'abc 123 foo value',
211
    }
212

  
213

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

  
224
        # make sure the token from cache is used
225
        connector.get_token()
226
        assert mocked.handlers[0].call['count'] == 1
227
        connector.get_token(True)
228
        assert mocked.handlers[0].call['count'] == 2
229

  
230

  
231
def test_fetch(app, connector, token):
232
    endpoint = utils.generic_endpoint_url('plone-restapi', 'fetch', slug=connector.slug)
233
    assert endpoint == '/plone-restapi/my_connector/fetch'
234
    url = connector.service_url + '/es/19f0cd24fec847c09744fbc85aace167'
235
    params = {
236
        'uid': '19f0cd24fec847c09744fbc85aace167',
237
        'uri': 'es',
238
        'text_template': '{{ title }} ({{ parent.portal_type }})',
239
    }
240
    with utils.mock_url(url=url, response=json_get_data('id_search')['items'][0]):
241
        resp = app.get(endpoint, params=params)
242
    assert not resp.json['err']
243
    assert resp.json['data']['id'] == '19f0cd24fec847c09744fbc85aace167'
244
    assert resp.json['data']['text'] == 'Bienvenido a Plone (LRF)'
245
    assert token.handlers[0].call['count'] == 1
246

  
247

  
248
def test_request_anonymously(app, connector, token):
249
    connector.token_ws_url = ''
250
    connector.save()
251
    endpoint = utils.generic_endpoint_url('plone-restapi', 'fetch', slug=connector.slug)
252
    assert endpoint == '/plone-restapi/my_connector/fetch'
253
    url = connector.service_url + '/es/19f0cd24fec847c09744fbc85aace167'
254
    params = {
255
        'uid': '19f0cd24fec847c09744fbc85aace167',
256
        'uri': 'es',
257
        'text_template': '{{ title }} ({{ parent.portal_type }})',
258
    }
259
    with utils.mock_url(url=url, response=json_get_data('id_search')['items'][0]):
260
        resp = app.get(endpoint, params=params)
261
    assert not resp.json['err']
262
    assert resp.json['data']['id'] == '19f0cd24fec847c09744fbc85aace167'
263
    assert resp.json['data']['text'] == 'Bienvenido a Plone (LRF)'
264
    assert token.handlers[0].call['count'] == 0
265

  
266

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

  
289

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

  
309

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

  
320

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

  
337

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

  
349

  
350
def test_remove(app, connector, token):
351
    endpoint = utils.generic_endpoint_url('plone-restapi', 'remove', slug=connector.slug)
352
    assert endpoint == '/plone-restapi/my_connector/remove'
353
    url = connector.service_url + '/es/19f0cd24fec847c09744fbc85aace167'
354
    query_string = '?uri=es&uid=19f0cd24fec847c09744fbc85aace167'
355
    with utils.mock_url(url=url, response='', status_code=204):
356
        resp = app.delete(endpoint + query_string)
357
    assert resp.json['data'] == {'uid': '19f0cd24fec847c09744fbc85aace167', 'removed': True}
358
    assert not resp.json['err']
359

  
360

  
361
def test_search(app, connector, token):
362
    endpoint = utils.generic_endpoint_url('plone-restapi', 'search', slug=connector.slug)
363
    assert endpoint == '/plone-restapi/my_connector/search'
364
    url = connector.service_url + '/es/@search'
365
    params = {
366
        'uri': 'es',
367
        'text_template': '{{ title }} ({{ portal_type }})',
368
        'sort': 'UID',
369
        'order': False,
370
        'limit': 3,
371
    }
372
    qs = {}
373
    with utils.mock_url(url=url, response=json_get_data('q_search'), qs=qs):
374
        resp = app.get(endpoint, params=params)
375
    assert token.handlers[0].call['count'] == 1
376
    assert qs == {'sort_on': 'UID', 'sort_order': 'descending', 'b_size': '3', 'fullobjects': 'y'}
377
    assert not resp.json['err']
378
    assert len(resp.json['data']) == 3
379
    assert [(x['id'], x['text']) for x in resp.json['data']] == [
380
        ('593cf5489fce493a95b59bb4f3ef9ee1', 'Una Página dentro de una carpeta (Document)'),
381
        ('19f0cd24fec847c09744fbc85aace167', 'Bienvenido a Plone (Document)'),
382
        ('13f909b522a94443823e187ea9ebab0b', 'Una página (Document)'),
383
    ]
384

  
385

  
386
def test_call_search_normalize_keys(app, connector, token):
387
    endpoint = utils.generic_endpoint_url('plone-restapi', 'search', slug=connector.slug)
388
    assert endpoint == '/plone-restapi/my_connector/search'
389
    url = connector.service_url + '/@search'
390

  
391
    plone_response = json_get_data('q_search')
392
    assert [x['id'] for x in plone_response['items']] == [
393
        'una-pagina-dentro-de-una-carpeta',
394
        'frontpage',
395
        'una-pagina',
396
    ]
397
    assert plone_response['items'][1]['text'] == {
398
        'content-type': 'text/html',
399
        'data': '<p>¡Edita esta página y prueba Plone ahora!</p>',
400
        'encoding': 'utf-8',
401
    }
402
    assert plone_response['items'][1]['parent'] == {
403
        '@id': 'https://demo.plone.org/es',
404
        '@type': 'LRF',
405
        'description': '',
406
        'review_state': 'published',
407
        'title': 'Español',
408
    }
409

  
410
    with utils.mock_url(url=url, response=plone_response):
411
        resp = app.get(
412
            endpoint,
413
            params={
414
                'text_template': '{{ original_id }} {{ original_text.encoding }} {{ parent.portal_type }}',
415
            },
416
        )
417
    assert not resp.json['err']
418
    assert resp.json['data'][1]['text'] == 'frontpage utf-8 LRF'
419

  
420
    # original 'id' and 'text' keys are renamed
421
    assert [x['original_id'] for x in resp.json['data']] == [x['id'] for x in plone_response['items']]
422
    assert resp.json['data'][1]['original_text'] == plone_response['items'][1]['text']
423
    # key prefixed with '@' are renamed
424
    assert resp.json['data'][1]['parent'] == {
425
        'description': '',
426
        'review_state': 'published',
427
        'title': 'Español',
428
        'portal_id': 'https://demo.plone.org/es',
429
        'portal_type': 'LRF',
430
    }
431

  
432

  
433
def test_search_using_q(app, connector, token):
434
    endpoint = utils.generic_endpoint_url('plone-restapi', 'search', slug=connector.slug)
435
    assert endpoint == '/plone-restapi/my_connector/search'
436
    url = connector.service_url + '/es/@search'
437
    params = {
438
        'uri': 'es',
439
        'text_template': '{{ title }} ({{ portal_type }})',
440
        'sort': 'title',
441
        'order': True,
442
        'limit': '3',
443
        'q': 'Página dentro',
444
    }
445
    qs = {}
446
    with utils.mock_url(url=url, response=json_get_data('q_search'), qs=qs):
447
        resp = app.get(endpoint, params=params)
448
    assert qs == {
449
        'SearchableText': 'Página dentro',
450
        'sort_on': 'title',
451
        'sort_order': 'ascending',
452
        'b_size': '3',
453
        'fullobjects': 'y',
454
    }
455
    assert not resp.json['err']
456
    assert len(resp.json['data']) == 3
457
    assert [(x['id'], x['text']) for x in resp.json['data']] == [
458
        ('593cf5489fce493a95b59bb4f3ef9ee1', 'Una Página dentro de una carpeta (Document)'),
459
        ('19f0cd24fec847c09744fbc85aace167', 'Bienvenido a Plone (Document)'),
460
        ('13f909b522a94443823e187ea9ebab0b', 'Una página (Document)'),
461
    ]
462

  
463

  
464
def test_search_using_id(app, connector, token):
465
    endpoint = utils.generic_endpoint_url('plone-restapi', 'search', slug=connector.slug)
466
    assert endpoint == '/plone-restapi/my_connector/search'
467
    url = connector.service_url + '/es/@search'
468
    params = {
469
        'uri': 'es',
470
        'text_template': '{{ title }} ({{ portal_type }})',
471
        'id': '19f0cd24fec847c09744fbc85aace167',
472
    }
473
    qs = {}
474
    with utils.mock_url(url=url, response=json_get_data('id_search'), qs=qs):
475
        resp = app.get(endpoint, params=params)
476
    assert qs == {'UID': '19f0cd24fec847c09744fbc85aace167', 'fullobjects': 'y'}
477
    assert len(resp.json['data']) == 1
478
    assert resp.json['data'][0]['text'] == 'Bienvenido a Plone (Document)'
479

  
480

  
481
def test_query_q(app, query, token):
482
    endpoint = '/plone-restapi/my_connector/q/my_query/'
483
    url = query.resource.service_url + '/es/@search'
484
    params = {
485
        'limit': 3,
486
    }
487
    qs = {}
488
    with utils.mock_url(url=url, response=json_get_data('q_search'), qs=qs):
489
        resp = app.get(endpoint, params=params)
490
        assert qs == {
491
            'sort_on': 'UID',
492
            'sort_order': 'descending',
493
            'b_size': '3',
494
            'portal_type': 'Document',
495
            'review_state': 'published',
496
            'fullobjects': 'y',
497
        }
498
    assert not resp.json['err']
499
    assert len(resp.json['data']) == 3
500
    assert [(x['id'], x['text']) for x in resp.json['data']] == [
501
        ('593cf5489fce493a95b59bb4f3ef9ee1', 'Una Página dentro de una carpeta (Document)'),
502
        ('19f0cd24fec847c09744fbc85aace167', 'Bienvenido a Plone (Document)'),
503
        ('13f909b522a94443823e187ea9ebab0b', 'Una página (Document)'),
504
    ]
505
    assert resp.json['meta'] == {'label': 'demo query', 'description': 'Spanish published documents'}
506

  
507

  
508
def test_query_q_using_q(app, query, token):
509
    endpoint = '/plone-restapi/my_connector/q/my_query/'
510
    url = query.resource.service_url + '/es/@search'
511
    params = {
512
        'q': 'Página dentro',
513
    }
514
    qs = {}
515
    with utils.mock_url(url=url, response=json_get_data('q_search'), qs=qs):
516
        resp = app.get(endpoint, params=params)
517
        assert qs == {
518
            'SearchableText': 'Página dentro',
519
            'sort_on': 'UID',
520
            'sort_order': 'descending',
521
            'b_size': '3',
522
            'portal_type': 'Document',
523
            'review_state': 'published',
524
            'fullobjects': 'y',
525
        }
526
    assert not resp.json['err']
527
    assert [(x['id'], x['text']) for x in resp.json['data']] == [
528
        ('593cf5489fce493a95b59bb4f3ef9ee1', 'Una Página dentro de una carpeta (Document)'),
529
        ('19f0cd24fec847c09744fbc85aace167', 'Bienvenido a Plone (Document)'),
530
        ('13f909b522a94443823e187ea9ebab0b', 'Una página (Document)'),
531
    ]
532
    assert resp.json['meta'] == {'label': 'demo query', 'description': 'Spanish published documents'}
533

  
534

  
535
def test_query_q_using_id(app, query, token):
536
    endpoint = '/plone-restapi/my_connector/q/my_query/'
537
    url = query.resource.service_url + '/es/@search'
538
    params = {
539
        'id': '19f0cd24fec847c09744fbc85aace167',
540
    }
541
    qs = {}
542
    with utils.mock_url(url=url, response=json_get_data('id_search'), qs=qs):
543
        resp = app.get(endpoint, params=params)
544
    assert qs == {
545
        'UID': '19f0cd24fec847c09744fbc85aace167',
546
        'fullobjects': 'y',
547
    }
548
    assert len(resp.json['data']) == 1
549
    assert resp.json['data'][0]['text'] == 'Bienvenido a Plone (Document)'
550
    assert resp.json['meta'] == {'label': 'demo query', 'description': 'Spanish published documents'}
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
-