Projet

Général

Profil

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

Nicolas Roche, 15 octobre 2021 12:46

Télécharger (65,2 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       | 393 +++++++++++++
 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                   | 546 ++++++++++++++++++
 tests/utils.py                                |   4 +-
 13 files changed, 1531 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.jsonresponse import APIError
31
from passerelle.utils.templates import render_to_string, validate_template
32

  
33

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

  
38

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

  
67
    category = _('Data Sources')
68

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

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

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

  
91
    @classmethod
92
    def plone_to_publik(cls, key):
93
        """Rename plone response keys having leading '@'
94
        ex: @id -> portal_id"""
95
        return 'portal_%s' % key[1:] if key[0] == '@' else None
96

  
97
    @classmethod
98
    def publik_to_plone(cls, key):
99
        """Rename wcs payload keys having leading 'portal_'
100
        ex: portal_id -> @id"""
101
        return '@%s' % key[7:] if key[0:7] == 'portal_' else None
102

  
103
    @classmethod
104
    def rename_keys(cls, translate, data):
105
        if isinstance(data, list):
106
            for value in list(data):
107
                cls.rename_keys(translate, value)
108
        elif isinstance(data, dict):
109
            for key, value in list(data.items()):
110
                cls.rename_keys(translate, value)
111
                new_key = translate(key)
112
                if new_key:
113
                    data[new_key] = value
114
                    del data[key]
115

  
116
    @classmethod
117
    def normalize_record(cls, text_template, record):
118
        data = {}
119
        for key, value in record.items():
120
            if key in ('id', 'text'):
121
                key = 'original_%s' % key
122
            data[key] = value
123
        data['id'] = record.get('UID')
124
        data['text'] = render_to_string(text_template, data).strip()
125
        return data
126

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

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

  
176
    def call_search(
177
        self,
178
        uri='',
179
        text_template='',
180
        filter_expression='',
181
        sort=None,
182
        order=True,
183
        limit=None,
184
        id=None,
185
        q=None,
186
    ):
187
        query = urlsplit(self.service_url).query
188
        params = dict(parse_qsl(query))
189
        if id:
190
            params['UID'] = id
191
        else:
192
            if q is not None:
193
                params['SearchableText'] = q
194
            if sort:
195
                params['sort_on'] = sort
196
                if order:
197
                    params['sort_order'] = 'ascending'
198
                else:
199
                    params['sort_order'] = 'descending'
200
            if limit:
201
                params['b_size'] = limit
202
            params.update(parse_qsl(filter_expression))
203
        params['fullobjects'] = 'y'
204
        json_response = self.request(uri=uri, uid='@search', method='GET', params=params)
205

  
206
        self.rename_keys(self.plone_to_publik, json_response)
207
        result = []
208
        for record in json_response.get('items'):
209
            data = self.normalize_record(text_template, record)
210
            result.append(data)
211
        return result
212

  
213
    @endpoint(
214
        perm='can_access',
215
        description=_('Fetch'),
216
        parameters={
217
            'uri': {'description': _('Uri')},
218
            'uid': {'description': _('Uid')},
219
            'text_template': {'description': _('Text template')},
220
        },
221
        display_order=1,
222
    )
223
    def fetch(self, request, uid, uri='', text_template=''):
224
        json_response = self.request(uri=uri, uid=uid, method='GET')
225
        self.rename_keys(self.plone_to_publik, json_response)
226
        data = self.normalize_record(text_template, json_response)
227
        return {'data': data}
228

  
229
    @endpoint(
230
        perm='can_access',
231
        description=_('Creates'),
232
        parameters={
233
            'uri': {'description': _('Uri')},
234
        },
235
        methods=['post'],
236
        display_order=2,
237
    )
238
    def create(self, request, uri):
239
        try:
240
            post_data = json_loads(request.body)
241
        except ValueError as e:
242
            raise ParameterTypeError(str(e))
243
        self.rename_keys(self.publik_to_plone, post_data)
244
        response = self.request(uri=uri, method='POST', json=post_data)
245
        return {'data': {'uid': response['UID'], 'created': True}}
246

  
247
    @endpoint(
248
        perm='can_access',
249
        description=_('Update'),
250
        parameters={
251
            'uri': {'description': _('Uri')},
252
            'uid': {'description': _('Uid')},
253
        },
254
        methods=['post'],
255
        display_order=3,
256
    )
257
    def update(self, request, uid, uri=''):
258
        try:
259
            post_data = json_loads(request.body)
260
        except ValueError as e:
261
            raise ParameterTypeError(str(e))
262
        self.rename_keys(self.publik_to_plone, post_data)
263
        self.request(uri=uri, uid=uid, method='PATCH', json=post_data)
264
        return {'data': {'uid': uid, 'updated': True}}
265

  
266
    @endpoint(
267
        perm='can_access',
268
        description=_('Remove'),
269
        parameters={
270
            'uri': {'description': _('Uri')},
271
            'uid': {'description': _('Uid')},
272
        },
273
        methods=['delete'],
274
        display_order=4,
275
    )
276
    def remove(self, request, uid, uri=''):
277
        self.request(method='DELETE', uri=uri, uid=uid)
278
        return {'data': {'uid': uid, 'removed': True}}
279

  
280
    @endpoint(
281
        perm='can_access',
282
        description=_('Search'),
283
        parameters={
284
            'uri': {'description': _('Uri')},
285
            'text_template': {'description': _('Text template')},
286
            'sort': {'description': _('Sort field')},
287
            'order': {'description': _('Ascending sort order'), 'type': 'bool'},
288
            'limit': {'description': _('Maximum items')},
289
            'id': {'description': _('Record identifier')},
290
            'q': {'description': _('Full text query')},
291
        },
292
    )
293
    def search(
294
        self,
295
        request,
296
        uri='',
297
        text_template='',
298
        sort=None,
299
        order=True,
300
        limit=None,
301
        id=None,
302
        q=None,
303
        **kwargs,
304
    ):
305
        result = self.call_search(uri, text_template, '', sort, order, limit, id, q)
306
        return {'data': result}
307

  
308
    @endpoint(
309
        name='q',
310
        description=_('Query'),
311
        pattern=r'^(?P<query_slug>[\w:_-]+)/$',
312
        perm='can_access',
313
        show=False,
314
    )
315
    def q(self, request, query_slug, **kwargs):
316
        query = get_object_or_404(Query, resource=self, slug=query_slug)
317
        result = query.q(request, **kwargs)
318
        meta = {'label': query.name, 'description': query.description}
319
        return {'data': result, 'meta': meta}
320

  
321
    def create_query_url(self):
322
        return reverse('plone-restapi-query-new', kwargs={'slug': self.slug})
323

  
324

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

  
363
    delete_view = 'plone-restapi-query-delete'
364
    edit_view = 'plone-restapi-query-edit'
365

  
366
    def q(self, request, **kwargs):
367
        return self.resource.call_search(
368
            uri=self.uri,
369
            text_template=self.text_template,
370
            filter_expression='&'.join(
371
                [x.strip() for x in str(self.filter_expression).splitlines() if x.strip()]
372
            ),
373
            sort=self.sort,
374
            order=self.order,
375
            limit=self.limit,
376
            id=kwargs.get('id'),
377
            q=kwargs.get('q'),
378
        )
379

  
380
    def as_endpoint(self):
381
        endpoint = super(Query, self).as_endpoint(path=self.resource.q.endpoint_info.name)
382

  
383
        search_endpoint = self.resource.search.endpoint_info
384
        endpoint.func = search_endpoint.func
385
        endpoint.show_undocumented_params = False
386

  
387
        # Copy generic params descriptions from original endpoint
388
        # if they are not overloaded by the query
389
        for param in search_endpoint.parameters:
390
            if param in ('uri', 'text_template') and getattr(self, param):
391
                continue
392
            endpoint.parameters[param] = search_endpoint.parameters[param]
393
        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
from copy import deepcopy
21

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

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

  
31
pytestmark = pytest.mark.django_db
32

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

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

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

  
47

  
48
# test data comes from https://demo.plone.org/en
49
def json_get_data(filename):
50
    with open(os.path.join(TEST_BASE_DIR, "%s.json" % filename)) as fd:
51
        return json.load(fd)
52

  
53

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

  
68

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

  
74

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

  
93

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

  
105

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

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

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

  
161

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

  
174

  
175
def test_rename_keys():
176
    data = {
177
        'portal_type': 'Document',
178
        'portal_dict': {
179
            'portal_array': [
180
                {
181
                    'portal_key1': 'value1',
182
                    'portal_key2': 'value2',
183
                },
184
                'portal_value_to_keep',
185
            ]
186
        },
187
    }
188
    plone_data = deepcopy(data)
189
    PloneRestApi.rename_keys(PloneRestApi.publik_to_plone, plone_data)
190
    assert plone_data == {
191
        '@type': 'Document',
192
        '@dict': {'@array': [{'@key1': 'value1', '@key2': 'value2'}, 'portal_value_to_keep']},
193
    }
194
    publik_data = deepcopy(plone_data)
195
    PloneRestApi.rename_keys(PloneRestApi.plone_to_publik, publik_data)
196
    assert json.dumps(publik_data) == json.dumps(data)
197

  
198

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

  
216

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

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

  
233

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

  
250

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

  
269

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

  
292

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

  
308

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

  
319

  
320
def test_update(app, connector, token):
321
    endpoint = utils.generic_endpoint_url('plone-restapi', 'update', slug=connector.slug)
322
    assert endpoint == '/plone-restapi/my_connector/update'
323
    url = connector.service_url + '/es/19f0cd24fec847c09744fbc85aace167'
324
    query_string = '?uri=es&uid=19f0cd24fec847c09744fbc85aace167'
325
    payload = {
326
        'title': 'Test update',
327
    }
328
    with utils.mock_url(url=url, response='', status_code=204):
329
        resp = app.post_json(endpoint + query_string, params=payload)
330
    assert not resp.json['err']
331
    assert resp.json['data'] == {'uid': '19f0cd24fec847c09744fbc85aace167', '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 + '/es/19f0cd24fec847c09744fbc85aace167'
338
    query_string = '?uri=es&uid=19f0cd24fec847c09744fbc85aace167'
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 + '/es/19f0cd24fec847c09744fbc85aace167'
350
    query_string = '?uri=es&uid=19f0cd24fec847c09744fbc85aace167'
351
    with utils.mock_url(url=url, response='', status_code=204):
352
        resp = app.delete(endpoint + query_string)
353
    assert resp.json['data'] == {'uid': '19f0cd24fec847c09744fbc85aace167', '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 + '/es/@search'
361
    params = {
362
        'uri': 'es',
363
        'text_template': '{{ title }} ({{ portal_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
        ('593cf5489fce493a95b59bb4f3ef9ee1', 'Una Página dentro de una carpeta (Document)'),
377
        ('19f0cd24fec847c09744fbc85aace167', 'Bienvenido a Plone (Document)'),
378
        ('13f909b522a94443823e187ea9ebab0b', 'Una página (Document)'),
379
    ]
380

  
381

  
382
def test_call_search_normalize_keys(app, connector, token):
383
    endpoint = utils.generic_endpoint_url('plone-restapi', 'search', slug=connector.slug)
384
    assert endpoint == '/plone-restapi/my_connector/search'
385
    url = connector.service_url + '/@search'
386

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

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

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

  
428

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

  
459

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

  
476

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

  
503

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

  
530

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