Project

General

Profile

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

Benjamin Dauvergne, 05 Nov 2019 07:22 PM

Download (55.1 KB)

View differences:

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

 passerelle/apps/mdel_ddpacs/__init__.py       |   0
 passerelle/apps/mdel_ddpacs/abstract.py       | 402 ++++++++++++++++++
 .../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, 1172 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['unflatten'] = True
144
        base_schema['merge_extra'] = True
145
        base_schema['properties'].update({
146
            'display_id': {'type': 'string'},
147
            'email': {'type': 'string'},
148
            'code_insee': {'type': 'string'},
149
        })
150
        base_schema.setdefault('required', []).append('display_id')
151
        return base_schema
152

  
153
    def _handle_create(self, request, payload):
154
        reference = 'A-' + payload['display_id']
155
        try:
156
            demand = self.demand_set.create(
157
                reference=reference,
158
                step=1,
159
                data=payload)
160
        except IntegrityError as e:
161
            return APIError('reference-non-unique', http_status=400,
162
                            data={'original_exc': exception_to_text(e)})
163
        self.add_job('push_demand', demand_id=demand.id)
164
        return self.status(request, demand)
165

  
166
    def push_demand(self, demand_id):
167
        demand = self.demand_set.get(id=demand_id)
168
        if not demand.push():
169
            raise SkipJob(after_timestamp=3600 * 6)
170

  
171
    @endpoint(perm='can_access',
172
              methods=['get'],
173
              description=_('Demand status'),
174
              pattern=r'(?P<demand_id>\d+)/$')
175
    def demand(self, request, demand_id):
176
        try:
177
            demand = self.demand_set.get(id=demand_id)
178
        except self.demand_set.model.DoesNotExist:
179
            raise APIError('demand not found', http_status=404)
180
        return self.status(request, demand)
181

  
182
    def status(self, request, demand):
183
        return {
184
            'id': demand.id,
185
            'status': demand.status,
186
            'url': request.build_absolute_uri(demand.status_url),
187
            'zip_url': request.build_absolute_uri(demand.zip_url),
188
        }
189

  
190
    @endpoint(perm='can_access',
191
              methods=['get'],
192
              description=_('Demand document'),
193
              pattern=r'(?P<demand_id>\d+)/.*$')
194
    def document(self, request, demand_id):
195
        try:
196
            demand = self.demand_set.get(id=demand_id)
197
        except self.demand_set.model.DoesNotExist:
198
            raise APIError('demand not found', http_status=404)
199
        response = HttpResponse(demand.zip_content, content_type='application/octet-stream')
200
        response['Content-Disposition'] = 'inline; filename=%s' % demand.zip_name
201
        return response
202

  
203
    @property
204
    def response_re(self):
205
        return re.compile(
206
            r'(?P<reference>[^-]+-[^-]+-[^-]+)-%s-'
207
            r'(?P<step>\d+).zip' % self.flow_type)
208

  
209
    def hourly(self):
210
        '''Get responses'''
211
        if not self.incoming_sftp:
212
            return
213
        try:
214
            with self.incoming_sftp.client() as client:
215
                for name in client.listdir():
216
                    m = self.response_re.match(name)
217
                    if not m:
218
                        self.logger.warning(
219
                            'pull responses: unexpected file "%s" in sftp, file name does not match pattern %s',
220
                            name, self.response_re)
221
                        continue
222
                    reference = m.groupdict()['reference']
223
                    step = int(m.groupdict()['step'])
224
                    demand = self.demand_set.filter(reference=reference).first()
225
                    if not demand:
226
                        self.logger.error(
227
                            'pull responses: unexpected file "%s" in sftp, no demand for reference "%s"',
228
                            name,
229
                            reference)
230
                        continue
231
                    if step < demand.step:
232
                        demand.logger.error(
233
                            'pull responses: unexpected file "%s" in sftp: step %s is inferior to demand step %s',
234
                            name,
235
                            step,
236
                            demand.step)
237
                        continue
238
                    demand.handle_response(sftp_client=client, filename=name, step=step)
239
        except sftp.paramiko.SSHException as e:
240
            self.logger.error('pull responses: sftp error %s', e)
241
            return
242

  
243

  
244
@six.python_2_unicode_compatible
245
class Demand(models.Model):
246
    STATUS_PENDING = 'pending'
247
    STATUS_PUSHED = 'pushed'
248
    STATUS_ERROR = 'error'
249

  
250
    STATUSES = [
251
        (STATUS_PENDING, _('pending')),
252
        (STATUS_PUSHED, _('pushed')),
253
        (STATUS_ERROR, _('error')),
254
    ]
255
    for mdel_status in MDEL_STATUSES:
256
        STATUSES.append((mdel_status.slug, mdel_status.label))
257

  
258
    created_at = models.DateTimeField(auto_now_add=True)
259
    updated_at = models.DateTimeField(auto_now=True)
260
    reference = models.CharField(max_length=32, null=False, unique=True)
261
    status = models.CharField(max_length=32, null=True, choices=STATUSES, default=STATUS_PENDING)
262
    step = models.IntegerField(default=0)
263
    data = jsonfield.JSONField()
264

  
265
    @functional.cached_property
266
    def logger(self):
267
        return self.resource.logger.context(
268
            demand_id=self.id,
269
            demand_status=self.status,
270
            demand_reference=self.reference)
271

  
272
    def push(self):
273
        if not self.resource.outcoming_sftp:
274
            return False
275
        try:
276
            with self.resource.outcoming_sftp.client() as client:
277
                with client.open(self.zip_name, mode='w') as fd:
