0001-esirius-add-e-sirius-connector-51365.patch
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 |
- |