Projet

Général

Profil

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

Nicolas Roche, 14 octobre 2021 15:24

Télécharger (65,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       | 387 +++++++++++++
 passerelle/apps/plone_restapi/urls.py         |  31 +
 passerelle/apps/plone_restapi/views.py        |  44 ++
 passerelle/base/models.py                     |   1 +
 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 +-
 14 files changed, 1526 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 on 2021-10-14 07:51
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 refine and exclude facet expressions separated 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. A minus sign - may be used to perform an ascending sort.',
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', 'slug'), ('resource', 'name')},
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 django.core.cache import cache
18
from django.db import models
19
from django.shortcuts import get_object_or_404
20
from django.urls import reverse
21
from django.utils.six.moves.urllib import parse as urlparse
22
from django.utils.translation import ugettext_lazy as _
23
from requests import RequestException
24

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

  
32

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

  
37

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

  
66
    category = _('Data Sources')
67

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

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

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

  
90
    @classmethod
91
    def plone_to_wcs(cls, key):
92
        return 'portal_%s' % key[1:] if key[0] == '@' else None
93

  
94
    @classmethod
95
    def wcs_to_plone(cls, key):
96
        return '@%s' % key[7:] if key[0:7] == 'portal_' else None
97

  
98
    @classmethod
99
    def rename_keys(cls, translate, data):
100
        if isinstance(data, list):
101
            for value in list(data):
102
                cls.rename_keys(translate, value)
103
        elif isinstance(data, dict):
104
            for key, value in list(data.items()):
105
                cls.rename_keys(translate, value)
106
                new_key = translate(key)
107
                if new_key:
108
                    data[new_key] = value
109
                    del data[key]
110

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

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

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

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

  
198
        self.rename_keys(self.plone_to_wcs, json_response)
199
        result = []
200
        for record in json_response.get('items'):
201
            data = self.normalize_record(text_template, record)
202
            result.append(data)
203
        return result
204

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

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

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

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

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

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

  
313
    def create_query_url(self):
314
        return reverse('plone-restapi-query-new', kwargs={'slug': self.slug})
315

  
316

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

  
357
    delete_view = 'plone-restapi-query-delete'
358
    edit_view = 'plone-restapi-query-edit'
359

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

  
374
    def as_endpoint(self):
375
        endpoint = super(Query, self).as_endpoint(path=self.resource.q.endpoint_info.name)
376

  
377
        search_endpoint = self.resource.search.endpoint_info
378
        endpoint.func = search_endpoint.func
379
        endpoint.show_undocumented_params = False
380

  
381
        # Copy generic params descriptions from original endpoint
382
        # if they are not overloaded by the query
383
        for param in search_endpoint.parameters:
384
            if param in ('uri', 'text_template') and getattr(self, param):
385
                continue
386
            endpoint.parameters[param] = search_endpoint.parameters[param]
387
        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/base/models.py
54 54
    ('CRITICAL', _('Critical')),
55 55
)
56 56

  
57 57
BASE_EXPORT_FIELDS = (
58 58
    models.TextField,
59 59
    models.CharField,
60 60
    models.SlugField,
61 61
    models.URLField,
62
    models.UUIDField,
62 63
    models.BooleanField,
63 64
    models.IntegerField,
64 65
    models.CommaSeparatedIntegerField,
65 66
    models.EmailField,
66 67
    models.IntegerField,
67 68
    models.PositiveIntegerField,
68 69
    JSONField,
69 70
    models.FloatField,
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.wcs_to_plone, plone_data)
190
    assert plone_data == {
191
        '@type': 'Document',
192
        '@dict': {'@array': [{'@key1': 'value1', '@key2': 'value2'}, 'portal_value_to_keep']},
193
    }
194
    wcs_data = deepcopy(plone_data)
195
    PloneRestApi.rename_keys(PloneRestApi.plone_to_wcs, wcs_data)
196
    assert json.dumps(wcs_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': False,
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': 'descending',
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
-