Projet

Général

Profil

0001-esirius-add-e-sirius-connector-51365.patch

Nicolas Roche, 25 février 2021 18:51

Télécharger (18,6 ko)

Voir les différences:

Subject: [PATCH] esirius: add e-sirius connector (#51365)

 passerelle/apps/esirius/__init__.py           |   0
 .../apps/esirius/migrations/0001_initial.py   |  51 +++++
 .../apps/esirius/migrations/__init__.py       |   0
 passerelle/apps/esirius/models.py             | 205 ++++++++++++++++++
 passerelle/settings.py                        |   1 +
 tests/test_esirius.py                         | 199 +++++++++++++++++
 6 files changed, 456 insertions(+)
 create mode 100644 passerelle/apps/esirius/__init__.py
 create mode 100644 passerelle/apps/esirius/migrations/0001_initial.py
 create mode 100644 passerelle/apps/esirius/migrations/__init__.py
 create mode 100644 passerelle/apps/esirius/models.py
 create mode 100644 tests/test_esirius.py
passerelle/apps/esirius/migrations/0001_initial.py
1
# -*- coding: utf-8 -*-
2
# Generated by Django 1.11.18 on 2021-02-24 10:23
3
from __future__ import unicode_literals
4

  
5
from django.db import migrations, models
6

  
7

  
8
class Migration(migrations.Migration):
9

  
10
    initial = True
11

  
12
    dependencies = [
13
        ('base', '0027_transaction_id'),
14
    ]
15

  
16
    operations = [
17
        migrations.CreateModel(
18
            name='Esirius',
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
                    'secret_id',
29
                    models.CharField(blank=True, max_length=128, verbose_name='Application identifier'),
30
                ),
31
                ('secret_key', models.CharField(blank=True, max_length=128, verbose_name='Secret Key')),
32
                (
33
                    'base_url',
34
                    models.CharField(
35
                        help_text='example: http://HOST/ePlanning/webservices/api/',
36
                        max_length=256,
37
                        verbose_name='Service URL',
38
                    ),
39
                ),
40
                (
41
                    'users',
42
                    models.ManyToManyField(
43
                        blank=True, related_name='_esirius_users_+', related_query_name='+', to='base.ApiUser'
44
                    ),
45
                ),
46
            ],
47
            options={
48
                'verbose_name': 'e-Sirius',
49
            },
50
        ),
51
    ]
passerelle/apps/esirius/models.py
1
# passerelle - uniform access to multiple data sources and services
2
# Copyright (C) 2020  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

  
18
import base64
19
from datetime import datetime
20
from urllib.parse import urljoin
21

  
22
from Cryptodome.Cipher import DES
23
from Cryptodome.Util.Padding import pad
24
from django.db import models
25
from django.utils.encoding import force_bytes
26
from django.utils.translation import ugettext_lazy as _
27

  
28
from passerelle.base.models import BaseResource
29
from passerelle.utils.api import endpoint
30
from passerelle.utils.jsonresponse import APIError
31

  
32
CREATE_APPOINTMENT_SCHEMA = {
33
    '$schema': 'http://json-schema.org/draft-04/schema#',
34
    "type": "object",
35
    'properties': {
36
        'idSys': {'type': 'integer'},
37
        'CodeRDV': {'type': 'string'},
38
        'beginDate': {'type': 'string', 'pattern': '^[0-9]{4}-[0-9]{2}-[0-9]{2}$'},
39
        'beginTime': {'type': 'string', 'pattern': '^[0-9]{2}:[0-9]{2}$'},
40
        'endDate': {'type': 'string', 'pattern': '^[0-9]{4}-[0-9]{2}-[0-9]{2}$'},
41
        'endTime': {'type': 'string', 'pattern': '^[0-9]{2}:[0-9]{2}$'},
42
        'comment': {'type': 'string'},
43
        'isoLanguage': {'description': 'ex: fr', 'type': 'string'},
44
        'needsConfirmation': {'type': 'boolean'},
45
        'rdvChannel': {'description': 'ex: EAPP0', 'type': 'string'},
46
        'receptionChannel': {'type': 'string'},
47
        'owner': {'type': 'object', 'properties': {'key': {'type': 'string'}, 'value': {'type': 'string'}}},
48
        'user': {
49
            'type': 'object',
50
            'properties': {
51
                'idSys': {'type': 'integer'},
52
                'personalIdentity': {'type': 'string'},
53
                'additionalPersonalIdentity': {"type": "array", "items": {'type': 'string'}},
54
                'lastName': {'type': 'string'},
55
                'civility': {'type': 'string'},
56
                'firstName': {'type': 'string'},
57
                'birthday': {'type': 'string'},
58
                'email': {'type': 'string'},
59
                'fixPhone': {'type': 'string'},
60
                'phone': {'type': 'string'},
61
                'address': {
62
                    'type': 'object',
63
                    'properties': {
64
                        'line1': {'type': 'string'},
65
                        'line2': {'type': 'string'},
66
                        'zipCode': {'type': 'string'},
67
                        'city': {'type': 'string'},
68
                        'country': {'type': 'string'},
69
                    },
70
                },
71
            },
72
        },
73
        'serviceId': {'type': 'string'},
74
        'siteCode': {'type': 'string'},
75
        "resources": {
76
            'type': 'object',
77
            'properties': {
78
                'id': {'type': 'integer'},
79
                'key': {'type': 'string'},
80
                'type': {'type': 'string'},
81
                'name': {'type': 'string'},
82
                'station': {
83
                    'type': 'object',
84
                    'properties': {
85
                        'id': {'type': 'integer'},
86
                        'key': {'type': 'string'},
87
                        'name': {'type': 'string'},
88
                    },
89
                },
90
            },
91
        },
92
        'motives': {
93
            "type": "array",
94
            "items": {
95
                'type': 'object',
96
                'properties': {
97
                    'id': {'type': 'integer'},
98
                    'name': {'type': 'string'},
99
                    'shortName': {'type': 'string'},
100
                    'processingTime': {'type': 'integer'},
101
                    'externalModuleAccess': {'type': 'integer'},
102
                    'quantity': {'type': 'integer'},
103
                    'usePremotiveQuantity': {'type': 'boolean'},
104
                },
105
            },
106
        },
107
    },
108
    'unflatten': True,
109
}
110

  
111

  
112
class ESirius(BaseResource):
113
    secret_id = models.CharField(max_length=128, verbose_name=_('Application identifier'), blank=True)
