Projet

Général

Profil

0001-add-toulouse_smart-connector-53834.patch

Benjamin Dauvergne, 07 mai 2021 12:30

Télécharger (24 ko)

Voir les différences:

Subject: [PATCH] add toulouse_smart connector (#53834)

 passerelle/contrib/toulouse_smart/__init__.py |   0
 .../toulouse_smart/migrations/0001_initial.py |  92 +++++++++
 .../toulouse_smart/migrations/__init__.py     |   0
 passerelle/contrib/toulouse_smart/models.py   | 122 ++++++++++++
 .../toulousesmartresource_detail.html         |  11 ++
 .../toulouse_smart/type-intervention.html     |  48 +++++
 .../templates/toulouse_smart/wcs_block.wcs    |  22 +++
 passerelle/contrib/toulouse_smart/urls.py     |  32 ++++
 passerelle/contrib/toulouse_smart/views.py    |  70 +++++++
 tests/settings.py                             |   1 +
 tests/test_toulouse_smart.py                  | 178 ++++++++++++++++++
 11 files changed, 576 insertions(+)
 create mode 100644 passerelle/contrib/toulouse_smart/__init__.py
 create mode 100644 passerelle/contrib/toulouse_smart/migrations/0001_initial.py
 create mode 100644 passerelle/contrib/toulouse_smart/migrations/__init__.py
 create mode 100644 passerelle/contrib/toulouse_smart/models.py
 create mode 100644 passerelle/contrib/toulouse_smart/templates/toulouse_smart/toulousesmartresource_detail.html
 create mode 100644 passerelle/contrib/toulouse_smart/templates/toulouse_smart/type-intervention.html
 create mode 100644 passerelle/contrib/toulouse_smart/templates/toulouse_smart/wcs_block.wcs
 create mode 100644 passerelle/contrib/toulouse_smart/urls.py
 create mode 100644 passerelle/contrib/toulouse_smart/views.py
 create mode 100644 tests/test_toulouse_smart.py
passerelle/contrib/toulouse_smart/migrations/0001_initial.py
1
# Generated by Django 2.2.19 on 2021-05-06 22:50
2

  
3
import django.contrib.postgres.fields.jsonb
4
from django.db import migrations, models
5
import django.db.models.deletion
6

  
7

  
8
class Migration(migrations.Migration):
9

  
10
    initial = True
11

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

  
16
    operations = [
17
        migrations.CreateModel(
18
            name='ToulouseSmartResource',
19
            fields=[
20
                (
21
                    'id',
22
                    models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
23
                ),
24
                ('title', models.CharField(max_length=50, verbose_name='Title')),
25
                ('slug', models.SlugField(unique=True, verbose_name='Identifier')),
26
                ('description', models.TextField(verbose_name='Description')),
27
                (
28
                    'basic_auth_username',
29
                    models.CharField(
30
                        blank=True, max_length=128, verbose_name='Basic authentication username'
31
                    ),
32
                ),
33
                (
34
                    'basic_auth_password',
35
                    models.CharField(
36
                        blank=True, max_length=128, verbose_name='Basic authentication password'
37
                    ),
38
                ),
39
                (
40
                    'client_certificate',
41
                    models.FileField(
42
                        blank=True, null=True, upload_to='', verbose_name='TLS client certificate'
43
                    ),
44
                ),
45
                (
46
                    'trusted_certificate_authorities',
47
                    models.FileField(blank=True, null=True, upload_to='', verbose_name='TLS trusted CAs'),
48
                ),
49
                (
50
                    'verify_cert',
51
                    models.BooleanField(blank=True, default=True, verbose_name='TLS verify certificates'),
52
                ),
53
                (
54
                    'http_proxy',
55
                    models.CharField(blank=True, max_length=128, verbose_name='HTTP and HTTPS proxy'),
56
                ),
57
                ('webservice_base_url', models.URLField(verbose_name='Webservice Base URL')),
58
                (
59
                    'users',
60
                    models.ManyToManyField(
61
                        blank=True,
62
                        related_name='_toulousesmartresource_users_+',
63
                        related_query_name='+',
64
                        to='base.ApiUser',
65
                    ),
66
                ),
67
            ],
68
            options={
69
                'verbose_name': 'Toulouse Smart',
70
            },
71
        ),
72
        migrations.CreateModel(
73
            name='Cache',
74
            fields=[
75
                (
76
                    'id',
77
                    models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
78
                ),
79
                ('key', models.CharField(max_length=64, verbose_name='Key')),
80
                ('timestamp', models.DateTimeField(auto_now=True, verbose_name='Timestamp')),
81
                ('value', django.contrib.postgres.fields.jsonb.JSONField(default=dict, verbose_name='Value')),
82
                (
83
                    'resource',
84
                    models.ForeignKey(
85
                        on_delete=django.db.models.deletion.CASCADE,
86
                        to='toulouse_smart.ToulouseSmartResource',
87
                        verbose_name='Resource',
88
                    ),
89
                ),
90
            ],
91
        ),
92
    ]
passerelle/contrib/toulouse_smart/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
import datetime
18

  
19
from django.db import models
20

  
21
from django.utils.timezone import now
22
from django.utils.translation import ugettext_lazy as _
23

  
24
from django.contrib.postgres.fields import JSONField
25

  
26
import lxml.etree as ET
27

  
28
from passerelle.base.models import BaseResource, HTTPResource
29
from passerelle.utils import xml
30
from passerelle.utils.api import endpoint
31

  
32

  
33
class ToulouseSmartResource(BaseResource, HTTPResource):
34
    category = _('Business Process Connectors')
35

  
36
    webservice_base_url = models.URLField(_('Webservice Base URL'))
37

  
38
    log_requests_errors = False
39

  
40
    class Meta:
41
        verbose_name = _('Toulouse Smart')
42

  
43
    def get_intervention_types(self):
44
        try:
45
            return self.get('intervention-types', max_age=120)
46
        except KeyError:
47
            pass
48

  
49
        try:
50
            url = self.webservice_base_url + 'v1/type-intervention'
51
            response = self.requests.get(url)
52
            doc = ET.fromstring(response.content)
53
            intervention_types = []
54
            for xml_item in doc:
55
                item = xml.to_json(xml_item)
56
                for prop in item.get('properties', []):
57
                    prop['required'] = prop.get('required') == 'true'
58
                intervention_types.append(item)
59
            intervention_types.sort(key=lambda x: x['name'])
60
            for i, intervention_type in enumerate(intervention_types):
61
                intervention_type['order'] = i + 1
62
        except Exception:
63
            try:
64
                return self.get('intervention-types')
65
            except KeyError:
66
                raise
67
        self.set('intervention-types', intervention_types)
68
        return intervention_types
69

  
70
    def get(self, key, max_age=None):
71
        cache_entries = self.cache_entries
72
        if max_age:
73
            cache_entries = cache_entries.filter(timestamp__gt=now() - datetime.timedelta(seconds=max_age))
74
        try:
75
            return cache_entries.get(key='intervention-types').value
76
        except Cache.DoesNotExist:
77
            raise KeyError(key)
78

  
79
    def set(self, key, value):
80
        self.cache_entries.update_or_create(key=key, defaults={'value': value})
81

  
82
    @endpoint(
83
        name='type-intervention',
84
        description=_('Get intervention types'),
85
        perm='can_access',
86
    )
87
    def type_intervention(self, request):
88
        try:
89
            return {
90
                'data': [
91
                    {
92
                        'id': intervention_type['id'],
93
                        'text': intervention_type['name'],
94
                    }
95
                    for intervention_type in self.get_intervention_types()
96
                ]
97
            }
98
        except Exception:
99
            return {
100
                'data': [
101
                    {
102
                        'id': '',
103
                        'text': _('Service is unavailable'),
104
                        'disabled': True,
105
                    }
106
                ]
107
            }
108

  
109

  
110
class Cache(models.Model):
111
    resource = models.ForeignKey(
112
        verbose_name=_('Resource'),
113
        to=ToulouseSmartResource,
114
        on_delete=models.CASCADE,
115
        related_name='cache_entries',
116
    )
117

  
118
    key = models.CharField(_('Key'), max_length=64)
119

  
120
    timestamp = models.DateTimeField(_('Timestamp'), auto_now=True)
121

  
122
    value = JSONField(_('Value'), default=dict)
passerelle/contrib/toulouse_smart/templates/toulouse_smart/toulousesmartresource_detail.html
1
{% extends "passerelle/manage/service_view.html" %}
2
{% load i18n passerelle %}
3

  
4
{% block extra-sections %}
5
    <div class="section">
6
        <h3>{% trans "Details" %}</h3>
7
        <ul>
8
            <li><a href="{% url "toulouse-smart-type-intervention" slug=object.slug %}">{% trans "Intervention types" %}</a></li>
9
        </ul>
10
    </div>
11
{% endblock %}
passerelle/contrib/toulouse_smart/templates/toulouse_smart/type-intervention.html
1
{% extends "passerelle/manage.html" %}
2
{% load i18n gadjo %}
3

  
4
{% block breadcrumb %}
5
{{ block.super }}
6
<a href="{{ toulousesmartresource.get_absolute_url }}">{{ toulousesmartresource.title }}</a>
7
<a href="#">{% trans 'Intervention types' %}</a>
8
{% endblock %}
9

  
10
{% block appbar %}
11
<h2>{% trans 'Intervention types' %}</h2>
12
<span class="actions">
13
    <a href="{% url "toulouse-smart-type-intervention-as-blocks" slug=toulousesmartresource.slug %}">{% trans "Export to blocks" %}</a>
14
</span>
15
{% endblock %}
16

  
17
{% block content %}
18
<table class="main">
19
    <thead>
20
        <tr>
21
            <th>Nom du type d'intervention</th>
22
            <th>Nom</th>
23
            <th>Type</th>
24
            <th>Requis</th>
25
            <th>Valeur par défaut</th>
26
        </tr>
27
    </thead>
28
    <tbody>
29
        {% for intervention_type in toulousesmartresource.get_intervention_types %}
30
        <tr><td colspan="4">{{ intervention_type.order }} - {{ intervention_type.name }}</td></tr>
31
            {% for property in intervention_type.properties %}
32
                <tr>
33
                    <td></td>
34
                    <td>{{ property.name }}</td>
35
                    <td>{{ property.type }}</td>
36
                    <td title="{{ property.required|lower }}">{{ property.required|yesno:"✔,✘" }}</td>
37
                    <td>{{ property.defaultValue }}</td>
38
                </tr>
39
            {% endfor %}
40
        {% endfor %}
41
    </tbody>
42
</table>
43

  
44
<!--
45
{{ toulousesmartresource.get_intervention_types|pprint }}
46
-->
47

  
48
{% endblock %}
passerelle/contrib/toulouse_smart/templates/toulouse_smart/wcs_block.wcs
1
<?xml version="1.0"?>
2
<block id="{{ id }}">
3
  <name>{{ name }}</name>
4
  <slug>{{ name|slugify }}</slug>
5
  <fields>
6
    {% for property in properties %}
7
    <field>
8
      <id>{{ property.id }}</id>
9
      <label>{{ property.name }}</label>
10
      <type>{{ property.type }}</type>
11
      <required>{{ property.required }}</required>
12
      <varname>{{ property.name|slugify }}</varname>
13
      <display_locations>
14
        <display_location>validation</display_location>
15
        <display_location>summary</display_location>
16
      </display_locations>{% if property.validation %}
17
      <validation>
18
        <type>{{ property.validation }}</type>
19
      </validation>{% endif %}
20
    </field>{% endfor %}
21
  </fields>
22
</block>
passerelle/contrib/toulouse_smart/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 .views import TypeIntervention, TypeInterventionAsBlocks
20

  
21
management_urlpatterns = [
22
    url(
23
        r'^(?P<slug>[\w,-]+)/type-intervention/as-blocks/$',
24
        TypeInterventionAsBlocks.as_view(),
25
        name='toulouse-smart-type-intervention-as-blocks',
26
    ),
27
    url(
28
        r'^(?P<slug>[\w,-]+)/type-intervention/$',
29
        TypeIntervention.as_view(),
30
        name='toulouse-smart-type-intervention',
31
    ),
32
]
passerelle/contrib/toulouse_smart/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
import hashlib
18
import uuid
19
import zipfile
20

  
21
from django.http import HttpResponse
22
from django.template.loader import render_to_string
23
from django.utils.text import slugify
24
from django.views.generic import DetailView
25

  
26
from .models import ToulouseSmartResource
27

  
28

  
29
class TypeIntervention(DetailView):
30
    model = ToulouseSmartResource
31
    template_name = 'toulouse_smart/type-intervention.html'
32

  
33

  
34
class TypeInterventionAsBlocks(DetailView):
35
    model = ToulouseSmartResource
36

  
37
    def get(self, request, *args, **kwargs):
38
        self.object = self.get_object()
39

  
40
        def make_id(s):
41
            return str(uuid.UUID(bytes=hashlib.md5(s.encode()).digest()))
42

  
43
        # generate file contents
44
        files = {}
45
        for intervention_type in self.object.get_intervention_types():
46
            slug = slugify(intervention_type['name'])
47
            # only export intervention_type with properties
48
            if not intervention_type.get('properties'):
49
                continue
50
            for prop in intervention_type['properties']:
51
                # generate a natural id for fields
52
                prop['id'] = make_id(slug + slugify(prop['name']))
53
                # adapt types
54
                prop.setdefault('type', 'string')
55
                if prop['type'] == 'boolean':
56
                    prop['type'] = 'bool'
57
                if prop['type'] == 'int':
58
                    prop['type'] = 'string'
59
                    prop['validation'] = 'digits'
60
            filename = 'block-%s.wcs' % slug
61
            files[filename] = render_to_string('toulouse_smart/wcs_block.wcs', context=intervention_type)
62

  
63
        # zip it !
64
        response = HttpResponse(content_type='application/zip')
65
        response['Content-Disposition'] = 'attachment; filename=blocks.zip'
66
        with zipfile.ZipFile(response, mode='w') as zip_file:
67
            for name in files:
68
                zip_file.writestr(name, files[name])
69

  
70
        return response
tests/settings.py
39 39
    'passerelle.contrib.teamnet_axel',
40 40
    'passerelle.contrib.tcl',
41 41
    'passerelle.contrib.toulouse_axel',
42
    'passerelle.contrib.toulouse_smart',
42 43
    'passerelle.contrib.lille_kimoce',
43 44
)
44 45

  
tests/test_toulouse_smart.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
import functools
18
import io
19
import zipfile
20

  
21
import lxml.etree as ET
22
import httmock
23
import pytest
24

  
25
import utils
26

  
27
from passerelle.contrib.toulouse_smart.models import ToulouseSmartResource
28
from passerelle.utils.xml import to_json
29

  
30
from test_manager import login
31

  
32

  
33
@pytest.fixture
34
def smart(db):
35
    return utils.make_resource(
36
        ToulouseSmartResource,
37
        title='Test',
38
        slug='test',
39
        description='Test',
40
        webservice_base_url='https://smart.example.com/',
41
        basic_auth_username='username',
42
        basic_auth_password='password',
43
    )
