Projet

Général

Profil

0010-initialize-sp_fr-connector-31595.patch

Benjamin Dauvergne, 09 avril 2019 13:33

Télécharger (47,7 ko)

Voir les différences:

Subject: [PATCH 10/10] initialize sp_fr connector (#31595)

New connector for transfering forms from Service-Public.fr to w.c.s.
 passerelle/apps/sp_fr/DOC.XSD                 | 108 ++++
 passerelle/apps/sp_fr/RCO.XSD                 | 137 +++++
 passerelle/apps/sp_fr/__init__.py             |   0
 passerelle/apps/sp_fr/forms.py                |  69 +++
 passerelle/apps/sp_fr/models.py               | 514 ++++++++++++++++++
 .../variable_and_expression_widget.html       |   6 +
 .../sp_fr/mapping_confirm_delete.html         |   1 +
 .../sp_fr/templates/sp_fr/mapping_form.html   |  44 ++
 .../templates/sp_fr/resource_detail.html      |  39 ++
 passerelle/apps/sp_fr/urls.py                 |  30 +
 passerelle/apps/sp_fr/views.py                |  67 +++
 passerelle/settings.py                        |   1 +
 passerelle/static/css/style.css               |   6 +
 tests/test_sp_fr.py                           |  65 +++
 14 files changed, 1087 insertions(+)
 create mode 100644 passerelle/apps/sp_fr/DOC.XSD
 create mode 100644 passerelle/apps/sp_fr/RCO.XSD
 create mode 100644 passerelle/apps/sp_fr/__init__.py
 create mode 100644 passerelle/apps/sp_fr/forms.py
 create mode 100644 passerelle/apps/sp_fr/models.py
 create mode 100644 passerelle/apps/sp_fr/templates/passerelle/widgets/variable_and_expression_widget.html
 create mode 100644 passerelle/apps/sp_fr/templates/sp_fr/mapping_confirm_delete.html
 create mode 100644 passerelle/apps/sp_fr/templates/sp_fr/mapping_form.html
 create mode 100644 passerelle/apps/sp_fr/templates/sp_fr/resource_detail.html
 create mode 100644 passerelle/apps/sp_fr/urls.py
 create mode 100644 passerelle/apps/sp_fr/views.py
 create mode 100644 tests/test_sp_fr.py
passerelle/apps/sp_fr/DOC.XSD
1
<?xml version="1.0" encoding="UTF-8"?>
2
<!-- edited with XMLSpy v2010 (http://www.altova.com) by BULL SAS (BULL SAS) -->
3
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" elementFormDefault="qualified" attributeFormDefault="unqualified">
4
  <xs:complexType name="DECLARANT">
5
    <xs:sequence>
6
      <xs:element name="identité" type="identité" minOccurs="0"/>
7
      <xs:element name="designation-permis" type="designation-permis" minOccurs="0"/>
8
      <xs:element name="coordonnees" type="coordonnees" minOccurs="0"/>
9
    </xs:sequence>
10
  </xs:complexType>
11
  <xs:complexType name="identité">
12
    <xs:sequence>
13
      <xs:element name="type-personne" type="xs:boolean" minOccurs="0"/>
14
      <xs:element name="personne-physique" type="personne-physique" minOccurs="0"/>
15
      <xs:element name="personne-morale" type="personne-morale" minOccurs="0"/>
16
    </xs:sequence>
17
  </xs:complexType>
18
  <xs:complexType name="personne-physique">
19
    <xs:sequence>
20
      <xs:element name="civilité" type="xs:string" minOccurs="0"/>
21
      <xs:element name="nom" type="xs:string" minOccurs="0"/>
22
      <xs:element name="prenom" type="xs:string" minOccurs="0"/>
23
    </xs:sequence>
24
  </xs:complexType>
25
  <xs:complexType name="personne-morale">
26
    <xs:sequence>
27
      <xs:element name="denomination"/>
28
      <xs:element name="raison-sociale"/>
29
      <xs:element name="SIRET"/>
30
      <xs:element name="categorie-juridique"/>
31
      <xs:element name="representant-personne-morale" type="representant-personne-morale"/>
32
    </xs:sequence>
33
  </xs:complexType>
34
  <xs:complexType name="representant-personne-morale">
35
    <xs:sequence>
36
      <xs:element name="civilité" type="xs:string" minOccurs="0"/>
37
      <xs:element name="nom" type="xs:string" minOccurs="0"/>
38
      <xs:element name="prenom" type="xs:string" minOccurs="0"/>
39
    </xs:sequence>
40
  </xs:complexType>
41
  <xs:element name="DOC">
42
    <xs:complexType>
43
      <xs:sequence>
44
        <xs:element name="DECLARANT" type="DECLARANT" minOccurs="0"/>
45
        <xs:element name="OUVERTURE-CHANTIER" type="OUVERTURE-CHANTIER" minOccurs="0"/>
46
        <xs:element name="acceptation" type="xs:boolean" minOccurs="0"/>
47
      </xs:sequence>
48
    </xs:complexType>
49
  </xs:element>
50
  <xs:complexType name="designation-permis">
51
    <xs:sequence>
52
      <xs:element name="numero-permis_construire" type="xs:string" minOccurs="0"/>
53
      <xs:element name="numero-permis_amenager" type="xs:string" minOccurs="0"/>
54
    </xs:sequence>
55
  </xs:complexType>
56
  <xs:complexType name="coordonnees">
57
    <xs:sequence>
58
      <xs:element name="adresse" type="adresse" minOccurs="0"/>
59
      <xs:element name="courriel" type="xs:string" minOccurs="0"/>
60
    </xs:sequence>
61
  </xs:complexType>
62
  <xs:complexType name="adresse">
63
    <xs:sequence>
64
      <xs:element name="numero-voie" type="xs:string" minOccurs="0"/>
65
      <xs:element name="extension" type="xs:string" minOccurs="0"/>
66
      <xs:element name="type-voie" type="xs:string" minOccurs="0"/>
67
      <xs:element name="nom-voie" type="xs:string" minOccurs="0"/>
68
      <xs:element name="lieu-dit" type="xs:string" minOccurs="0"/>
69
      <xs:element name="boite-postale" type="xs:string" minOccurs="0"/>
70
      <xs:element name="code-postal" type="xs:string" minOccurs="0"/>
71
      <xs:element name="localite" type="xs:string" minOccurs="0"/>
72
      <xs:element name="bureau-cedex" type="xs:string" minOccurs="0"/>
73
      <xs:element name="pays" type="xs:string" minOccurs="0"/>
74
      <xs:element name="division-territoriale" type="xs:string" minOccurs="0"/>
75
    </xs:sequence>
76
  </xs:complexType>
77
  <xs:complexType name="OUVERTURE-CHANTIER">
78
    <xs:sequence>
79
      <xs:element name="date-ouverture" type="xs:date" minOccurs="0"/>
80
      <xs:element name="totalite-travaux" type="xs:boolean" minOccurs="0"/>
81
      <xs:element name="tranche-travaux" type="tranche-travaux" minOccurs="0"/>
82
      <xs:element name="autorisation-differer-travaux" type="xs:string" minOccurs="0"/>
83
      <xs:element name="SHON" type="xs:string" minOccurs="0"/>
84
      <xs:element name="nombre-logements-commences" type="nombre-logements-commences" minOccurs="0"/>
85
      <xs:element name="repartition-type-financement" type="repartition-type-financement" minOccurs="0"/>
86
    </xs:sequence>
87
  </xs:complexType>
88
  <xs:complexType name="tranche-travaux">
89
    <xs:sequence>
90
      <xs:element name="amenagements-commences" type="xs:string" minOccurs="0"/>
91
    </xs:sequence>
92
  </xs:complexType>
93
  <xs:complexType name="nombre-logements-commences">
94
    <xs:sequence>
95
      <xs:element name="total-logements" type="xs:int" minOccurs="0"/>
96
      <xs:element name="individuels" type="xs:int" minOccurs="0"/>
97
      <xs:element name="collectifs" type="xs:int" minOccurs="0"/>
98
    </xs:sequence>
99
  </xs:complexType>
100
  <xs:complexType name="repartition-type-financement">
101
    <xs:sequence>
102
      <xs:element name="logement-locatif-social" type="xs:int" minOccurs="0"/>
103
      <xs:element name="accession-aidee" type="xs:int" minOccurs="0"/>
104
      <xs:element name="pret-taux-zero" type="xs:int" minOccurs="0"/>
105
      <xs:element name="autres-financements" type="xs:int" minOccurs="0"/>
106
    </xs:sequence>
107
  </xs:complexType>
108
</xs:schema>
passerelle/apps/sp_fr/RCO.XSD
1
<?xml version="1.0" encoding="UTF-8"?>
2
<!-- edited with XMLSpy v2010 rel. 2 (http://www.altova.com) by BULL SAS (BULL SAS) -->
3
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
4
	<xs:element name="RecensementCitoyen">
5
		<xs:complexType>
6
			<xs:sequence>
7
				<xs:element name="Convention" type="xs:string"/>
8
				<xs:element name="Formalite">
9
					<xs:complexType>
10
						<xs:sequence>
11
							<xs:element name="Identifiant" type="xs:string"/>
12
							<xs:element name="FormaliteType" type="xs:string"/>
13
							<xs:element name="DateSoumission" type="xs:string"/>
14
							<xs:element name="FormaliteMotifCode" type="xs:string" maxOccurs="2"/>
15
							<xs:element name="FormaliteModeCode" type="xs:string"/>
16
						</xs:sequence>
17
					</xs:complexType>
18
				</xs:element>
19
				<xs:element name="Personne">
20
					<xs:complexType>
21
						<xs:sequence>
22
							<xs:element name="Civilite" type="xs:string"/>
23
							<xs:element name="Sexe" type="xs:string"/>
24
							<xs:element name="NomFamille" type="xs:string"/>
25
							<xs:element name="NomUsage" type="xs:string"/>
26
							<xs:element name="PrenomUsuel" type="xs:string"/>
27
							<xs:element name="Prenom" type="xs:string" maxOccurs="2"/>
28
							<xs:element name="DateNaissance" type="xs:string"/>
29
							<xs:element name="PaysNaissance" type="xs:string"/>
30
							<xs:element name="Nationalite" type="xs:string"/>
31
							<xs:element name="CodeINSEENaissance" type="xs:string"/>
32
							<xs:element name="LieuNaissance" maxOccurs="2">
33
								<xs:complexType>
34
									<xs:sequence>
35
										<xs:element name="Code" type="xs:string"/>
36
										<xs:element name="Nom" type="xs:string"/>
37
									</xs:sequence>
38
								</xs:complexType>
39
							</xs:element>
40
							<xs:element name="AdresseDomicile">
41
								<xs:complexType>
42
									<xs:sequence>
43
										<xs:element name="PointDeRemise" type="xs:string"/>
44
										<xs:element name="Complement" type="xs:string"/>
45
										<xs:element name="NumeroVoie" type="xs:string"/>
46
										<xs:element name="Extension" type="xs:string"/>
47
										<xs:element name="TypeVoie" type="xs:string"/>
48
										<xs:element name="NomVoie" type="xs:string"/>
49
										<xs:element name="LieuDit" type="xs:string"/>
50
										<xs:element name="CodePostal" type="xs:string"/>
51
										<xs:element name="Localite" type="xs:string"/>
52
										<xs:element name="CodeINSEE" type="xs:string"/>
53
									</xs:sequence>
54
								</xs:complexType>
55
							</xs:element>
56
							<xs:element name="AdresseResidence">
57
								<xs:complexType>
58
									<xs:sequence>
59
										<xs:element name="PointDeRemise" type="xs:string"/>
60
										<xs:element name="Complement" type="xs:string"/>
61
										<xs:element name="NumeroVoie" type="xs:string"/>
62
										<xs:element name="Extension" type="xs:string"/>
63
										<xs:element name="TypeVoie" type="xs:string"/>
64
										<xs:element name="NomVoie" type="xs:string"/>
65
										<xs:element name="LieuDit" type="xs:string"/>
66
										<xs:element name="CodePostal" type="xs:string"/>
67
										<xs:element name="Localite" type="xs:string"/>
68
									</xs:sequence>
69
								</xs:complexType>
70
							</xs:element>
71
							<xs:element name="SituationFamille">
72
								<xs:complexType>
73
									<xs:sequence>
74
										<xs:element name="SituationMatrimoniale" type="xs:string"/>
75
										<xs:element name="NombreEnfants" type="xs:string"/>
76
										<xs:element name="Pupille" type="xs:string"/>
77
										<xs:element name="NombreFrereSoeur" type="xs:string"/>
78
									</xs:sequence>
79
								</xs:complexType>
80
							</xs:element>
81
							<xs:element name="MethodeContact" maxOccurs="2">
82
								<xs:complexType>
83
									<xs:sequence>
84
										<xs:element name="URI" type="xs:string"/>
85
										<xs:element name="CanalCode" type="xs:string"/>
86
									</xs:sequence>
87
								</xs:complexType>
88
							</xs:element>
89
						</xs:sequence>
90
					</xs:complexType>
91
				</xs:element>
92
				<xs:element name="FiliationPere">
93
					<xs:complexType>
94
						<xs:sequence>
95
							<xs:element name="NomFamille" type="xs:string"/>
96
							<xs:element name="PrenomUsuel" type="xs:string"/>
97
							<xs:element name="Prenom" type="xs:string" maxOccurs="2"/>
98
							<xs:element name="DateNaissance" type="xs:string"/>
99
							<xs:element name="PaysNaissance" type="xs:string"/>
100
							<xs:element name="Nationalite" type="xs:string"/>
101
							<xs:element name="CodeINSEENaissance" type="xs:string"/>
102
							<xs:element name="LieuNaissance" maxOccurs="2">
103
								<xs:complexType>
104
									<xs:sequence>
105
										<xs:element name="Code" type="xs:string"/>
106
										<xs:element name="Nom" type="xs:string"/>
107
									</xs:sequence>
108
								</xs:complexType>
109
							</xs:element>
110
						</xs:sequence>
111
					</xs:complexType>
112
				</xs:element>
113
				<xs:element name="FiliationMere">
114
					<xs:complexType>
115
						<xs:sequence>
116
							<xs:element name="NomFamille" type="xs:string"/>
117
							<xs:element name="PrenomUsuel" type="xs:string"/>
118
							<xs:element name="Prenom" type="xs:string" maxOccurs="2"/>
119
							<xs:element name="DateNaissance" type="xs:string"/>
120
							<xs:element name="PaysNaissance" type="xs:string"/>
121
							<xs:element name="Nationalite" type="xs:string"/>
122
							<xs:element name="CodeINSEENaissance" type="xs:string"/>
123
							<xs:element name="LieuNaissance" maxOccurs="2">
124
								<xs:complexType>
125
									<xs:sequence>
126
										<xs:element name="Code" type="xs:string"/>
127
										<xs:element name="Nom" type="xs:string"/>
128
									</xs:sequence>
129
								</xs:complexType>
130
							</xs:element>
131
						</xs:sequence>
132
					</xs:complexType>
133
				</xs:element>
134
			</xs:sequence>
135
		</xs:complexType>
136
	</xs:element>
137
</xs:schema>
passerelle/apps/sp_fr/forms.py
1
# passerelle - uniform access to multiple data sources and services
2
# Copyright (C) 2019 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
from django import forms
18

  
19
from . import models, fields
20

  
21

  
22
class MappingForm(forms.ModelForm):
23

  
24
    def __init__(self, *args, **kwargs):
25
        super(MappingForm, self).__init__(*args, **kwargs)
26
        if self.instance.procedure and self.instance and self.instance.formdef:
27
            choices = [('', '--------')] + [(v, v) for v in self.instance.variables]
28
            for i, field in enumerate(self.schema_fields()):
29
                label = field.label
30
                label += ' (%s)' % (field.varname or 'NO VARNAME')
31
                base_name = str(field.varname or i)
32
                initial = self.instance.rules.get('fields', {}).get(base_name)
33
                self.fields['field_%s' % base_name] = fields.VariableAndExpressionField(
34
                    label=label,
35
                    choices=choices,
36
                    initial=initial,
37
                    required=False)
38

  
39
    def table_fields(self):
40
        return [field for field in self if field.name.startswith('field_')]
41

  
42
    def schema_fields(self):
43
        if self.instance and self.instance.formdef:
44
            schema = self.instance.formdef.schema
45
            for i, field in enumerate(schema.fields):
46
                if field.type in ('page', 'comment', 'title', 'subtitle'):
47
                    continue
48
                yield field
49

  
50
    def save(self, commit=True):
51
        fields = {}
52
        for key in self.cleaned_data:
53
            if not key.startswith('field_'):
54
                continue
55
            if not self.cleaned_data[key]:
56
                continue
57
            real_key = key[len('field_'):]
58
            value = self.cleaned_data[key].copy()
59
            value['label'] = self.fields[key].label
60
            fields[real_key] = value
61
        self.instance.rules['fields'] = fields
62
        return super(MappingForm, self).save(commit=commit)
63

  
64
    class Meta:
65
        model = models.Mapping
66
        fields = [
67
            'procedure',
68
            'formdef',
69
        ]
passerelle/apps/sp_fr/models.py
1
# -*- coding: utf-8 -*-
2
# passerelle - uniform access to multiple data sources 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
import re
19
import os
20
import stat
21
import zipfile
22
import collections
23
import io
24
import base64
25
import datetime
26

  
27
from lxml import etree as ET
28

  
29
from django.core.urlresolvers import reverse
30
from django.db import models, transaction
31
from django.template import engines
32
from django.utils.translation import ugettext_lazy as _
33

  
34
from jsonfield import JSONField
35

  
36
from passerelle.base.models import BaseResource
37
from passerelle.utils.api import endpoint
38
from passerelle.utils.sftp import SFTPField
39
from passerelle.utils.wcs import FormDefField, get_wcs_choices
40
from passerelle.utils.xsd import Schema
41
from passerelle.utils.xml import text_content
42

  
43

  
44
PROCEDURE_DOC = 'DOC'
45
PROCEDURE_RCO = 'RCO'
46
PROCEDURE_DDPACS = 'DDPACS'
47
PROCEDURES = [
48
    (PROCEDURE_DOC, _('Request for construction site opening')),
49
    (PROCEDURE_RCO, _('Request for mandatory citizen census')),
50
    (PROCEDURE_DDPACS, _('Pre-request for citizen solidarity pact')),
51
]
52

  
53
ET.register_namespace('dgme-metier', 'http://finances.gouv.fr/dgme/gf/composants/teledemarchexml/donnee/metier')
54

  
55

  
56
def simplify(s):
57
    '''
58
    Simplify a string, trying to transform it to lower ascii chars (a-z, 0-9)
59
    and minimize spaces. Used to compare strings on ?q=something requests.
60
    '''
61
    if not s:
62
        return ''
63
    if not isinstance(s, six.text_type):
64
        s = six.text_type(s, 'utf-8', 'ignore')
65
    s = unicodedata.normalize('NFKD', s).encode('ascii', 'ignore')
66
    s = re.sub(r'[^\w\s\'-_]', '', s)
67
    s = re.sub(r'[\s\']+', ' ', s)
68
    return s.strip().lower()
69

  
70

  
71
class Resource(BaseResource):
72
    category = _('Business Process Connectors')
73

  
74
    input_sftp = SFTPField(
75
        verbose_name=_('Input SFTP URL'),
76
        null=True)
77

  
78
    output_sftp = SFTPField(
79
        verbose_name=_('Output SFTP URL'),
80
        null=True)
81

  
82
    def check_status(self):
83
        with self.input_sftp as sftp:
84
            sftp.listdir()
85
        with self.output_sftp as sftp:
86
            sftp.listdir()
87
        get_wcs_choices(self.requests)
88

  
89
    @endpoint(name='ping', description=_('Check Solis API availability'))
90
    def ping(self, request):
91
        self.check_status()
92
        return {'err': 0}
93

  
94
    def run_loop(self, count=1):
95
        with transaction.atomic():
96
            # lock resource
97
            r = Resource.objects.select_for_update(skip_locked=True).filter(pk=self.pk)
98
            if not r:
99
                # already locked
100
                self.logger.info('did nothing')
101
                return
102
            with self.input_sftp as sftp:
103
                try:
104
                    sftp.lstat('DONE')
105
                except IOError:
106
                    sftp.mkdir('DONE')
107

  
108
                def helper():
109
                    for file_stat in sftp.listdir_attr():
110
                        if stat.S_ISDIR(file_stat.st_mode):
111
                            continue
112
                        yield file_stat.filename
113

  
114
                mappings = {mapping.procedure: mapping for mapping in self.mappings.all()}
115

  
116
                for filename, i in zip(helper(), range(count)):
117
                    self.handle_filename(filename, sftp, mappings)
118

  
119
    def handle_filename(self, filename, sftp, mappings):
120
        nsmap = {
121
            'dgme-metier': 'http://finances.gouv.fr/dgme/gf/composants/teledemarchexml/donnee/metier'
122
        }
123
        routage_xpath = ET.XPath(
124
            ('dgme-metier:Routage/dgme-metier:Donnee/dgme-metier:Valeur/text()'),
125
            namespaces=nsmap)
126
        m = re.match(r'(.*)-([A-Z]+)-(\d+).zip', filename)
127
        if not m:
128
            self.logger.warning('found file with an unknown pattern %s moving in DONE/', filename)
129
            return
130
        mdel_identifier, procedure, mdel_sequence = m.groups()
131
        if procedure not in mappings:
132
            self.logger.warning('found file for an unsupported procedure %s', procedure)
133
            return
134
        mapping = mappings[procedure]
135
        self.logger.info('found %s %s %s, handling', mdel_identifier, procedure, mdel_sequence)
136
        with sftp.open(filename) as fd:
137
            try:
138
                archive = zipfile.ZipFile(fd)
139
            except Exception:
140
                self.logger.error('could not load zipfile %s', filename)
141
                return
142
            doc_files = []
143
            ent_files = []
144
            attachments = {}
145
            for name in archive.namelist():
146
                if re.match(r'^.*-ent-\d+(?:-.*)?.xml$', name):
147
                    ent_files.append(name)
148
                if re.match(r'^.*-doc-\d+-XML-\d+(?:-.*)?\.xml$', name):
149
                    doc_files.append(name)
150
                m = re.match(r'^.*-pj-([^-]+)-\d+\.([^.]+)$', name)
151
                if m:
152
                    attachment_type, extension = m.groups()
153
                    attachments.setdefault(attachment_type, []).append(name)
154
            if len(ent_files) != 1:
155
                self.logger.warning('too many/few ent files found: %s', ent_files)
156
                return
157
            if len(doc_files) != 1:
158
                self.logger.warning('too many/few doc files found: %s', doc_files)
159
                return
160
            for key in attachments:
161
                if len(attachments[key]) > 1:
162
                    self.logger.warning('too many attachments of kind %s: %s', key, attachments[key])
163
                name = attachments[key][0]
164
                content = archive.open(attachments[key][0]).read()
165
                attachments[key] = {
166
                    'filename': name,
167
                    'content': base64.b64encode(content).decode('ascii'),
168
                    'content_type': 'application/octet-stream',
169
                }
170
            if procedure == 'RCO' and not attachments:
171
                self.logger.warning('no attachments but RCO requires them')
172
                return
173
            ent_file = ent_files[0]
174
            doc_file = doc_files[0]
175

  
176
            with archive.open(ent_file) as fd:
177
                document = ET.parse(fd)
178
            insee_codes = routage_xpath(document)
179
            if len(insee_codes) != 1:
180
                self.logger.warning('too many/few insee codes found: %s', insee_codes)
181
                return
182
            insee_code = insee_codes[0]
183
            data = {'insee_code': insee_code}
184
            data.update(attachments)
185
            with archive.open(doc_file) as fd:
186
                document = ET.parse(fd)
187
                data.update(self.extract_data(document))
188
                if hasattr(self, 'update_data_%s' % procedure):
189
                    getattr(self, 'update_data_%s' % procedure)(mapping, data)
190

  
191
            formdef = mapping.formdef
192
            formdef.session = self.requests
193

  
194
            with formdef.submit() as submitter:
195
                submitter.submission_channel = 'web'
196
                submitter.submission_context = {
197
                    'mdel_procedure': procedure,
198
                    'mdel_identifier': mdel_identifier,
199
                    'mdel_sequence': mdel_sequence,
200
                }
201
                for field in mapping.rules.get('fields', {}):
202
                    variable = mapping.rules['fields'][field]['variable']
203
                    expression = mapping.rules['fields'][field]['expression']
204
                    value = data.get(variable)
205
                    if expression.strip():
206
                        template = engines['django'].from_string(expression)
207
                        context = data.copy()
208
                        context['value'] = value
209
                        value = template.render(context)
210
                    submitter.set(field, value)
211
                import pprint
212
                print 'Payload'
213
                pprint.pprint(submitter.payload())
214

  
215
            for key in sorted(data):
216
                if not isinstance(data[key], dict):
217
                    print key, data[key]
218
                else:
219
                    print key, '<fichier>', data[key]['filename']
220

  
221
    def update_data_DOC(self, mapping, data):
222
        variables = list(mapping.variables)
223
        assert all(key in variables for key in data)
224

  
225
        def get(name):
226
            # prevent error in manual mapping
227
            if name not in variables:
228
                print '\n'.join(sorted(variables))
229
                assert False, name
230
            return data.get(name, '')
231

  
232
        numero_permis_construire = get('doc_declarant_designation_permis_numero_permis_construire')
233
        numero_permis_amenager = get('doc_declarant_designation_permis_numero_permis_amenager')
234
        data['type_permis'] = u'Un permis de construire' if numero_permis_construire else u'Un permis d\'aménager'
235
        data['numero_permis'] = numero_permis_construire or numero_permis_amenager
236
        particulier = get('doc_declarant_identite_type_personne').strip().lower() == 'true'
237
        data['type_declarant'] = u'Un particulier' if particulier else u'Une personne morale'
238
        if particulier:
239
            data['nom'] = get('doc_declarant_identite_personne_physique_nom')
240
            data['prenoms'] = get('doc_declarant_identite_personne_physique_prenom')
241
        else:
242
            data['nom'] = get('doc_declarant_identite_personne_morale_representant_personne_morale_nom')
243
            data['prenoms'] = get('doc_declarant_identite_personne_morale_representant_personne_morale_prenom')
244
        mapping = {
245
            '1000': 'Monsieur',
246
            '1001': 'Madame',
247
            '1002': 'Madame et Monsieur',
248
        }
249
        if particulier:
250
            data['civilite_particulier'] = mapping.get(get('doc_declarant_identite_personne_physique_civilite'), '')
251
        else:
252
            data['civilite_pm'] = mapping.get(
253
                get('doc_declarant_identite_personne_morale_representant_personne_morale_civilite'), '')
254
        data['portee'] = (u'Pour la totalité des travaux'
255
                          if get('doc_ouverture_chantier_totalite_travaux').lower().strip() == 'true'
256
                          else u'Pour une tranche des travaux')
257
        assert all(key in variables for key in data)
258

  
259
    def update_data_RCO(self, mapping, data):
260
        variables = list(mapping.variables)
261
        assert all(key in variables for key in data)
262

  
263
        def get(name):
264
            # prevent error in manual mapping
265
            if name not in variables:
266
                print '\n'.join(sorted(variables))
267
                assert False, name
268
            return data.get(name, '')
269

  
270
        motif = (
271
            get('recensementcitoyen_formalite_formalitemotifcode_1')
272
            or get('recensementcitoyen_formalite_formalitemotifcode_2')
273
        )
274
        data['motif'] = {
275
            'RECENSEMENT': '1',
276
            'EXEMPTION': '2'
277
        }[motif]
278
        if data['motif'] == '2':
279
            data['motif_exempte'] = (
280
                u"Titulaire d'une carte d'invalidité de 80% minimum"
281
                if get('recensementcitoyen_formalite_formalitemotifcode_2') == 'INFIRME'
282
                else u"Autre situation")
283
        data['justificatif_exemption'] = get('je')
284
        data['double_nationalite'] = (
285
            'Oui'
286
            if get('recensementcitoyen_personne_nationalite')
287
            else 'Non')
288
        data['residence_differente'] = (
289
            'Oui'
290
            if get('recensementcitoyen_personne_adresseresidence_localite')
291
            else 'Non')
292
        data['civilite'] = (
293
            'Monsieur'
294
            if get('recensementcitoyen_personne_civilite') == 'M'
295
            else 'Madame'
296
        )
297

  
298
        def get_lieu_naissance(variable, code):
299
            for idx in ['', '_1', '_2']:
300
                v = variable + idx
301
                if get(v + '_code') == code:
302
                    return get(v + '_nom')
303

  
304
        data['cp_naissance'] = get_lieu_naissance('recensementcitoyen_personne_lieunaissance', 'AUTRE')
305
        data['commune_naissance'] = get_lieu_naissance('recensementcitoyen_personne_lieunaissance', 'COMMUNE')
306
        data['justificatif_identite'] = get('ji')
307
        situation_matrimoniale = get('recensementcitoyen_personne_situationfamille_situationmatrimoniale')
308
        data['situation_familiale'] = {
309
            u'Célibataire': u'Célibataire',
310
            u'Marié': u'Marié(e)',
311
        }.get(situation_matrimoniale, u'Autres')
312
        if data['situation_familiale'] == u'Autres':
313
            data['situation_familiale_precision'] = situation_matrimoniale
314
        pupille = get('recensementcitoyen_personne_situationfamille_pupille')
315
        data['pupille'] = (
316
            'Oui'
317
            if pupille
318
            else 'Non'
319
        )
320
        data['pupille_categorie'] = {
321
            'NATION': u"Pupille de la nation",
322
            'ETAT': u"Pupille de l'État",
323
        }.get(pupille)
324
        for idx in ['', '_1', '_2']:
325
            code = get('recensementcitoyen_personne_methodecontact%s_canalcode' % idx)
326
            uri = get('recensementcitoyen_personne_methodecontact%s_uri' % idx)
327
            if code == 'EMAIL':
328
                data['courriel'] = uri
329
            if code == 'TEL':
330
                data['telephone_fixe'] = uri
331
        data['justificatif_famille'] = data['jf']
332
        data['filiation_inconnue_p1'] = not get('recensementcitoyen_filiationpere_nomfamille')
333
        data['filiation_inconnue_p2'] = not get('recensementcitoyen_filiationmere_nomfamille')
334
        data['cp_naissance_p1'] = get_lieu_naissance('recensementcitoyen_filiationpere_lieunaissance', 'AUTRE')
335
        data['cp_naissance_p2'] = get_lieu_naissance('recensementcitoyen_filiationmere_lieunaissance', 'AUTRE')
336
        data['commune_naissance_p1'] = get_lieu_naissance('recensementcitoyen_filiationpere_lieunaissance', 'COMMUNE')
337
        data['commune_naissance_p2'] = get_lieu_naissance('recensementcitoyen_filiationmere_lieunaissance', 'COMMUNE')
338
        for key in data:
339
            if key.endswith('_datenaissance') and data[key]:
340
                data[key] = (
341
                    datetime.datetime.strptime(data[key], '%d/%m/%Y')
342
                    .date()
343
                    .strftime('%Y-%m-%d')
344
                )
345

  
346
    def extract_data(self, document):
347
        root = document.getroot()
348

  
349
        def tag_name(node):
350
            return simplify(ET.QName(node.tag).localname)
351

  
352
        def helper(path, node):
353
            if len(node):
354
                tags = collections.Counter(tag_name(child) for child in node)
355
                counter = collections.Counter()
356
                for child in node:
357
                    name = tag_name(child)
358
                    if tags[name] > 1:
359
                        counter[name] += 1
360
                        name += '_%s' % counter[name]
361
                    for p, value in helper(path + [name], child):
362
                        yield p, value
363
            else:
364
                yield path, text_content(node)
365
        return {
366
            '_'.join(path).replace('-', '_').replace(' ', ''): value
367
            for path, value in helper([tag_name(root)], root)
368
        }
369

  
370
    class Meta:
371
        verbose_name = _('Service-Public.fr')
372

  
373

  
374
class SPFRMessage(object):
375
    @classmethod
376
    def from_file(self, resource, filename, fd):
377
        m = re.match(r'(.*)-([A-Z]+)-(\d+).zip', filename)
378
        if not m:
379
            resource.logger.warning('found file with an unknown pattern %s moving in DONE/', filename)
380
            return None
381
        mdel_number, procedure, sequence = m.groups()
382

  
383

  
384
def default_rule():
385
    return {}
386

  
387

  
388
class Mapping(models.Model):
389

  
390
    resource = models.ForeignKey(
391
        Resource,
392
        verbose_name=_('Resource'),
393
        related_name='mappings')
394

  
395
    procedure = models.CharField(
396
        verbose_name=_('Procedure'),
397
        choices=PROCEDURES,
398
        unique=True,
399
        max_length=8)
400

  
401
    formdef = FormDefField(
402
        verbose_name=_('Formulaire'))
403

  
404
    rules = JSONField(
405
        verbose_name=_('Rules'),
406
        default=default_rule)
407

  
408
    def get_absolute_url(self):
409
        return reverse('sp-fr-mapping-edit', kwargs=dict(
410
            slug=self.resource.slug,
411
            pk=self.pk))
412

  
413
    @property
414
    def xsd(self):
415
        doc = ET.parse(os.path.join(os.path.dirname(__file__), '%s.XSD' % self.procedure))
416
        schema = Schema()
417
        schema.visit(doc.getroot())
418
        return schema
419

  
420
    @property
421
    def variables(self):
422
        yield 'insee_code'
423
        for path, xsd_type in self.xsd.paths():
424
            names = [simplify(tag.localname).replace('-', '_').replace(' ', '') for tag in path]
425
            yield '_'.join(names)
426
        if hasattr(self, 'variables_%s' % self.procedure):
427
            for variable in getattr(self, 'variables_%s' % self.procedure):
428
                yield variable
429

  
430
    @property
431
    def variables_DOC(self):
432
        yield 'type_permis'
433
        yield 'numero_permis'
434
        yield 'type_declarant'
435
        yield 'nom'
436
        yield 'prenoms'
437
        yield 'civilite_particulier'
438
        yield 'civilite_pm'
439
        yield 'portee'
440

  
441
    @property
442
    def variables_RCO(self):
443
        yield 'motif'
444
        yield 'motif_exemple'
445
        yield 'justificatif_exemption'
446
        yield 'double_nationalite'
447
        yield 'residence_differente'
448
        yield 'civilite'
449
        yield 'cp_naissance'
450
        yield 'commune_naissance'
451
        yield 'je'
452
        yield 'ji'
453
        yield 'situation_familiale'
454
        yield 'situation_familiale_precision'
455
        yield 'pupille'
456
        yield 'pupille_categorie'
457
        yield 'courriel'
458
        yield 'telephone_fixe'
459
        yield 'jf'
460
        yield 'filiation_inconnue_p1'
461
        yield 'filiation_inconnue_p2'
462
        yield 'cp_naissance_p1'
463
        yield 'cp_naissance_p2'
464
        yield 'commune_naissance_p1'
465
        yield 'commune_naissance_p2'
466

  
467
#def archive_upload_to(instance, filename):
468
#    return 'sp_fr/{instance.procedure}/{filename}'.format(
469
#        instance=instance,
470
#        filename=filename)
471
#
472
#
473
#class Request(models.Model):
474
#
475
#    # To prevent mixing errors from analysing archive from s-p.fr and errors
476
#    # from pushing to w.c.s we separate processing with three steps:
477
#    # - receiving, i.e. copying zipfile from SFTP and storing them locally
478
#    # - processing, i.e. openeing the zipfile and extracting content as we need it
479
#    # - transferring, pushing content as a new form in w.c.s.
480
#    STATE_RECEIVED = 'received'
481
#    STATE_PROCESSED = 'processed'
482
#    STATE_TRANSFERED = 'transfered'
483
#    STATE_ERROR = 'error'
484
#    STATES = [
485
#        (STATE_RECEIVED, _('Received')),
486
#        (STATE_TRANSFERED, _('Transfered')),
487
#        (STATE_ERROR, _('Transfered')),
488
#    ]
489
#
490
#    ressource = models.ForeignKey(
491
#        Resource,
492
#        verbose_name=_('Resource'))
493
#    identifier = models.CharField(
494
#        verbose_name=_('Identifier'),
495
#        max_length=32)
496
#    procedure = models.CharField(
497
#        verbose_name=_('Procedure'),
498
#        choices=PROCEDURES,
499
#        max_length=8)
500
#    sequence_number = models.PositiveIntegerField(
501
#        verbose_name=_('Sequence number'))
502
#
503
#    state = models.CharField(
504
#        verbose_name=_('State'),
505
#        choices=STATES,
506
#        max_length=16)
507
#
508
#    content = JSONField(
509
#        verbose_name=_('Content'),
510
#        null=True)
511
#
512
#    archive = models.FileField(
513
#        verbose_name=_('Archive'),
514
#        upload_to=archive_upload_to)
passerelle/apps/sp_fr/templates/passerelle/widgets/variable_and_expression_widget.html
1
<div class="variable-widget">
2
    {% include widget.subwidgets.0.template_name with widget=widget.subwidgets.0 %}
3
</div>
4
<div class="expression-widget">
5
    {% include widget.subwidgets.1.template_name with widget=widget.subwidgets.1 %}
6
</div>
passerelle/apps/sp_fr/templates/sp_fr/mapping_confirm_delete.html
1
{% extends "passerelle/manage/resource_child_confirm_delete.html" %}
passerelle/apps/sp_fr/templates/sp_fr/mapping_form.html
1
{% extends "passerelle/manage/resource_child_form.html" %}
2
{% load i18n %}
3

  
4

  
5
{% block form %}
6
    {% if form.errors %}
7
      <div class="errornotice">
8
        <p>{% trans "There were errors processing your form." %}</p>
9
        {% for error in form.non_field_errors %}
10
        <p>{{ error }}</p>
11
        {% endfor %}
12
        {% for field in form %}
13
          {% if field.is_hidden and field.errors %}
14
            <p>
15
              {% for error in field.errors %}
16
              {% blocktrans with name=field.name %}(Hidden field {{name}}) {{ error }}{% endblocktrans %}
17
              {% if not forloop.last %}<br>{% endif %}
18
              {% endfor %}
19
            </p>
20
          {% endif %}
21
        {% endfor %}
22
      </div>
23
    {% endif %}
24
    {% include "gadjo/widget.html" with field=form.procedure %}
25

  
26
    {% include "gadjo/widget.html" with field=form.formdef%}
27

  
28
    <table class="main">
29
        <thead>
30
            <tr>
31
                <td>Label</td>
32
                <td>Variable</td>
33
            </tr>
34
        </thead>
35
        <tbody>
36
            {% for field in form.table_fields %}
37
                <tr>
38
                    <td>{{ field.label_tag }}</td>
39
                    <td>{{ field }}</td>
40
                </tr>
41
            {% endfor %}
42
       </tbody>
43
    </table>
44
{% endblock %}
passerelle/apps/sp_fr/templates/sp_fr/resource_detail.html
1
{% extends "passerelle/manage/service_view.html" %}
2
{% load i18n passerelle %}
3

  
4
{% block description %}
5
    <p>
6
    {% blocktrans %}
7
        Connector to forms published by <a href="https://www.service-public.fr/">service-public.fr</a> 
8
    {% endblocktrans %}
9
        <a href="{% url "sp-fr-run" slug=object.slug %}">Run</a>
10
    </p>
11
    {{ block.super }}
12
{% endblock %}
13

  
14

  
15
{% block extra-sections %}
16
<div id="mappings" class="section">
17
    <h3>{% trans "Mappings" %} <a class="button" href="{% url "sp-fr-mapping-new" slug=object.slug %}">{% trans "Add" %}</a></h3>
18
    <ul>
19

  
20
        {% for mapping in object.mappings.all %}
21
            <li>
22
                <fieldset class="gadjo-foldable gadjo-folded" id="sp-fr-mapping-{{ mapping.pk}}">
23
                    <legend class="gadjo-foldable-widget">
24
                        <a href="{% url "sp-fr-mapping-edit" connector=object.get_connector_slug slug=object.slug pk=mapping.pk %}">{% blocktrans with procedure=mapping.get_procedure_display formdef=mapping.formdef.title %}From procedure {{ procedure }} to form {{ formdef }}{% endblocktrans %}</a>
25
                    </legend>
26
                    <div class="gadjo-folding">
27
                        {% for key, value in mapping.rules.fields.items %}
28
                        {% if value %}
29
                            <p>{{ value.label }}&nbsp;: {{ value.variable }} {% if value.expression %}({% trans "with expression" %} <tt>{{ value.expression }}</tt>){% endif %}</p>
30
                        {% endif %}
31
                        {% endfor %}
32
                        <a rel="popup" class="delete" href="{% url "sp-fr-mapping-delete" connector=object.get_connector_slug slug=object.slug pk=mapping.pk %}">{% trans "Delete" %}</a>
33
                    </div>
34
                </fieldset>
35
            </li>
36
        {% endfor %}
37
    </ul>
38
</div>
39
{% endblock %}
passerelle/apps/sp_fr/urls.py
1
# passerelle - uniform access to multiple data sources and services
2
# Copyright (C) 2019 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
from django.conf.urls import url
18

  
19
from . import views
20

  
21
management_urlpatterns = [
22
    url(r'^(?P<slug>[\w,-]+)/mapping/new/$',
23
        views.MappingNew.as_view(), name='sp-fr-mapping-new'),
24
    url(r'^(?P<slug>[\w,-]+)/mapping/(?P<pk>\d+)/$',
25
        views.MappingEdit.as_view(), name='sp-fr-mapping-edit'),
26
    url(r'^(?P<slug>[\w,-]+)/mapping/(?P<pk>\d+)/delete/$',
27
        views.MappingDelete.as_view(), name='sp-fr-mapping-delete'),
28
    url(r'^(?P<slug>[\w,-]+)/run/$',
29
        views.run, name='sp-fr-run'),
30
]
passerelle/apps/sp_fr/views.py
1
# passerelle - uniform access to multiple data sources and services
2
# Copyright (C) 2019 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
from django.views.generic import UpdateView, CreateView, DeleteView
18
from django.shortcuts import get_object_or_404
19
from django.http import HttpResponseRedirect
20

  
21
from passerelle.base.mixins import ResourceChildViewMixin
22

  
23
from . import models, forms
24

  
25

  
26
class StayIfChanged(object):
27
    has_changed = False
28

  
29
    def form_valid(self, form):
30
        if set(form.changed_data) & set(['procedure', 'formdef']):
31
            self.has_changed = True
32
        return super(StayIfChanged, self).form_valid(form)
33

  
34
    def get_success_url(self):
35
        if self.has_changed:
36
            return self.get_changed_url()
37
        return super(StayIfChanged, self).get_success_url()
38

  
39
    def get_changed_url(self):
40
        return ''
41

  
42

  
43
class MappingNew(StayIfChanged, ResourceChildViewMixin, CreateView):
44
    model = models.Mapping
45
    form_class = forms.MappingForm
46

  
47
    def form_valid(self, form):
48
        form.instance.resource = self.resource
49
        return super(MappingNew, self).form_valid(form)
50

  
51
    def get_changed_url(self):
52
        return self.object.get_absolute_url()
53

  
54

  
55
class MappingEdit(StayIfChanged, ResourceChildViewMixin, UpdateView):
56
    model = models.Mapping
57
    form_class = forms.MappingForm
58

  
59

  
60
class MappingDelete(ResourceChildViewMixin, DeleteView):
61
    model = models.Mapping
62

  
63

  
64
def run(request, connector, slug):
65
    resource = get_object_or_404(models.Resource, slug=slug)
66
    resource.run_loop(1000)
67
    return HttpResponseRedirect(resource.get_absolute_url())
passerelle/settings.py
135 135
    'passerelle.apps.feeds',
136 136
    'passerelle.apps.gdc',
137 137
    'passerelle.apps.jsondatastore',
138
    'passerelle.apps.sp_fr',
138 139
    'passerelle.apps.mobyt',
139 140
    'passerelle.apps.okina',
140 141
    'passerelle.apps.opengis',
passerelle/static/css/style.css
184 184
.log-dialog table td {
185 185
        vertical-align: top;
186 186
}
187
.expression-widget input {
188
	width: 100%;
189
}
190
.variable-widget select {
191
	width: 100%;
192
}
tests/test_sp_fr.py
1
# -*- coding: utf-8 -*-
2
# passerelle - uniform access to multiple data sources 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
import pytest
19

  
20
from passerelle.apps.sp_fr.models import Resource
21

  
22
import utils
23

  
24

  
25
DUMMY_CONTENT = {
26
    'DILA': {
27
        'a.zip': 'a',
28
    }
29
}
30

  
31

  
32
@pytest.fixture
33
def spfr(settings, db, sftpserver):
34
    settings.KNOWN_SERVICES = {
35
        'wcs': {
36
            'eservices': {
37
                'title': u'Démarches',
38
                'url': 'https://demarches-hautes-alpes.test.entrouvert.org/',
39
                'secret': '9a1f62b680c1cabe73cefcbc5ff4cd4f95c24c3be8dd930adb80d8aaa33bfe67',
40
                'orig': 'passerelle-hautes-alpes.test.entrouvert.org',
41
            }
42
        }
43
    }
44
    yield utils.make_resource(
45
        Resource,
46
        title='Test 1',
47
        slug='test1',
48
        description='Connecteur de test',
49
        input_sftp='sftp://john:doe@{server.host}:{server.port}/DILA/'.format(server=sftpserver),
50
        output_sftp='sftp://john:doe@{server.host}:{server.port}/DILA/'.format(server=sftpserver)
51
    )
52

  
53

  
54
def test_resource(spfr):
55
    from passerelle.utils.wcs import get_wcs_choices
56

  
57
    assert get_wcs_choices() == []
58

  
59

  
60
def test_sftp_access(spfr, sftpserver):
61
    with sftpserver.serve_content(DUMMY_CONTENT):
62
        with spfr.input_sftp as input_sftp:
63
            assert input_sftp.listdir() == ['a.zip']
64
        with spfr.output_sftp as output_sftp:
65
            assert output_sftp.listdir() == ['a.zip']
0
-