114
    secret_key = models.CharField(max_length=128, verbose_name=_('Secret Key'), blank=True)
115
    base_url = models.CharField(
116
        max_length=256,
117
        blank=False,
118
        verbose_name=_('Service URL'),
119
        help_text=_('example: http://HOST/ePlanning/webservices/api/'),
120
    )
121

  
122
    category = _('Business Process Connectors')
123

  
124
    class Meta:
125
        verbose_name = _('e-Sirius')
126

  
127
    @staticmethod
128
    def tokenize(caller, key, epoch):
129

  
130
        des_key = pad(force_bytes(key), 8)[:8]
131
        cipher = DES.new(des_key, DES.MODE_ECB)
132

  
133
        # error 500 if spaces are inserted
134
        plaintext = '{"caller":"%s","createInfo":%i}' % (caller, epoch)
135

  
136
        msg = cipher.encrypt(pad(force_bytes(plaintext), 8))
137
        return base64.b64encode(msg)
138

  
139
    def pre_request(self, uri):
140
        url = urljoin(self.base_url, uri)
141
        epoch = int(datetime.now().strftime('%s') + '000')
142
        headers = {
143
            'Accept': 'application/json; charset=utf-8',
144
            'token_info_caller': ESirius.tokenize(self.secret_id, self.secret_key, epoch),
145
        }
146
        return url, headers
147

  
148
    @staticmethod
149
    def post_request(response):
150
        if response.status_code != 200:
151
            try:
152
                json_content = response.json()
153
            except ValueError:
154
                json_content = None
155
            raise APIError(
156
                'error status:%s %r, content:%r'
157
                % (response.status_code, response.reason, response.text[:1024]),
158
                data={'status_code': response.status_code, 'json_content': json_content},
159
            )
160

  
161
    def check_status(self):
162
        """
163
        Raise an exception if something goes wrong.
164
        """
165
        url, headers = self.pre_request('sites/')
166
        response = self.requests.get(url, headers=headers)
167
        ESirius.post_request(response)
168

  
169
    @endpoint(
170
        display_category=_('Appointment'),
171
        name='create-appointment',
172
        perm='can_access',
173
        methods=['post'],
174
        description=_('Create appointment'),
175
        post={'request_body': {'schema': {'application/json': CREATE_APPOINTMENT_SCHEMA}}},
176
    )
177
    def create_appointment(self, request, post_data):
178
        # address dict is required
179
        if not post_data.get('user'):
180
            post_data.update({'user': {}})
181
        if not post_data['user'].get('address'):