44

  
45

  
46
def mock_response(*path_contents):
47
    def decorator(func):
48
        @httmock.urlmatch()
49
        def error(url, request):
50
            assert False, 'request to %s' % (url,)
51

  
52
        @functools.wraps(func)
53
        def wrapper(*args, **kwargs):
54
            handlers = []
55
            for row in path_contents:
56
                path, content = row
57

  
58
                @httmock.urlmatch(path=path)
59
                def handler(url, request):
60
                    return content
61

  
62
                handlers.append(handler)
63
            handlers.append(error)
64

  
65
            with httmock.HTTMock(*handlers):
66
                return func(*args, **kwargs)
67

  
68
        return wrapper
69

  
70
    return decorator
71

  
72

  
73
@mock_response(['/v1/type-intervention', b'<List></List>'])
74
def test_empty_intervention_types(smart):
75
    assert smart.get_intervention_types() == []
76

  
77

  
78
INTERVENTION_TYPES = b'''<List>
79
   <item>
80
       <id>1234</id>
81
       <name>coin</name>
82
       <properties>
83
           <properties>
84
              <name>FIELD1</name>
85
              <type>string</type>
86
              <required>false</required>
87
           </properties>
88
           <properties>
89
              <name>FIELD2</name>
90
              <type>int</type>
91
              <required>true</required>
92
           </properties>
93
       </properties>
94
   </item>
95
</List>'''
96

  
97

  
98
@mock_response(['/v1/type-intervention', INTERVENTION_TYPES])
99
def test_model_intervention_types(smart):
100
    assert smart.get_intervention_types() == [
101
        {
102
            'id': '1234',
103
            'name': 'coin',
104
            'order': 1,
105
            'properties': [
106
                {'name': 'FIELD1', 'required': False, 'type': 'string'},
107
                {'name': 'FIELD2', 'required': True, 'type': 'int'},
108
            ],
109
        },
110
    ]
