0009-add-MDEL-DDPACS-connector-35818.patch
passerelle/apps/mdel_ddpacs/abstract.py | ||
---|---|---|
1 |
# coding: utf-8 |
|
2 |
# Passerelle - uniform access to data and services |
|
3 |
# Copyright (C) 2019 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 |
from __future__ import unicode_literals |
|
19 | ||
20 |
from collections import namedtuple |
|
21 |
import inspect |
|
22 |
import os |
|
23 |
import re |
|
24 |
import xml.etree.ElementTree as ET |
|
25 |
import zipfile |
|
26 | ||
27 |
from django.db import models, IntegrityError |
|
28 |
from django.core.urlresolvers import reverse |
|
29 |
from django.http import HttpResponse |
|
30 |
from django.utils.translation import ugettext_lazy as _ |
|
31 |
from django.utils import six, functional, html |
|
32 | ||
33 |
import xmlschema |
|
34 | ||
35 |
import jsonfield |
|
36 | ||
37 |
from passerelle.base.models import BaseResource, SkipJob |
|
38 |
from passerelle.utils.api import endpoint |
|
39 |
from passerelle.utils.jsonresponse import APIError |
|
40 |
from passerelle.utils.zip import ZipTemplate |
|
41 |
from passerelle.utils.conversion import exception_to_text |
|
42 | ||
43 |
from passerelle.utils import json, xml, sftp |
|
44 | ||
45 |
'''Base abstract models for implementing MDEL compatible requests. |
|
46 |
''' |
|
47 | ||
48 |
MDELStatus = namedtuple('MDELStatus', ['code', 'slug', 'label']) |
|
49 | ||
50 |
MDEL_STATUSES = map(lambda t: MDELStatus(*t), [ |
|
51 |
('100', 'closed', _('closed')), |
|
52 |
('20', 'rejected', _('rejected')), |
|
53 |
('19', 'accepted', _('accepted')), |
|
54 |
('17', 'information needed', _('information needed')), |
|
55 |
('16', 'in progress', _('in progress')), |
|
56 |
('15', 'invalid', _('invalid')), |
|
57 |
('14', 'imported', _('imported')), |
|
58 |
]) |
|
59 | ||
60 |
MDEL_STATUSES_BY_CODE = {mdel_status.code: mdel_status for mdel_status in MDEL_STATUSES} |
|
61 | ||
62 | ||
63 |
class Resource(BaseResource): |
|
64 |
outcoming_sftp = sftp.SFTPField( |
|
65 |
verbose_name=_('Outcoming SFTP'), |
|
66 |
blank=True, |
|
67 |
) |
|
68 |
incoming_sftp = sftp.SFTPField( |
|
69 |
verbose_name=_('Incoming SFTP'), |
|
70 |
blank=True, |
|
71 |
) |
|
72 |
recipient_siret = models.CharField( |
|
73 |
verbose_name=_('SIRET'), |
|
74 |
max_length=128) |
|
75 |
recipient_service = models.CharField( |
|
76 |
verbose_name=_('Service'), |
|
77 |
max_length=128) |
|
78 |
recipient_guichet = models.CharField( |
|
79 |
verbose_name=_('Guichet'), |
|
80 |
max_length=128) |
|
81 |
code_insee = models.CharField( |
|
82 |
verbose_name=_('INSEE Code'), |
|
83 |
max_length=6) |
|
84 | ||
85 |
xsd_path = 'schema.xsd' |
|
86 |
xsd_root_element = None |
|
87 |
flow_type = 'flow_type CHANGEME' |
|
88 |
doc_type = 'doc_type CHANGEME' |
|
89 |
zip_manifest = 'mdel/zip/manifest.json' |
|
90 |
code_insee_id = 'CODE_INSEE' |
|
91 | ||
92 |
class Meta: |
|
93 |
abstract = True |
|
94 | ||
95 |
def check_status(self): |
|
96 |
if self.outcoming_sftp: |
|
97 |
with self.outcoming_sftp.client() as out_sftp: |
|
98 |
out_sftp.listdir() |
|
99 |
if self.incoming_sftp: |
|
100 |
with self.incoming_sftp.client() as in_sftp: |
|
101 |
in_sftp.listdir() |
|
102 | ||
103 |
@classmethod |
|
104 |
def get_doc_xml_schema(cls): |
|
105 |
base_dir = os.path.dirname(inspect.getfile(cls)) |
|
106 |
path = os.path.join(base_dir, cls.xsd_path) |
|
107 |
assert os.path.exists(path) |
|
108 |
return xmlschema.XMLSchema(path, converter=xmlschema.UnorderedConverter) |
|
109 | ||
110 |
@classmethod |
|
111 |
def get_doc_json_schema(cls): |
|
112 |
return xml.JSONSchemaFromXMLSchema(cls.get_doc_xml_schema(), cls.xsd_root_element).json_schema |
|
113 | ||
114 |
@classmethod |
|
115 |
def get_flattened_schema(cls): |
|
116 |
return json.flatten_json_schema(cls.get_doc_json_schema()) |
|
117 | ||
118 |
@classmethod |
|
119 |
def render_schema(cls): |
|
120 |
schema = cls.get_flattened_schema() |
|
121 |
table = html.format_html('''\ |
|
122 |
<table> |
|
123 |
<thead><tr><td>{0}</td><td>{1}</td></tr> |
|
124 |
<tbody> |
|
125 |
''', _('Label'), _('Type')) |
|
126 |
for key in sorted(schema['properties']): |
|
127 |
key_schema = schema['properties'][key] |
|
128 |
if 'type' in key_schema: |
|
129 |
_type = key_schema['type'] |
|
130 |
elif 'oneOf' in key_schema: |
|
131 |
_type = ' | '.join(x['type'] for x in key_schema['oneOf']) |
|
132 |
else: |
|
133 |
raise NotImplementedError |
|
134 |
table += html.format_html('''\ |
|
135 |
<tr><td><tt>{0}</tt></td><td><tt>{1}</tt></td></tr> |
|
136 |
''', key, _type) |
|
137 |
table += html.format_html('</tbody></table>') |
|
138 |
return table |
|
139 | ||
140 |
@classmethod |
|
141 |
def get_create_schema(cls): |
|
142 |
base_schema = cls.get_doc_json_schema() |
|
143 |
base_schema['properties'].update({ |
|
144 |
'display_id': {'type': 'string'}, |
|
145 |
'email': {'type': 'string'}, |
|
146 |
'code_insee': {'type': 'string'}, |
|
147 |
}) |
|
148 |
base_schema.setdefault('required', []).append('display_id') |
|
149 |
return base_schema |
|
150 | ||
151 |
def _handle_create(self, request): |
|
152 |
try: |
|
153 |
raw_payload = json.loads(request.body) |
|
154 |
except (TypeError, ValueError): |
|
155 |
raise APIError('Invalid payload format: JSON expected') |
|
156 |
payload = json.unflatten(raw_payload) |
|
157 |
extra = payload.pop('extra', {}) |
|
158 |
# w.c.s. pass non form fields in extra |
|
159 |
if isinstance(extra, dict): |
|
160 |
payload.update(extra) |
|
161 |
try: |
|
162 |
json.validate(payload, self.get_create_schema()) |
|
163 |
except json.ValidationError as e: |
|
164 |
raise APIError('Invalid payload format: %s' % e) |
|
165 | ||
166 |
reference = 'A-' + payload['display_id'] |
|
167 |
try: |
|
168 |
demand = self.demand_set.create( |
|
169 |
reference=reference, |
|
170 |
step=1, |
|
171 |
data=payload) |
|
172 |
except IntegrityError as e: |
|
173 |
return APIError('reference-non-unique', http_status=400, |
|
174 |
data={'original_exc': exception_to_text(e)}) |
|
175 |
self.add_job('push_demand', demand_id=demand.id) |
|
176 |
return self.status(request, demand) |
|
177 | ||
178 |
def push_demand(self, demand_id): |
|
179 |
demand = self.demand_set.get(id=demand_id) |
|
180 |
if not demand.push(): |
|
181 |
raise SkipJob(after_timestamp=3600 * 6) |
|
182 | ||
183 |
@endpoint(perm='can_access', |
|
184 |
methods=['get'], |
|
185 |
description=_('Demand status'), |
|
186 |
pattern=r'(?P<demand_id>\d+)/$') |
|
187 |
def demand(self, request, demand_id): |
|
188 |
try: |
|
189 |
demand = self.demand_set.get(id=demand_id) |
|
190 |
except self.demand_set.model.DoesNotExist: |
|
191 |
raise APIError('demand not found', http_status=404) |
|
192 |
return self.status(request, demand) |
|
193 | ||
194 |
def status(self, request, demand): |
|
195 |
return { |
|
196 |
'id': demand.id, |
|
197 |
'status': demand.status, |
|
198 |
'url': request.build_absolute_uri(demand.status_url), |
|
199 |
'zip_url': request.build_absolute_uri(demand.zip_url), |
|
200 |
} |
|
201 | ||
202 |
@endpoint(perm='can_access', |
|
203 |
methods=['get'], |
|
204 |
description=_('Demand document'), |
|
205 |
pattern=r'(?P<demand_id>\d+)/.*$') |
|
206 |
def document(self, request, demand_id): |
|
207 |
try: |
|
208 |
demand = self.demand_set.get(id=demand_id) |
|
209 |
except self.demand_set.model.DoesNotExist: |
|
210 |
raise APIError('demand not found', http_status=404) |
|
211 |
response = HttpResponse(demand.zip_content, content_type='application/octet-stream') |
|
212 |
response['Content-Disposition'] = 'inline; filename=%s' % demand.zip_name |
|
213 |
return response |
|
214 | ||
215 |
@property |
|
216 |
def response_re(self): |
|
217 |
return re.compile( |
|
218 |
r'(?P<reference>[^-]+-[^-]+-[^-]+)-%s-' |
|
219 |
r'(?P<step>\d+).zip' % self.flow_type) |
|
220 | ||
221 |
def hourly(self): |
|
222 |
'''Get responses''' |
|
223 |
if not self.incoming_sftp: |
|
224 |
return |
|
225 |
try: |
|
226 |
with self.incoming_sftp.client() as client: |
|
227 |
for name in client.listdir(): |
|
228 |
m = self.response_re.match(name) |
|
229 |
if not m: |
|
230 |
self.logger.warning( |
|
231 |
'pull responses: unexpected file "%s" in sftp, file name does not match pattern %s', |
|
232 |
name, self.response_re) |
|
233 |
continue |
|
234 |
reference = m.groupdict()['reference'] |
|
235 |
step = int(m.groupdict()['step']) |
|
236 |
demand = self.demand_set.filter(reference=reference).first() |
|
237 |
if not demand: |
|
238 |
self.logger.error( |
|
239 |
'pull responses: unexpected file "%s" in sftp, no demand for reference "%s"', |
|
240 |
name, |
|
241 |
reference) |
|
242 |
continue |
|
243 |
if step < demand.step: |
|
244 |
demand.logger.error( |
|
245 |
'pull responses: unexpected file "%s" in sftp: step %s is inferior to demand step %s', |
|
246 |
name, |
|
247 |
step, |
|
248 |
demand.step) |
|
249 |
continue |
|
250 |
demand.handle_response(sftp_client=client, filename=name, step=step) |
|
251 |
except sftp.paramiko.SSHException as e: |
|
252 |
self.logger.error('pull responses: sftp error %s', e) |
|
253 |
return |
|
254 | ||
255 | ||
256 |
@six.python_2_unicode_compatible |
|
257 |
class Demand(models.Model): |
|
258 |
STATUS_PENDING = 'pending' |
|
259 |
STATUS_PUSHED = 'pushed' |
|
260 |
STATUS_ERROR = 'error' |
|
261 | ||
262 |
STATUSES = [ |
|
263 |
(STATUS_PENDING, _('pending')), |
|
264 |
(STATUS_PUSHED, _('pushed')), |
|
265 |
(STATUS_ERROR, _('error')), |
|
266 |
] |
|
267 |
for mdel_status in MDEL_STATUSES: |
|
268 |
STATUSES.append((mdel_status.slug, mdel_status.label)) |
|
269 | ||
270 |
created_at = models.DateTimeField(auto_now_add=True) |
|
271 |
updated_at = models.DateTimeField(auto_now=True) |
|
272 |
reference = models.CharField(max_length=32, null=False, unique=True) |
|
273 |
status = models.CharField(max_length=32, null=True, choices=STATUSES, default=STATUS_PENDING) |
|
274 |
step = models.IntegerField(default=0) |
|
275 |
data = jsonfield.JSONField() |
|
276 | ||
277 |
@functional.cached_property |
|
278 |
def logger(self): |
|
279 |
return self.resource.logger.context( |
|
280 |
demand_id=self.id, |
|
281 |
demand_status=self.status, |
|
282 |
demand_reference=self.reference) |
|
283 | ||
284 |
def push(self): |
|
285 |
if not self.resource.outcoming_sftp: |
|
286 |
return False |
|
287 |
try: |
|
288 |
with self.resource.outcoming_sftp.client() as client: |
|
289 |
with client.open(self.zip_name, mode='w') as fd: |
|
290 |
fd.write(self.zip_content) |
|
291 |
except sftp.paramiko.SSHException as e: |
|
292 |
self.logger.error('push demand: %s failed, "%s"', |
|
293 |
self, |
|
294 |
exception_to_text(e)) |
|
295 |
self.status = self.STATUS_ERROR |
|
296 |
except Exception as e: |
|
297 |
self.logger.exception('push demand: %s failed, "%s"', |
|
298 |
self, |
|
299 |
exception_to_text(e)) |
|
300 |
self.status = self.STATUS_ERROR |
|
301 |
else: |
|
302 |
self.resource.logger.info('push demand: %s success', self) |
|
303 |
self.status = self.STATUS_PUSHED |
|
304 |
self.save() |
|
305 |
return True |
|
306 | ||
307 |
@functional.cached_property |
|
308 |
def zip_template(self): |
|
309 |
return ZipTemplate(self.resource.zip_manifest, ctx={ |
|
310 |
'reference': self.reference, |
|
311 |
'flow_type': self.resource.flow_type, |
|
312 |
'doc_type': self.resource.doc_type, |
|
313 |
'step': '1', # We never create more than one document for a reference |
|
314 |
'siret': self.resource.recipient_siret, |
|
315 |
'service': self.resource.recipient_service, |
|
316 |
'guichet': self.resource.recipient_guichet, |
|
317 |
'code_insee': self.data.get('code_insee', self.resource.code_insee), |
|
318 |
'document': self.document, |
|
319 |
'code_insee_id': self.resource.code_insee_id, |
|
320 |
'date': self.created_at.isoformat(), |
|
321 |
'email': self.data.get('email', ''), |
|
322 |
}) |
|
323 | ||
324 |
@property |
|
325 |
def zip_name(self): |
|
326 |
return self.zip_template.name |
|
327 | ||
328 |
@property |
|
329 |
def zip_content(self): |
|
330 |
return self.zip_template.render_to_bytes() |
|
331 | ||
332 |
@property |
|
333 |
def document(self): |
|
334 |
xml_schema = self.resource.get_doc_xml_schema() |
|
335 |
return ET.tostring( |
|
336 |
xml_schema.elements[self.resource.xsd_root_element].encode( |
|
337 |
self.data[self.resource.xsd_root_element])) |
|
338 | ||
339 |
@property |
|
340 |
def status_url(self): |
|
341 |
return reverse( |
|
342 |
'generic-endpoint', |
|
343 |
kwargs={ |
|
344 |
'connector': self.resource.get_connector_slug(), |
|
345 |
'slug': self.resource.slug, |
|
346 |
'endpoint': 'demand', |
|
347 |
'rest': '%s/' % self.id, |
|
348 |
}) |
|
349 | ||
350 |
@property |
|
351 |
def zip_url(self): |
|
352 |
return reverse( |
|
353 |
'generic-endpoint', |
|
354 |
kwargs={ |
|
355 |
'connector': self.resource.get_connector_slug(), |
|
356 |
'slug': self.resource.slug, |
|
357 |
'endpoint': 'document', |
|
358 |
'rest': '%s/%s' % (self.id, self.zip_name) |
|
359 |
}) |
|
360 | ||
361 |
def handle_response(self, sftp_client, filename, step): |
|
362 |
try: |
|
363 |
with sftp_client.open(filename) as fd: |
|
364 |
with zipfile.ZipFile(fd) as zip_file: |
|
365 |
with zip_file.open('message.xml') as fd: |
|
366 |
tree = ET.parse(fd) |
|
367 |
ns = 'http://finances.gouv.fr/dgme/pec/message/v1' |
|
368 |
etat_node = tree.find('.//{%s}Etat' % ns) |
|
369 |
if etat_node is None: |
|
370 |
self.logger.error( |
|
371 |
'pull responses: missing Etat node in "%s"', |
|
372 |
filename) |
|
373 |
return |
|
374 |
etat = etat_node.text |
|
375 |
if etat in MDEL_STATUSES_BY_CODE: |
|
376 |
self.status = MDEL_STATUSES_BY_CODE[etat].slug |
|
377 |
else: |
|
378 |
self.logger.error( |
|
379 |
'pull responses: unknown etat in "%s", etat="%s"', |
|
380 |
filename, |
|
381 |
etat) |
|
382 |
return |
|
383 |
commentaire_node = tree.find('.//{%s}Etat' % ns) |
|
384 |
if commentaire_node is not None: |
|
385 |
commentaire = commentaire_node.text |
|
386 |
self.data = self.data or {} |
|
387 |
self.data.setdefault('commentaires', []).append(commentaire) |
|
388 |
self.data['commentaire'] = commentaire |
|
389 |
self.step = step + 1 |
|
390 |
self.save() |
|
391 |
self.logger.info('pull responses: status of demand %s changed to %s', |
|
392 |
self, self.status) |
|
393 |
except sftp.paramiko.SSHException as e: |
|
394 |
self.logger.error( |
|
395 |
'pull responses: failed to read response "%s", %s', |
|
396 |
filename, |
|
397 |
exception_to_text(e)) |
|
398 |
else: |
|
399 |
try: |
|
400 |
sftp_client.remove(filename) |
|
401 |
except sftp.paramiko.SSHException as e: |
|
402 |
self.logger.error( |
|
403 |
'pull responses: failed to remove response "%s", %s', |
|
404 |
filename, |
|
405 |
exception_to_text(e)) |
|
406 | ||
407 |
def __str__(self): |
|
408 |
return '<Demand %s reference:%s flow_type:%s>' % ( |
|
409 |
self.id, |
|
410 |
self.reference, |
|
411 |
self.resource.flow_type) |
|
412 | ||
413 |
class Meta: |
|
414 |
abstract = True |
passerelle/apps/mdel_ddpacs/migrations/0001_initial.py | ||
---|---|---|
1 |
# -*- coding: utf-8 -*- |
|
2 |
# Generated by Django 1.11.20 on 2019-10-24 08:59 |
|
3 |
from __future__ import unicode_literals |
|
4 | ||
5 |
from django.db import migrations, models |
|
6 |
import django.db.models.deletion |
|
7 |
import jsonfield.fields |
|
8 |
import passerelle.utils.sftp |
|
9 | ||
10 | ||
11 |
class Migration(migrations.Migration): |
|
12 | ||
13 |
initial = True |
|
14 | ||
15 |
dependencies = [ |
|
16 |
('base', '0015_auto_20190921_0347'), |
|
17 |
] |
|
18 | ||
19 |
operations = [ |
|
20 |
migrations.CreateModel( |
|
21 |
name='Demand', |
|
22 |
fields=[ |
|
23 |
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), |
|
24 |
('created_at', models.DateTimeField(auto_now_add=True)), |
|
25 |
('updated_at', models.DateTimeField(auto_now=True)), |
|
26 |
('reference', models.CharField(max_length=32, unique=True)), |
|
27 |
('status', models.CharField(choices=[('pending', 'pending'), ('pushed', 'pushed'), ('error', 'error'), ('closed', 'closed'), ('rejected', 'rejected'), ('accepted', 'accepted'), ('information needed', 'information needed'), ('in progress', 'in progress'), ('invalid', 'invalid'), ('imported', 'imported')], default='pending', max_length=32, null=True)), |
|
28 |
('step', models.IntegerField(default=0)), |
|
29 |
('data', jsonfield.fields.JSONField(default=dict)), |
|
30 |
], |
|
31 |
options={ |
|
32 |
'verbose_name': 'MDEL compatible DDPACS request', |
|
33 |
}, |
|
34 |
), |
|
35 |
migrations.CreateModel( |
|
36 |
name='Resource', |
|
37 |
fields=[ |
|
38 |
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), |
|
39 |
('title', models.CharField(max_length=50, verbose_name='Title')), |
|
40 |
('description', models.TextField(verbose_name='Description')), |
|
41 |
('slug', models.SlugField(unique=True, verbose_name='Identifier')), |
|
42 |
('outcoming_sftp', passerelle.utils.sftp.SFTPField(blank=True, default=None, verbose_name='Outcoming SFTP')), |
|
43 |
('incoming_sftp', passerelle.utils.sftp.SFTPField(blank=True, default=None, verbose_name='Incoming SFTP')), |
|
44 |
('recipient_siret', models.CharField(max_length=128, verbose_name='SIRET')), |
|
45 |
('recipient_service', models.CharField(max_length=128, verbose_name='Service')), |
|
46 |
('recipient_guichet', models.CharField(max_length=128, verbose_name='Guichet')), |
|
47 |
('code_insee', models.CharField(max_length=6, verbose_name='INSEE Code')), |
|
48 |
('users', models.ManyToManyField(blank=True, related_name='_resource_users_+', related_query_name='+', to='base.ApiUser')), |
|
49 |
], |
|
50 |
options={ |
|
51 |
'verbose_name': 'MDEL compatible DDPACS request builder', |
|
52 |
}, |
|
53 |
), |
|
54 |
migrations.AddField( |
|
55 |
model_name='demand', |
|
56 |
name='resource', |
|
57 |
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='mdel_ddpacs.Resource'), |
|
58 |
), |
|
59 |
] |
passerelle/apps/mdel_ddpacs/models.py | ||
---|---|---|
1 |
# coding: utf-8 |
|
2 |
# Passerelle - uniform access to data and services |
|
3 |
# Copyright (C) 2019 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 |
from __future__ import unicode_literals |
|
19 | ||
20 |
import json |
|
21 | ||
22 |
from django.db import models |
|
23 |
from django.utils.translation import ugettext_lazy as _ |
|
24 |
from django.utils.html import format_html |
|
25 | ||
26 |
from passerelle.utils.api import endpoint |
|
27 | ||
28 |
from . import abstract |
|
29 | ||
30 | ||
31 |
class Resource(abstract.Resource): |
|
32 |
category = _('Civil Status Connectors') |
|
33 |
xsd_root_element = 'PACS' |
|
34 |
flow_type = 'depotDossierPACS' |
|
35 |
doc_type = 'flux-pacs' |
|
36 | ||
37 |
class Meta: |
|
38 |
verbose_name = _('MDEL compatible DDPACS request builder') |
|
39 | ||
40 |
@endpoint(perm='can_access', |
|
41 |
methods=['post'], |
|
42 |
description=_('Create request'), |
|
43 |
post={ |
|
44 |
'long_description': 'coin' |
|
45 |
}) |
|
46 |
def create(self, request): |
|
47 |
return self._handle_create(request) |
|
48 | ||
49 |
Resource.create.endpoint_info.post['long_description'] = \ |
|
50 |
format_html('<p>Body schema</p>{0}', Resource.render_schema()) |
|
51 | ||
52 | ||
53 |
class Demand(abstract.Demand): |
|
54 |
resource = models.ForeignKey(Resource) |
|
55 | ||
56 |
class Meta: |
|
57 |
verbose_name = _('MDEL compatible DDPACS request') |
passerelle/apps/mdel_ddpacs/schema.xsd | ||
---|---|---|
1 |
<?xml version="1.0" encoding="UTF-8"?> |
|
2 |
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> |
|
3 |
<xs:element name="PACS" type="PacsType"/> |
|
4 |
<xs:complexType name="PacsType"> |
|
5 |
<xs:sequence> |
|
6 |
<xs:element name="partenaire1" type="PartenaireType" /> |
|
7 |
<xs:element name="partenaire2" type="PartenaireType" /> |
|
8 |
<xs:element name="convention" type="ConventionType" maxOccurs="1" minOccurs="1" /> |
|
9 |
<xs:element name="residenceCommune" type="AdresseType" /> |
|
10 |
<xs:element name="attestationHonneur" type="AttestationHonneurType" /> |
|
11 |
</xs:sequence> |
|
12 |
</xs:complexType> |
|
13 |
<xs:complexType name = "AttestationHonneurType"> |
|
14 |
<xs:sequence> |
|
15 |
<xs:element name="nonParente" type="xs:boolean"/> |
|
16 |
<xs:element name="residenceCommune" type="xs:boolean"/> |
|
17 |
</xs:sequence> |
|
18 |
</xs:complexType> |
|
19 |
<xs:complexType name="PartenaireType"> |
|
20 |
<xs:sequence> |
|
21 |
<xs:element name="civilite" type="CiviliteType"></xs:element> |
|
22 |
<xs:element name="nomNaissance" type="xs:string" /> |
|
23 |
<xs:element name="prenoms" type="xs:string" /> |
|
24 |
<xs:element name="codeNationalite" type="xs:string" maxOccurs="unbounded"/> |
|
25 |
<xs:element name="jourNaissance" type="xs:integer" maxOccurs="1" minOccurs="0" /> |
|
26 |
<xs:element name="moisNaissance" type="xs:integer" maxOccurs="1" minOccurs="0" /> |
|
27 |
<xs:element name="anneeNaissance" type="xs:integer" /> |
|
28 |
<xs:element name="LieuNaissance" type="LieuNaissanceType" /> |
|
29 |
<xs:element name="ofpra" type="xs:boolean" /> |
|
30 |
<xs:element name="mesureJuridique" type="xs:boolean" /> |
|
31 |
<xs:element name="adressePostale" type="AdresseType" /> |
|
32 |
<xs:element name="adresseElectronique" type="xs:string" /> |
|
33 |
<xs:element name="telephone" type="xs:string" minOccurs="0"/> |
|
34 |
<xs:element name="filiationParent1" type="FiliationType" minOccurs="0"/> |
|
35 |
<xs:element name="filiationParent2" type="FiliationType" minOccurs="0" /> |
|
36 |
<xs:element name="titreIdentiteVerifie" type="xs:boolean"/> |
|
37 |
</xs:sequence> |
|
38 |
</xs:complexType> |
|
39 |
<xs:complexType name="ConventionType"> |
|
40 |
<xs:choice> |
|
41 |
<xs:element name="conventionType" type="ConventionTypeType" /> |
|
42 |
<xs:element name="conventionSpecifique" type="xs:boolean" /> |
|
43 |
</xs:choice> |
|
44 |
</xs:complexType> |
|
45 |
<xs:complexType name="ConventionTypeType"> |
|
46 |
<xs:sequence> |
|
47 |
<xs:element name="aideMaterielMontant" type="xs:double" maxOccurs="1" minOccurs="0"/> |
|
48 |
<xs:element name="regimePacs" type="regimePacsType" /> |
|
49 |
<xs:element name="aideMateriel" type="AideMaterielType" /> |
|
50 |
</xs:sequence> |
|
51 |
</xs:complexType> |
|
52 |
<xs:complexType name="AdresseType"> |
|
53 |
<xs:sequence> |
|
54 |
<xs:element name="NumeroLibelleVoie" type="xs:string" minOccurs="0" /> |
|
55 |
<xs:element name="Complement1" type="xs:string" minOccurs="0" /> |
|
56 |
<xs:element name="Complement2" type="xs:string" minOccurs="0" /> |
|
57 |
<xs:element name="LieuDitBpCommuneDeleguee" type="xs:string" minOccurs="0" /> |
|
58 |
<xs:element name="CodePostal" type="codePostalType" /> |
|
59 |
<xs:element name="Localite" type="localiteType" /> |
|
60 |
<xs:element name="Pays" type="xs:string" /> |
|
61 |
</xs:sequence> |
|
62 |
</xs:complexType> |
|
63 |
<xs:complexType name="LieuNaissanceType"> |
|
64 |
<xs:sequence> |
|
65 |
<xs:element name="localite" type="localiteType"/> |
|
66 |
<xs:element name="codePostal" type="xs:string"/> |
|
67 |
<xs:element name="codeInsee" type="xs:string" minOccurs="0"/> |
|
68 |
<xs:element name="departement" type="xs:string" maxOccurs="1" minOccurs="0"/> |
|
69 |
<xs:element name="codePays" type="xs:string"/> |
|
70 |
</xs:sequence> |
|
71 |
</xs:complexType> |
|
72 |
<xs:simpleType name="localiteType"> |
|
73 |
<xs:restriction base="xs:string"> |
|
74 |
<xs:minLength value="1" /> |
|
75 |
</xs:restriction> |
|
76 |
</xs:simpleType> |
|
77 |
<xs:simpleType name="codePostalType"> |
|
78 |
<xs:restriction base="xs:string"> |
|
79 |
<xs:length value="5" /> |
|
80 |
</xs:restriction> |
|
81 |
</xs:simpleType> |
|
82 |
<xs:simpleType name="regimePacsType"> |
|
83 |
<xs:restriction base="xs:string"> |
|
84 |
<xs:enumeration value="indivision"/> |
|
85 |
<xs:enumeration value="legal"/> |
|
86 |
</xs:restriction> |
|
87 |
</xs:simpleType> |
|
88 |
<xs:complexType name="FiliationType"> |
|
89 |
<xs:sequence> |
|
90 |
<xs:choice> |
|
91 |
<xs:element name="filiationInconnu" type="xs:boolean"></xs:element> |
|
92 |
<xs:element name="filiationConnu" type="FiliationConnuType"> |
|
93 |
</xs:element> |
|
94 |
</xs:choice> |
|
95 |
</xs:sequence> |
|
96 |
</xs:complexType> |
|
97 |
<xs:simpleType name="CiviliteType"> |
|
98 |
<xs:restriction base="xs:string"> |
|
99 |
<xs:enumeration value="M"></xs:enumeration> |
|
100 |
<xs:enumeration value="MME"></xs:enumeration> |
|
101 |
</xs:restriction> |
|
102 |
</xs:simpleType> |
|
103 |
<xs:simpleType name="TypeAideMaterielType"> |
|
104 |
<xs:restriction base="xs:string"> |
|
105 |
<xs:enumeration value="aideFixe"/> |
|
106 |
<xs:enumeration value="aideProportionnel"/> |
|
107 |
</xs:restriction> |
|
108 |
</xs:simpleType> |
|
109 |
<xs:complexType name="AideMaterielType"> |
|
110 |
<xs:sequence> |
|
111 |
<xs:element name="typeAideMateriel" type="TypeAideMaterielType"></xs:element> |
|
112 |
</xs:sequence> |
|
113 |
</xs:complexType> |
|
114 |
<xs:complexType name="FiliationConnuType"> |
|
115 |
<xs:sequence> |
|
116 |
<xs:element name="sexe" type="SexeType"/> |
|
117 |
<xs:element name="nomNaissance" type="xs:string" maxOccurs="1" minOccurs="0" /> |
|
118 |
<xs:element name="prenoms" type="xs:string" maxOccurs="1" minOccurs="0" /> |
|
119 |
<xs:element name="dateNaissance" type="xs:string" maxOccurs="1" minOccurs="0" /> |
|
120 |
<xs:element name="lieuNaissance" type="LieuNaissanceType" maxOccurs="1" minOccurs="0" /> |
|
121 |
</xs:sequence> |
|
122 |
</xs:complexType> |
|
123 |
<xs:simpleType name="SexeType"> |
|
124 |
<xs:restriction base="xs:string"> |
|
125 |
<xs:enumeration value="M"/> |
|
126 |
<xs:enumeration value="F"/> |
|
127 |
</xs:restriction> |
|
128 |
</xs:simpleType> |
|
129 |
</xs:schema> |
passerelle/apps/mdel_ddpacs/templates/mdel/zip/doc.xml | ||
---|---|---|
1 |
<?xml version="1.0" encoding="UTF-8" ?> |
|
2 |
<PACS xmlns:xs="http://www.w3.org/2001/XMLSchema" > |
|
3 |
<partenaire1> |
|
4 |
<civilite>{{ partenaire1.civilite }}</civilite> |
|
5 |
<nomNaissance>{{ partenaire1.nom_naissance }}</nomNaissance> |
|
6 |
<prenoms>{{ partenaire1.prenoms }}</prenoms> |
|
7 |
{% for code_nationalite in partenaire1.code_nationalite %} |
|
8 |
<codeNationalite>{{ code_nationalite }}</codeNationalite> |
|
9 |
{% endfor %} |
|
10 |
<jourNaissance>{{ partenaire1.jour_naissance }}</jourNaissance> |
|
11 |
<moisNaissance>{{ partenaire1.mois_naissance }}</moisNaissance> |
|
12 |
<anneeNaissance>{{ partenaire1.annee_naissance }}</anneeNaissance> |
|
13 |
<LieuNaissance> |
|
14 |
<localite>{{ partenaire1.localite_naissance }}</localite> |
|
15 |
<codePostal>{{ partenaire1.codepostal_naissance }}</codePostal> |
|
16 |
<codeInsee>{{ partenaire1.codeinsee_naissance }}</codeInsee> |
|
17 |
<departement>{{ partenaire1.departement_naissance }}</departement> |
|
18 |
<codePays>{{ partenaire1.codepays_naissance }}</codePays> |
|
19 |
</LieuNaissance> |
|
20 |
<ofpra>{{ partenaire1.ofpra|yesno:"true,false" }}</ofpra> |
|
21 |
<mesureJuridique>{{ partenaire1.mesure_juridique }}</mesureJuridique> |
|
22 |
<adressePostale> |
|
23 |
<NumeroLibelleVoie>{{ partenaire1.adresse_numero_voie }}</NumeroLibelleVoie> |
|
24 |
<Complement1>{{ partenaire1.adresse_complement1 }}</Complement1> |
|
25 |
<Complement2>{{ partenaire1.adresse_complement2 }}</Complement2> |
|
26 |
<LieuDitBpCommuneDeleguee>{{ partenaire1.adresse_lieuditbpcommunedeleguee }}</LieuDitBpCommuneDeleguee> |
|
27 |
<CodePostal>{{ partenaire1.adresse_codepostal }}</CodePostal> |
|
28 |
<Localite>{{ partenaire1.adresse_localite }}</Localite> |
|
29 |
<Pays>{{ partenaire1.adresse_pays }}</Pays> |
|
30 |
</adressePostale> |
|
31 |
<adresseElectronique>{{ partenaire1.email }}</adresseElectronique> |
|
32 |
<telephone>{{ partenaire1.telephone }}</telephone> |
|
33 |
<titreIdentiteVerifie>{{ partenaire1.yesno:"true,false" }}</titreIdentiteVerifie> |
|
34 |
</partenaire1> |
|
35 |
<partenaire2> |
|
36 |
<civilite>{{ partenaire2.civilite }}</civilite> |
|
37 |
<nomNaissance>{{ partenaire2.nom_naissance }}</nomNaissance> |
|
38 |
<prenoms>{{ partenaire2.prenoms }}</prenoms> |
|
39 |
{% for code_nationalite in partenaire2.code_nationalite %} |
|
40 |
<codeNationalite>{{ code_nationalite }}</codeNationalite> |
|
41 |
{% endfor %} |
|
42 |
<jourNaissance>{{ partenaire2.jour_naissance }}</jourNaissance> |
|
43 |
<moisNaissance>{{ partenaire2.mois_naissance }}</moisNaissance> |
|
44 |
<anneeNaissance>{{ partenaire2.annee_naissance }}</anneeNaissance> |
|
45 |
<LieuNaissance> |
|
46 |
<localite>{{ partenaire2.localite_naissance }}</localite> |
|
47 |
<codePostal>{{ partenaire2.codepostal_naissance }}</codePostal> |
|
48 |
<codeInsee>{{ partenaire2.codeinsee_naissance }}</codeInsee> |
|
49 |
<departement>{{ partenaire2.departement_naissance }}</departement> |
|
50 |
<codePays>{{ partenaire2.codepays_naissance }}</codePays> |
|
51 |
</LieuNaissance> |
|
52 |
<ofpra>{{ partenaire2.ofpra|yesno:"true,false" }}</ofpra> |
|
53 |
<mesureJuridique>{{ partenaire2.mesure_juridique }}</mesureJuridique> |
|
54 |
<adressePostale> |
|
55 |
<NumeroLibelleVoie>{{ partenaire2.adresse_numero_voie }}</NumeroLibelleVoie> |
|
56 |
<Complement1>{{ partenaire2.adresse_complement1 }}</Complement1> |
|
57 |
<Complement2>{{ partenaire2.adresse_complement2 }}</Complement2> |
|
58 |
<LieuDitBpCommuneDeleguee>{{ partenaire2.adresse_lieuditbpcommunedeleguee }}</LieuDitBpCommuneDeleguee> |
|
59 |
<CodePostal>{{ partenaire2.adresse_codepostal }}</CodePostal> |
|
60 |
<Localite>{{ partenaire2.adresse_localite }}</Localite> |
|
61 |
<Pays>{{ partenaire2.adresse_pays }}</Pays> |
|
62 |
</adressePostale> |
|
63 |
<adresseElectronique>{{ partenaire2.email }}</adresseElectronique> |
|
64 |
<telephone>{{ partenaire2.telephone }}</telephone> |
|
65 |
<titreIdentiteVerifie>{{ partenaire2.yesno:"true,false" }}</titreIdentiteVerifie> |
|
66 |
</partenaire2> |
|
67 |
<convention> |
|
68 |
<conventionType> |
|
69 |
<aideMaterielMontant>100000</aideMaterielMontant> |
|
70 |
<regimePacs>legal</regimePacs> |
|
71 |
<aideMateriel> |
|
72 |
<typeAideMateriel>aideFixe</typeAideMateriel> |
|
73 |
</aideMateriel> |
|
74 |
</conventionType> |
|
75 |
</convention> |
|
76 |
<residenceCommune> |
|
77 |
<NumeroLibelleVoie>3 place du test</NumeroLibelleVoie> |
|
78 |
<CodePostal>05100</CodePostal> |
|
79 |
<Localite>VILLAR ST PANCRACE</Localite> |
|
80 |
<Pays></Pays> |
|
81 |
</residenceCommune> |
|
82 |
<attestationHonneur> |
|
83 |
<nonParente>true</nonParente> |
|
84 |
<residenceCommune>true</residenceCommune> |
|
85 |
</attestationHonneur> |
|
86 |
|
|
87 |
</PACS> |
|
88 | ||
89 | ||
90 | ||
91 | ||
92 | ||
93 | ||
94 | ||
95 | ||
96 | ||
97 | ||
98 | ||
99 | ||
100 | ||
101 | ||
102 | ||
103 | ||
104 | ||
105 | ||
106 |
passerelle/apps/mdel_ddpacs/templates/mdel/zip/entete.xml | ||
---|---|---|
1 |
<?xml version="1.0" encoding="UTF-8" standalone="yes"?> |
|
2 |
<EnteteMetierEnveloppe xmlns="http://finances.gouv.fr/dgme/gf/composants/teledemarchexml/donnee/metier"> |
|
3 |
<NumeroDemarche>{{ flow_type }}</NumeroDemarche> |
|
4 |
<Teledemarche> |
|
5 |
<NumeroTeledemarche>{{ reference }}</NumeroTeledemarche> |
|
6 |
<!-- <MotDePasse></MotDePasse> --> |
|
7 |
<!-- <Suivi></Suivi> --> |
|
8 |
<Date>{{ date }}</Date> |
|
9 |
<IdentifiantPlateforme>Publik</IdentifiantPlateforme> |
|
10 |
<Email>{{ email }}</Email> |
|
11 |
</Teledemarche> |
|
12 |
<Routage> |
|
13 |
<Donnee> |
|
14 |
<Id>{{ code_insee_id }}</Id> |
|
15 |
<Valeur>{{ code_insee }}</Valeur> |
|
16 |
</Donnee> |
|
17 |
</Routage> |
|
18 |
<Document> |
|
19 |
<Code>{{ doc_type }}</Code> |
|
20 |
<Nom>{{ doc_type }}</Nom> |
|
21 |
<FichierFormulaire> |
|
22 |
<FichierDonnees>{{ reference }}-{{ flow_type }}-doc-{{ doc_type }}-1-1.xml</FichierDonnees> |
|
23 |
</FichierFormulaire> |
|
24 |
</Document> |
|
25 |
</EnteteMetierEnveloppe> |
passerelle/apps/mdel_ddpacs/templates/mdel/zip/manifest.json | ||
---|---|---|
1 |
{ |
|
2 |
"name_template": "{{ reference }}-{{ flow_type }}-{{ step }}.zip", |
|
3 |
"part_templates": [ |
|
4 |
{ |
|
5 |
"name_template": "message.xml", |
|
6 |
"template_path": "message.xml" |
|
7 |
}, |
|
8 |
{ |
|
9 |
"name_template": "{{ reference }}-{{ flow_type }}-doc-{{ doc_type }}-1-1.xml", |
|
10 |
"content_expression": "document" |
|
11 |
}, |
|
12 |
{ |
|
13 |
"name_template": "{{ reference }}-{{ flow_type }}-ent-1.xml", |
|
14 |
"template_path": "entete.xml" |
|
15 |
} |
|
16 |
] |
|
17 |
} |
passerelle/apps/mdel_ddpacs/templates/mdel/zip/message.xml | ||
---|---|---|
1 |
<?xml version="1.0" encoding="UTF-8" standalone="yes"?> |
|
2 |
<ns2:Message xmlns:ns2="http://finances.gouv.fr/dgme/pec/message/v1" xmlns="http://finances.gouv.fr/dgme/gf/composants/teledemarchexml/donnee/metier"> |
|
3 |
<ns2:Header> |
|
4 |
<ns2:Routing> |
|
5 |
<ns2:MessageId>{{ reference }} {{ step }}</ns2:MessageId> |
|
6 |
<ns2:FlowType>{{ flow_type }}</ns2:FlowType> |
|
7 |
<ns2:Sender> |
|
8 |
<ns2:Country>FR</ns2:Country> |
|
9 |
<ns2:Location xsi:type="ns2:Location_FR" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"> |
|
10 |
<ns2:Siret>13000210800012</ns2:Siret> |
|
11 |
<ns2:Service>flux_GS_PEC_AVL</ns2:Service> |
|
12 |
<ns2:Guichet></ns2:Guichet> |
|
13 |
</ns2:Location> |
|
14 |
</ns2:Sender> |
|
15 |
<ns2:Recipients> |
|
16 |
<ns2:Recipient> |
|
17 |
<ns2:Country>FR</ns2:Country> |
|
18 |
<ns2:Location xsi:type="ns2:Location_FR" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"> |
|
19 |
<ns2:Siret>{{ siret }}</ns2:Siret> |
|
20 |
<ns2:Service>{{ service }}</ns2:Service> |
|
21 |
<ns2:Guichet>{{ guichet }}</ns2:Guichet> |
|
22 |
</ns2:Location> |
|
23 |
</ns2:Recipient> |
|
24 |
</ns2:Recipients> |
|
25 |
<ns2:AckRequired>true</ns2:AckRequired> |
|
26 |
<ns2:AckType>AVL</ns2:AckType> |
|
27 |
<ns2:AckType>ANL</ns2:AckType> |
|
28 |
<ns2:AckTo> |
|
29 |
<ns2:Country>FR</ns2:Country> |
|
30 |
<ns2:Location xsi:type="ns2:Location_FR" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"> |
|
31 |
<ns2:Siret>13000210800012</ns2:Siret> |
|
32 |
<ns2:Service>flux_GS_PEC_AVL</ns2:Service> |
|
33 |
<ns2:Guichet></ns2:Guichet> |
|
34 |
</ns2:Location> |
|
35 |
</ns2:AckTo> |
|
36 |
</ns2:Routing> |
|
37 |
</ns2:Header> |
|
38 |
<ns2:Body> |
|
39 |
<ns2:Content> |
|
40 |
<ns2:Aller> |
|
41 |
<NumeroDemarche>{{ flow_type }}</NumeroDemarche> |
|
42 |
<Teledemarche> |
|
43 |
<NumeroTeledemarche>{{ reference }}</NumeroTeledemarche> |
|
44 |
<!-- <MotDePasse></MotDePasse> --> |
|
45 |
<!-- <Suivi></Suivi> --> |
|
46 |
<Date>{{ date }}</Date> |
|
47 |
<IdentifiantPlateforme>Publik</IdentifiantPlateforme> |
|
48 |
<Email>{{ email }}</Email> |
|
49 |
</Teledemarche> |
|
50 |
<Routage> |
|
51 |
<Donnee> |
|
52 |
<Id>{{ code_insee_id }}</Id> |
|
53 |
<Valeur>{{ code_insee }}</Valeur> |
|
54 |
</Donnee> |
|
55 |
</Routage> |
|
56 |
<Document> |
|
57 |
<Code>{{ doc_type }}</Code> |
|
58 |
<Nom>{{ doc_type }}</Nom> |
|
59 |
<FichierFormulaire> |
|
60 |
<FichierDonnees>{{ reference }}-{{ flow_type }}-doc-{{ doc_type }}-1-1.xml</FichierDonnees> |
|
61 |
</FichierFormulaire> |
|
62 |
</Document> |
|
63 |
</ns2:Aller> |
|
64 |
</ns2:Content> |
|
65 |
</ns2:Body> |
|
66 |
</ns2:Message> |
passerelle/apps/mdel_ddpacs/utils.py | ||
---|---|---|
1 |
# Passerelle - uniform access to data and services |
|
2 |
# Copyright (C) 2016 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 os |
|
18 |
import zipfile |
|
19 |
from xml.etree import ElementTree as etree |
|
20 | ||
21 |
from django.utils.dateparse import parse_date as django_parse_date |
|
22 | ||
23 |
from passerelle.utils.jsonresponse import APIError |
|
24 | ||
25 |
def parse_date(date): |
|
26 |
try: |
|
27 |
parsed_date = django_parse_date(date) |
|
28 |
except ValueError as e: |
|
29 |
raise APIError('Invalid date: %r (%r)' % ( date, e)) |
|
30 |
if not parsed_date: |
|
31 |
raise APIError('date %r not iso-formated' % date) |
|
32 |
return parsed_date.isoformat() |
|
33 | ||
34 | ||
35 |
class ElementFactory(etree.Element): |
|
36 | ||
37 |
def __init__(self, *args, **kwargs): |
|
38 |
self.text = kwargs.pop('text', None) |
|
39 |
namespace = kwargs.pop('namespace', None) |
|
40 |
if namespace: |
|
41 |
super(ElementFactory, self).__init__( |
|
42 |
etree.QName(namespace, args[0]), **kwargs |
|
43 |
) |
|
44 |
self.namespace = namespace |
|
45 |
else: |
|
46 |
super(ElementFactory, self).__init__(*args, **kwargs) |
|
47 | ||
48 |
def append(self, element, allow_new=True): |
|
49 | ||
50 |
if not allow_new: |
|
51 |
if isinstance(element.tag, etree.QName): |
|
52 |
found = self.find(element.tag.text) |
|
53 |
else: |
|
54 |
found = self.find(element.tag) |
|
55 | ||
56 |
if found is not None: |
|
57 |
return self |
|
58 | ||
59 |
super(ElementFactory, self).append(element) |
|
60 |
return self |
|
61 | ||
62 |
def extend(self, elements): |
|
63 |
super(ElementFactory, self).extend(elements) |
|
64 |
return self |
|
65 | ||
66 | ||
67 |
def zipdir(path): |
|
68 |
"""Zip directory |
|
69 |
""" |
|
70 |
archname = path + '.zip' |
|
71 |
with zipfile.ZipFile(archname, 'w', zipfile.ZIP_DEFLATED) as zipf: |
|
72 |
for root, dirs, files in os.walk(path): |
|
73 |
for f in files: |
|
74 |
fpath = os.path.join(root, f) |
|
75 |
zipf.write(fpath, os.path.basename(fpath)) |
|
76 |
return archname |
|
77 | ||
78 | ||
79 |
def get_file_content_from_zip(path, filename): |
|
80 |
"""Rreturn file content |
|
81 |
""" |
|
82 |
with zipfile.ZipFile(path, 'r') as zipf: |
|
83 |
return zipf.read(filename) |
passerelle/settings.py | ||
---|---|---|
140 | 140 |
'passerelle.apps.jsondatastore', |
141 | 141 |
'passerelle.apps.sp_fr', |
142 | 142 |
'passerelle.apps.mdel', |
143 |
'passerelle.apps.mdel_ddpacs', |
|
143 | 144 |
'passerelle.apps.mobyt', |
144 | 145 |
'passerelle.apps.okina', |
145 | 146 |
'passerelle.apps.opengis', |
passerelle/utils/zip.py | ||
---|---|---|
16 | 16 | |
17 | 17 |
from __future__ import unicode_literals, absolute_import |
18 | 18 | |
19 |
import difflib |
|
19 | 20 |
import io |
20 | 21 |
import os.path |
21 | 22 |
import json |
... | ... | |
239 | 240 |
full_path = os.path.join(str(path), self.name) |
240 | 241 |
with atomic_write(full_path, dir=tmp_dir) as fd: |
241 | 242 |
self.render_to_file(fd) |
243 | ||
244 | ||
245 |
def diff_zip(one, two): |
|
246 |
differences = [] |
|
247 | ||
248 |
def compute_diff(one, two, fd_one, fd_two): |
|
249 |
content_one = fd_one.read() |
|
250 |
content_two = fd_two.read() |
|
251 | ||
252 |
if content_one == content_two: |
|
253 |
return |
|
254 |
if one.endswith(('.xml', '.json', '.txt')): |
|
255 |
diff = list(difflib.ndiff(content_one.splitlines(), |
|
256 |
content_two.splitlines())) |
|
257 |
return ['File %s differs' % one] + diff |
|
258 |
return 'File %s differs' % one |
|
259 | ||
260 |
if not hasattr(one, 'read'): |
|
261 |
one = open(one) |
|
262 |
with one: |
|
263 |
if not hasattr(two, 'read'): |
|
264 |
two = open(two) |
|
265 |
with two: |
|
266 |
with zipfile.ZipFile(one) as one_zip: |
|
267 |
with zipfile.ZipFile(two) as two_zip: |
|
268 |
one_nl = set(one_zip.namelist()) |
|
269 |
two_nl = set(two_zip.namelist()) |
|
270 |
for name in one_nl - two_nl: |
|
271 |
differences.append('File %s only in %s' % (name, one)) |
|
272 |
for name in two_nl - one_nl: |
|
273 |
differences.append('File %s only in %s' % (name, two)) |
|
274 |
for name in one_nl & two_nl: |
|
275 |
with one_zip.open(name) as fd_one: |
|
276 |
with two_zip.open(name) as fd_two: |
|
277 |
difference = compute_diff(name, name, fd_one, fd_two) |
|
278 |
if difference: |
|
279 |
differences.append(difference) |
|
280 |
return differences |
tests/data/mdel_ddpacs/response_manifest.json | ||
---|---|---|
1 |
{ |
|
2 |
"name_template": "{{ reference }}-{{ flow_type }}-{{ step }}.zip", |
|
3 |
"part_templates": [ |
|
4 |
{ |
|
5 |
"name_template": "message.xml", |
|
6 |
"template_path": "response_message.xml" |
|
7 |
} |
|
8 |
] |
|
9 |
} |
tests/data/mdel_ddpacs/response_message.xml | ||
---|---|---|
1 |
<ns2:Message xmlns:ns2="http://finances.gouv.fr/dgme/pec/message/v1" xmlns="http://finances.gouv.fr/dgme/gf/composants/teledemarchexml/donnee/metier"> |
|
2 |
<ns2:Header> |
|
3 |
<ns2:Routing> |
|
4 |
<ns2:MessageId>{{ reference }} {{ step }}</ns2:MessageId> |
|
5 |
<ns2:RefToMessageId>{{ reference }} {{ old_step }}</ns2:RefToMessageId> |
|
6 |
<ns2:FlowType>{{ flow_type }}</ns2:FlowType> |
|
7 |
<ns2:Sender/> |
|
8 |
<ns2:Recipients> |
|
9 |
<ns2:Recipient/> |
|
10 |
</ns2:Recipients> |
|
11 |
</ns2:Routing> |
|
12 |
<ns2:Security> |
|
13 |
<ns2:Horodatage>false</ns2:Horodatage> |
|
14 |
</ns2:Security> |
|
15 |
</ns2:Header> |
|
16 |
<ns2:Body> |
|
17 |
<ns2:Content> |
|
18 |
<ns2:Retour> |
|
19 |
<ns2:Enveloppe> |
|
20 |
<ns2:NumeroTeledemarche>{{ reference }}</ns2:NumeroTeledemarche> |
|
21 |
</ns2:Enveloppe> |
|
22 |
<ns2:Instruction> |
|
23 |
<ns2:Maj> |
|
24 |
{% if etat %}<ns2:Etat>{{ etat }}</ns2:Etat>{% endif %} |
|
25 |
{% if commentaire %}<ns2:Commentaire>{{ commentaire }}</ns2:Commentaire>{% endif %} |
|
26 |
</ns2:Maj> |
|
27 |
</ns2:Instruction> |
|
28 |
</ns2:Retour> |
|
29 |
</ns2:Content> |
|
30 |
</ns2:Body> |
|
31 |
</ns2:Message> |
tests/test_mdel_ddpacs.py | ||
---|---|---|
1 |
# coding: utf-8 |
|
2 |
# Passerelle - uniform access to data and services |
|
3 |
# Copyright (C) 2019 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.deepcopy of the GNU Affero General Public License |
|
16 |
# along with this program. If not, see <http://www.gnu.org/licenses/>. |
|
17 | ||
18 |
from __future__ import unicode_literals |
|
19 | ||
20 |
import io |
|
21 |
import logging |
|
22 |
import os |
|
23 | ||
24 |
import pytest |
|
25 |
import utils |
|
26 | ||
27 |
from passerelle.apps.mdel_ddpacs.models import Resource, Demand |
|
28 | ||
29 |
from passerelle.utils import json, sftp |
|
30 |
from passerelle.utils.zip import diff_zip, ZipTemplate |
|
31 | ||
32 | ||
33 |
def build_response_zip(**kwargs): |
|
34 |
zip_template = ZipTemplate(os.path.abspath('tests/data/mdel_ddpacs/response_manifest.json'), ctx=kwargs) |
|
35 |
return zip_template.name, zip_template.render_to_bytes() |
|
36 | ||
37 | ||
38 |
@pytest.fixture(autouse=True) |
|
39 |
def resource(db): |
|
40 |
return utils.setup_access_rights(Resource.objects.create( |
|
41 |
slug='test', |
|
42 |
code_insee='66666', |
|
43 |
recipient_siret='999999', |
|
44 |
recipient_service='SERVICE', |
|
45 |
recipient_guichet='GUICHET')) |
|
46 | ||
47 | ||
48 |
@pytest.fixture |
|
49 |
def ddpacs_payload(): |
|
50 |
xmlschema = Resource.get_doc_xml_schema() |
|
51 |
return json.flatten({'PACS': xmlschema.to_dict('tests/data/pacs-doc.xml')}) |
|
52 | ||
53 | ||
54 |
def test_create_demand(app, resource, ddpacs_payload, freezer, sftpserver, caplog): |
|
55 |
# paramiko log socket errors when connection is closed :/ |
|
56 |
caplog.set_level(logging.CRITICAL, 'paramiko.transport') |
|
57 |
freezer.move_to('2019-01-01') |
|
58 | ||
59 |
# Push new demand |
|
60 |
payload = { |
|
61 |
'display_id': '1-1', |
|
62 |
} |
|
63 |
payload.update(ddpacs_payload) |
|
64 |
assert Demand.objects.count() == 0 |
|
65 |
assert resource.jobs_set().count() == 0 |
|
66 |
resp = app.post_json('/mdel-ddpacs/test/create?raise=1', params=payload) |
|
67 |
assert resp.json['err'] == 0 |
|
68 |
assert resp.json['status'] == 'pending' |
|
69 |
assert Demand.objects.count() == 1 |
|
70 |
assert resource.jobs_set().count() == 1 |
|
71 | ||
72 |
url = resp.json['url'] |
|
73 |
zip_url = resp.json['zip_url'] |
|
74 | ||
75 |
# Check demand status URL |
|
76 |
status = app.get(url) |
|
77 |
assert status.json['err'] == 0 |
|
78 |
assert status.json == resp.json |
|
79 | ||
80 |
# Check demand document URL |
|
81 |
zip_document = app.get(zip_url) |
|
82 |
with io.BytesIO(zip_document.body) as fd: |
|
83 |
differences = diff_zip('tests/data/mdel_ddpacs_expected.zip', fd) |
|
84 |
assert not differences, differences |
|
85 | ||
86 |
# Check job is skipped as no SFTP is configured |
|
87 |
assert resource.jobs_set().get().after_timestamp is None |
|
88 |
resource.jobs() |
|
89 |
assert resource.jobs_set().get().after_timestamp is not None |
|
90 |
assert resource.jobs_set().exclude(status='completed').count() == 1 |
|
91 | ||
92 |
with sftpserver.serve_content({'input': {}, 'output': {}}): |
|
93 |
content = sftpserver.content_provider.content_object |
|
94 |
resource.outcoming_sftp = sftp.SFTP( |
|
95 |
'sftp://john:doe@{server.host}:{server.port}/output/'.format( |
|
96 |
server=sftpserver)) |
|
97 |
resource.jobs() |
|
98 |
assert not content['output'] |
|
99 |
# Jump over the 6 hour wait time for retry |
|
100 |
freezer.move_to('2019-01-02') |
|
101 |
resource.jobs() |
|
102 |
assert 'A-1-1-depotDossierPACS-1.zip' in content['output'] |
|
103 |
# Check it's the same document than through the zip_url |
|
104 |
with open('/tmp/zip.zip', 'w') as fd: |
|
105 |
fd.write(content['output']['A-1-1-depotDossierPACS-1.zip']) |
|
106 |
with io.BytesIO(content['output']['A-1-1-depotDossierPACS-1.zip']) as fd: |
|
107 |
differences = diff_zip('tests/data/mdel_ddpacs_expected.zip', fd) |
|
108 |
assert not differences, differences |
|
109 |
# Act as if zip was consumed |
|
110 |
content['output'] = {} |
|
111 |
# Jump over the 6 hour wait time for retry |
|
112 |
freezer.move_to('2019-01-03') |
|
113 |
resource.jobs() |
|
114 |
assert not content['output'] |
|
115 |
assert resource.jobs_set().exclude(status='completed').count() == 0 |
|
116 | ||
117 |
# Check response |
|
118 |
resource.hourly() |
|
119 | ||
120 |
resource.incoming_sftp = sftp.SFTP( |
|
121 |
'sftp://john:doe@{server.host}:{server.port}/input/'.format( |
|
122 |
server=sftpserver)) |
|
123 | ||
124 |
response_name, response_content = build_response_zip( |
|
125 |
reference='A-1-1', |
|
126 |
flow_type='depotDossierPACS', |
|
127 |
step=1, |
|
128 |
old_step=1, |
|
129 |
etat=100, |
|
130 |
commentaire='coucou') |
|
131 |
content['input'][response_name] = response_content |
|
132 |
resource.hourly() |
|
133 |
assert resource.demand_set.get().status == 'closed' |
|
134 |
assert response_name not in content['input'] |
|
135 | ||
136 |
response_name, response_content = build_response_zip( |
|
137 |
reference='A-1-1', |
|
138 |
flow_type='depotDossierPACS', |
|
139 |
step=1, |
|
140 |
old_step=1, |
|
141 |
etat=1, |
|
142 |
commentaire='coucou') |
|
143 |
content['input'][response_name] = response_content |
|
144 |
resource.hourly() |
|
145 |
assert 'unexpected file "A-1-1-depotDossierPACS-1.zip"' in caplog.messages[-1] |
|
146 |
assert 'step 1 is inferior' in caplog.messages[-1] |
|
147 | ||
148 |
resource.check_status() |
|
0 |
- |