Projet

Général

Profil

0009-add-MDEL-DDPACS-connector-35818.patch

Benjamin Dauvergne, 25 octobre 2019 10:48

Télécharger (55,5 ko)

Voir les différences:

Subject: [PATCH 9/9] add MDEL DDPACS connector (#35818)

 passerelle/apps/mdel_ddpacs/__init__.py       |   0
 passerelle/apps/mdel_ddpacs/abstract.py       | 414 ++++++++++++++++++
 .../mdel_ddpacs/migrations/0001_initial.py    |  59 +++
 .../apps/mdel_ddpacs/migrations/__init__.py   |   0
 passerelle/apps/mdel_ddpacs/models.py         |  57 +++
 passerelle/apps/mdel_ddpacs/schema.xsd        | 129 ++++++
 .../mdel_ddpacs/templates/mdel/zip/doc.xml    | 106 +++++
 .../mdel_ddpacs/templates/mdel/zip/entete.xml |  25 ++
 .../templates/mdel/zip/manifest.json          |  17 +
 .../templates/mdel/zip/message.xml            |  66 +++
 passerelle/apps/mdel_ddpacs/utils.py          |  83 ++++
 passerelle/settings.py                        |   1 +
 passerelle/utils/zip.py                       |  39 ++
 tests/data/mdel_ddpacs/response_manifest.json |   9 +
 tests/data/mdel_ddpacs/response_message.xml   |  31 ++
 tests/data/mdel_ddpacs_expected.zip           | Bin 0 -> 7465 bytes
 tests/test_mdel_ddpacs.py                     | 148 +++++++
 17 files changed, 1184 insertions(+)
 create mode 100644 passerelle/apps/mdel_ddpacs/__init__.py
 create mode 100644 passerelle/apps/mdel_ddpacs/abstract.py
 create mode 100644 passerelle/apps/mdel_ddpacs/migrations/0001_initial.py
 create mode 100644 passerelle/apps/mdel_ddpacs/migrations/__init__.py
 create mode 100644 passerelle/apps/mdel_ddpacs/models.py
 create mode 100644 passerelle/apps/mdel_ddpacs/schema.xsd
 create mode 100644 passerelle/apps/mdel_ddpacs/templates/mdel/zip/doc.xml
 create mode 100644 passerelle/apps/mdel_ddpacs/templates/mdel/zip/entete.xml
 create mode 100644 passerelle/apps/mdel_ddpacs/templates/mdel/zip/manifest.json
 create mode 100644 passerelle/apps/mdel_ddpacs/templates/mdel/zip/message.xml
 create mode 100644 passerelle/apps/mdel_ddpacs/utils.py
 create mode 100644 tests/data/mdel_ddpacs/response_manifest.json
 create mode 100644 tests/data/mdel_ddpacs/response_message.xml
 create mode 100644 tests/data/mdel_ddpacs_expected.zip
 create mode 100644 tests/test_mdel_ddpacs.py
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
-