111

  
112

  
113
URL = '/toulouse-smart/test/'
114

  
115

  
116
@mock_response(['/v1/type-intervention', INTERVENTION_TYPES])
117
def test_endpoint_intervention_types(app, smart):
118
    resp = app.get(URL + 'type-intervention')
119
    assert resp.json == {'data': [{'id': '1234', 'text': 'coin'}], 'err': 0}
120

  
121

  
122
@mock_response()
123
def test_endpoint_intervention_types_unavailable(app, smart):
124
    resp = app.get(URL + 'type-intervention')
125
    assert resp.json == {'data': [{'id': '', 'text': 'Service is unavailable', 'disabled': True}], 'err': 0}
126

  
127

  
128
@mock_response(['/v1/type-intervention', INTERVENTION_TYPES])
129
def test_manage_intervention_types(app, smart, admin_user):
130
    login(app)
131
    resp = app.get('/manage' + URL + 'type-intervention/')
132
    assert [[td.text for td in tr.cssselect('td,th')] for tr in resp.pyquery('tr')] == [
133
        ["Nom du type d'intervention", 'Nom', 'Type', 'Requis', 'Valeur par défaut'],
134
        ['1 - coin'],
135
        [None, 'FIELD1', 'string', '✘', None],
136
        [None, 'FIELD2', 'int', '✔', None],
137
    ]