182
            post_data['user'].update({'address': {}})
183

  
184
        url, headers = self.pre_request('appointments/')
185
        response = self.requests.post(url, json=post_data, headers=headers)
186
        ESirius.post_request(response)
187
        return {'data': {'booking_id': response.text}}
188

  
189
    @endpoint(
190
        display_category=_('Appointment'),
191
        name='delete-appointment',
192
        perm='can_access',
193
        methods=['post'],
194
        description=_('Delete appointment'),
195
        parameters={
196
            'booking_id': {'description': _('id returned by create-appointment endpoint')},
197
        },
198
    )
199
    def delete_appointment(self, request, booking_id):
200
        url, headers = self.pre_request('appointments/%s/' % booking_id)
201
        response = self.requests.delete(url, headers=headers)
202
        if response.status_code == 304:
203
            raise APIError('Booking not found: %s' % booking_id)
204
        ESirius.post_request(response)
205
        return {'data': {}}
passerelle/settings.py
131 131
    'passerelle.apps.bdp',
132 132
    'passerelle.apps.cartads_cs',
133 133
    'passerelle.apps.choosit',
134 134
    'passerelle.apps.cityweb',
135 135
    'passerelle.apps.clicrdv',
136 136
    'passerelle.apps.cmis',
137 137
    'passerelle.apps.cryptor',
138 138
    'passerelle.apps.csvdatasource',
139
    'passerelle.apps.esirius',
139 140
    'passerelle.apps.family',
140 141
    'passerelle.apps.feeds',
141 142
    'passerelle.apps.gdc',
142 143
    'passerelle.apps.gesbac',
143 144
    'passerelle.apps.jsondatastore',
144 145
    'passerelle.apps.sp_fr',
145 146
    'passerelle.apps.maelis',
146 147
    'passerelle.apps.mdel',
tests/test_esirius.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 httmock
18
import pytest
19

  
20
from passerelle.apps.esirius.models import ESirius
21
from passerelle.utils.jsonresponse import APIError
22

  
23
import utils
24

  
25
CREATE_APPOINTMENT_PAYLOAD = {
26
    'beginDate': '2021-02-24',
27
    'beginTime': '16:40',
28
    'endDate': '2021-02-24',
29
    'endTime': '17:00',
30
    'comment': 'commentaire',
31
    'isoLanguage': 'fr',
32
    'needsConfirmation': False,
33
    'rdvChannel': 'WEBSERVICES',
34
    'receptionChannel': 'WS',
35
    'serviceId': '9',
36
    'siteCode': 'site1',
37
    'resources': {
38
        'id': 1,
39
        'key': '17',
40
        'type': 'STATION',
41
    },
42
}
43

  
44

  
45
@pytest.fixture
46
def connector(db):
47
    return utils.setup_access_rights(
48
        ESirius.objects.create(
49
            slug='test', secret_id='xxx', secret_key='yyy', base_url='https://dummy-server.org'
50
        )
51
    )
52

  
53

  
54
def get_endpoint(name):
55
    return utils.generic_endpoint_url('esirius', name)
56

  
57

  
58
def test_tokenise(connector):
59
    assert (
60
        ESirius.tokenize('eAppointment', 'ES2I Info Caller Http Encryption Key', 1611673986880)
61
        == b'yM4zYAxT67Qvjd20riG3j0eu0t0Ku+HLlttj17Gul7zkruFaXX1J8BJ6sV2Ldgw40axfWh+ESAY='
62
    )
63

  
64

  
65
def test_pre_request(connector):
66
    url, headers = connector.pre_request('an/uri/')
67
    assert url == 'https://dummy-server.org/an/uri/'
68
    assert headers['Accept'] == 'application/json; charset=utf-8'
69
    assert headers['token_info_caller'][:42] == b'f3G6sjRZETBam6vcdrAxmvJQTX5hh6OjZ8XlUO6SMo'
70
    assert headers['token_info_caller'][-10:] == b'DbrIGCxaCW'
71

  
72

  
73
@pytest.mark.parametrize(
74
    'status_code, content, a_dict',
75
    [
76
        (400, '{"message": "help"}', {'message': 'help'}),
77
        (500, 'not json', None),
78
    ],
79
)
80
def test_post_request(status_code, content, a_dict):
81
    with pytest.raises(APIError) as exc:
82
        ESirius.post_request(httmock.response(status_code, content))
83

  
84
    assert exc.value.err
85
    assert exc.value.data['status_code'] == status_code
86
    assert exc.value.data['json_content'] == a_dict