278
                    fd.write(self.zip_content)
279
        except sftp.paramiko.SSHException as e:
280
            self.logger.error('push demand: %s failed, "%s"',
281
                              self,
282
                              exception_to_text(e))
283
            self.status = self.STATUS_ERROR
284
        except Exception as e:
285
            self.logger.exception('push demand: %s failed, "%s"',
286
                                  self,
287
                                  exception_to_text(e))
288
            self.status = self.STATUS_ERROR
289
        else:
290
            self.resource.logger.info('push demand: %s success', self)
291
            self.status = self.STATUS_PUSHED
292
        self.save()
293
        return True
294

  
295
    @functional.cached_property
296
    def zip_template(self):
297
        return ZipTemplate(self.resource.zip_manifest, ctx={
298
            'reference': self.reference,
299
            'flow_type': self.resource.flow_type,
300
            'doc_type': self.resource.doc_type,
301
            'step': '1',  # We never create more than one document for a reference
302
            'siret': self.resource.recipient_siret,
303
            'service': self.resource.recipient_service,
304
            'guichet': self.resource.recipient_guichet,
305
            'code_insee': self.data.get('code_insee', self.resource.code_insee),
306
            'document': self.document,
307
            'code_insee_id': self.resource.code_insee_id,
308
            'date': self.created_at.isoformat(),
309
            'email': self.data.get('email', ''),
310
        })
311

  
312
    @property
313
    def zip_name(self):
314
        return self.zip_template.name
315

  
316
    @property
317
    def zip_content(self):
318
        return self.zip_template.render_to_bytes()
319

  
320
    @property
321
    def document(self):
322
        xml_schema = self.resource.get_doc_xml_schema()
323
        return ET.tostring(
324
            xml_schema.elements[self.resource.xsd_root_element].encode(
325
                self.data[self.resource.xsd_root_element]))
326

  
327
    @property
328
    def status_url(self):
329
        return reverse(
330
            'generic-endpoint',
331
            kwargs={
332
                'connector': self.resource.get_connector_slug(),
333
                'slug': self.resource.slug,
334
                'endpoint': 'demand',
335
                'rest': '%s/' % self.id,
336
            })
337

  
338
    @property
339
    def zip_url(self):
340
        return reverse(
341
            'generic-endpoint',
342
            kwargs={
343
                'connector': self.resource.get_connector_slug(),
344
                'slug': self.resource.slug,
345
                'endpoint': 'document',
346
                'rest': '%s/%s' % (self.id, self.zip_name)
347
            })
348

  
349
    def handle_response(self, sftp_client, filename, step):
350
        try:
351
            with sftp_client.open(filename) as fd:
352
                with zipfile.ZipFile(fd) as zip_file:
353
                    with zip_file.open('message.xml') as fd:
354
                        tree = ET.parse(fd)
355
                ns = 'http://finances.gouv.fr/dgme/pec/message/v1'
356
                etat_node = tree.find('.//{%s}Etat' % ns)
357
                if etat_node is None:
358
                    self.logger.error(
359
                        'pull responses: missing Etat node in "%s"',
360
                        filename)
361
                    return
362
                etat = etat_node.text
363
                if etat in MDEL_STATUSES_BY_CODE:
364
                    self.status = MDEL_STATUSES_BY_CODE[etat].slug
365
                else:
366
                    self.logger.error(
367
                        'pull responses: unknown etat in "%s", etat="%s"',
368
                        filename,
369
                        etat)
370
                    return
371
                commentaire_node = tree.find('.//{%s}Etat' % ns)
372
                if commentaire_node is not None:
373
                    commentaire = commentaire_node.text
374
                    self.data = self.data or {}
375
                    self.data.setdefault('commentaires', []).append(commentaire)
376
                    self.data['commentaire'] = commentaire
377
                self.step = step + 1
378
                self.save()
379
                self.logger.info('pull responses: status of demand %s changed to %s',
380
                                 self, self.status)
381
        except sftp.paramiko.SSHException as e:
382
            self.logger.error(
383
                'pull responses: failed to read response "%s", %s',
384
                filename,
385
                exception_to_text(e))
386
        else:
387
            try:
388
                sftp_client.remove(filename)
389
            except sftp.paramiko.SSHException as e:
390
                self.logger.error(
391
                    'pull responses: failed to remove response "%s", %s',
392
                    filename,
393
                    exception_to_text(e))
394

  
395
    def __str__(self):
396
        return '<Demand %s reference:%s flow_type:%s>' % (
397
            self.id,
398
            self.reference,
399
            self.resource.flow_type)
400

  
401
    class Meta:
402
        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
from django.db import models
21
from django.utils.translation import ugettext_lazy as _
22

  
23
from passerelle.utils.api import endpoint
24

  
25
from . import abstract
26

  
27

  
28
class Resource(abstract.Resource):
29
    category = _('Civil Status Connectors')
30
    xsd_root_element = 'PACS'
31
    flow_type = 'depotDossierPACS'
32
    doc_type = 'flux-pacs'
33

  
34
    class Meta:
35
        verbose_name = _('MDEL compatible DDPACS request builder')
36

  
37
    @endpoint(perm='can_access',
38
              methods=['post'],
39
              description=_('Create request'),
40
              post={
41
                  'request_body': {
42
                      'schema': {
43
                          'application/json': None
44
                      }
45
                  }
46
              })
47
    def create(self, request, post_data):
48
        return self._handle_create(request, post_data)
49

  
50
Resource.create.endpoint_info.post['request_body']['schema']['application/json'] = Resource.get_create_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
-