138
    resp = resp.click('Export to blocks')
139
    with zipfile.ZipFile(io.BytesIO(resp.body)) as zip_file:
140
        assert zip_file.namelist() == ['block-coin.wcs']
141
        with zip_file.open('block-coin.wcs') as fd:
142
            content = ET.tostring(ET.fromstring(fd.read()), pretty_print=True).decode()
143
            assert (
144
                content
145
                == '''<block id="1234">
146
  <name>coin</name>
147
  <slug>coin</slug>
148
  <fields>
149
    
150
    <field>
151
      <id>038a8c2e-14de-4d4f-752f-496eb7fe90d7</id>
152
      <label>FIELD1</label>
153
      <type>string</type>
154
      <required>False</required>
155
      <varname>field1</varname>
156
      <display_locations>
157
        <display_location>validation</display_location>
158
        <display_location>summary</display_location>
159
      </display_locations>
160
    </field>
161
    <field>
162
      <id>e72f251a-5eef-5b78-c35a-94b549510029</id>
163
      <label>FIELD2</label>
164
      <type>string</type>
165
      <required>True</required>
166
      <varname>field2</varname>
167
      <display_locations>
168
        <display_location>validation</display_location>
169
        <display_location>summary</display_location>
170
      </display_locations>
171
      <validation>
172
        <type>digits</type>
173
      </validation>
174
    </field>
175
  </fields>
176
</block>
177
'''
178
            )
0
-