87

  
88

  
89
@pytest.mark.parametrize(
90
    'status_code, content, is_up',
91
    [
92
        (200, 'wathever', True),
93
        (500, '{"message": "help"}', False),
94
    ],
95
)
96
def test_check_status(app, connector, status_code, content, is_up):
97
    @httmock.all_requests
98
    def sigerly_mock(url, request):
99
        return httmock.response(status_code, content)
100

  
101
    if is_up:
102
        with httmock.HTTMock(sigerly_mock):
103
            connector.check_status()
104
    else:
105
        with pytest.raises(APIError):
106
            with httmock.HTTMock(sigerly_mock):
107
                connector.check_status()
108

  
109

  
110
def test_create_appointment(app, connector):
111
    endpoint = get_endpoint('create-appointment')
112

  
113
    @httmock.urlmatch(netloc='dummy-server.org', path='/appointments/', method='POST')
114
    def sigerly_mock(url, request):
115
        return httmock.response(200, b'94PEP4')
116

  
117
    with httmock.HTTMock(sigerly_mock):
118
        resp = app.post_json(endpoint, params=CREATE_APPOINTMENT_PAYLOAD)
119

  
120
    assert not resp.json['err']
121
    assert resp.json['data'] == {'booking_id': '94PEP4'}
122

  
123

  
124
def test_create_appointment_error_404(app, connector):
125
    endpoint = get_endpoint('create-appointment')
126

  
127
    # payload not providing or probiding an unconfigured serviceId
128
    payload = CREATE_APPOINTMENT_PAYLOAD
129
    del payload['serviceId']
130

  
131
    @httmock.urlmatch(netloc='dummy-server.org', path='/appointments/', method='POST')
132
    def sigerly_mock(url, request):
133
        return httmock.response(
134
            404,
135
            {
136
                'code': 'Not Found',
137
                'type': 'com.es2i.planning.api.exception.NoService4RDVException',
138
                'message': "Le rendez-vous {0} n'a pas créé",
139
            },
140
        )
141

  
142
    with httmock.HTTMock(sigerly_mock):
143
        resp = app.post_json(endpoint, params=payload)
144

  
145
    assert resp.json['err']
146
    assert resp.json['data']['status_code'] == 404
147
    assert resp.json['data']['json_content'] == {
148
        'code': 'Not Found',
149
        'type': 'com.es2i.planning.api.exception.NoService4RDVException',
150
        'message': "Le rendez-vous {0} n'a pas créé",
151
    }
152

  
153

  
154
def test_create_appointment_error_500(app, connector):
155
    endpoint = get_endpoint('create-appointment')
156

  
157
    # payload not providing beginTime
158
    payload = {'beginDate': '2021-02-23'}
159

  
160
    @httmock.urlmatch(netloc='dummy-server.org', path='/appointments/', method='POST')
161
    def sigerly_mock(url, request):
162
        return httmock.response(500, 'java stack')
163

  
164
    with httmock.HTTMock(sigerly_mock):
165
        resp = app.post_json(endpoint, params=payload)
166

  
167
    assert resp.json['err']
168
    assert resp.json['err_class'] == 'passerelle.utils.jsonresponse.APIError'
169
    assert resp.json['err_desc'] == "error status:500 None, content:'java stack'"
170
    assert resp.json['data']['status_code'] == 500
171
    assert resp.json['data']['json_content'] is None
172

  
173

  
174
def test_delete_appointment(app, connector):
175
    endpoint = get_endpoint('delete-appointment')
176

  
177
    @httmock.urlmatch(netloc='dummy-server.org', path='/appointments/', method='DELETE')
178
    def sigerly_mock(url, request):
179
        return httmock.response(200, b'')
180

  
181
    with httmock.HTTMock(sigerly_mock):
182
        resp = app.post_json(endpoint + '?booking_id=94PEP4')
183

  
184
    assert not resp.json['err']
185
    assert resp.json['data'] == {}
186

  
187

  
188
def test_delete_appointment_error(app, connector):
189
    endpoint = get_endpoint('delete-appointment')
190

  
191
    @httmock.urlmatch(netloc='dummy-server.org', path='/appointments/', method='DELETE')
192
    def sigerly_mock(url, request):
193
        return httmock.response(304, b'')
194

  
195
    with httmock.HTTMock(sigerly_mock):
196
        resp = app.post_json(endpoint + '?booking_id=94PEP4')
197

  
198
    assert resp.json['err']
199
    assert resp.json['err_desc'] == 'Booking not found: 94PEP4'
0
-