0008-new-DDPACS-connector.patch
passerelle/apps/mdel_ddpacs/abstract.py | ||
---|---|---|
1 |
# coding: utf-8 |
|
2 |
# Passerelle - uniform access to data and services |
|
3 |
# Copyright (C) 2019 Entr'ouvert |
|
4 |
# |
|
5 |
# This program is free software: you can redistribute it and/or modify it |
|
6 |
# under the terms of the GNU Affero General Public License as published |
|
7 |
# by the Free Software Foundation, either version 3 of the License, or |
|
8 |
# (at your option) any later version. |
|
9 |
# |
|
10 |
# This program is distributed in the hope that it will be useful, |
|
11 |
# but WITHOUT ANY WARRANTY; without even the implied warranty of |
|
12 |
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|
13 |
# GNU Affero General Public License for more details. |
|
14 |
# |
|
15 |
# You should have received a copy of the GNU Affero General Public License |
|
16 |
# along with this program. If not, see <http://www.gnu.org/licenses/>. |
|
17 | ||
18 |
from __future__ import unicode_literals |
|
19 | ||
20 |
from collections import namedtuple |
|
21 |
import inspect |
|
22 |
import os |
|
23 |
import xml.etree.ElementTree as ET |
|
24 | ||
25 |
from django.db import models |
|
26 |
from django.core.urlresolvers import reverse |
|
27 |
from django.http import HttpResponse |
|
28 |
from django.utils.translation import ugettext_lazy as _ |
|
29 |
from django.utils import six, timezone, functional |
|
30 | ||
31 |
import xmlschema |
|
32 | ||
33 |
import jsonfield |
|
34 | ||
35 |
from passerelle.base.models import BaseResource, SkipJob |
|
36 |
from passerelle.utils.api import endpoint |
|
37 |
from passerelle.utils.jsonresponse import APIError |
|
38 |
from passerelle.utils.zip import ZipTemplate |
|
39 | ||
40 |
from passerelle.utils import json, xml, sftp |
|
41 | ||
42 |
'''Base abstract models for implementing MDEL compatible requests. |
|
43 |
''' |
|
44 | ||
45 |
MDELStatus = namedtuple('MDELStatus', ['code', 'slug', 'label']) |
|
46 | ||
47 |
MDEL_STATUSES = map(lambda t: MDELStatus(*t), [ |
|
48 |
('100', 'closed', _('closed')), |
|
49 |
('20', 'rejected', _('rejected')), |
|
50 |
('19', 'accepted', _('accepted')), |
|
51 |
('17', 'information needed', _('information needed')), |
|
52 |
('16', 'in progress', _('in progress')), |
|
53 |
('15', 'invalid', _('invalid')), |
|
54 |
('14', 'imported', _('imported')), |
|
55 |
]) |
|
56 | ||
57 | ||
58 |
class Resource(BaseResource): |
|
59 |
outcoming_sftp = sftp.SFTPField( |
|
60 |
verbose_name=_('Outcoming SFTP'), |
|
61 |
blank=True, |
|
62 |
) |
|
63 |
incoming_sftp = sftp.SFTPField( |
|
64 |
verbose_name=_('Incoming SFTP'), |
|
65 |
blank=True, |
|
66 |
) |
|
67 |
recipient_siret = models.CharField( |
|
68 |
verbose_name=_('SIRET'), |
|
69 |
max_length=128) |
|
70 |
recipient_service = models.CharField( |
|
71 |
verbose_name=_('Service'), |
|
72 |
max_length=128) |
|
73 |
recipient_guichet = models.CharField( |
|
74 |
verbose_name=_('Guichet'), |
|
75 |
max_length=128) |
|
76 |
code_insee = models.CharField( |
|
77 |
verbose_name=_('INSEE Code'), |
|
78 |
max_length=6) |
|
79 | ||
80 |
xsd_path = 'schema.xsd' |
|
81 |
xsd_root_element = None |
|
82 |
flow_type = 'flow_type CHANGEME' |
|
83 |
doc_type = 'doc_type CHANGEME' |
|
84 |
zip_manifest = 'mdel/zip/manifest.json' |
|
85 |
code_insee_id = 'CODE_INSEE' |
|
86 | ||
87 |
class Meta: |
|
88 |
abstract = True |
|
89 | ||
90 |
@classmethod |
|
91 |
def get_doc_xml_schema(cls): |
|
92 |
base_dir = os.path.dirname(inspect.getfile(cls)) |
|
93 |
path = os.path.join(base_dir, cls.xsd_path) |
|
94 |
assert os.path.exists(path) |
|
95 |
return xmlschema.XMLSchema(path, converter=xmlschema.UnorderedConverter) |
|
96 | ||
97 |
@classmethod |
|
98 |
def get_doc_json_schema(cls): |
|
99 |
return xml.JSONSchemaFromXMLSchema(cls.get_doc_xml_schema(), cls.xsd_root_element).json_schema |
|
100 | ||
101 |
@classmethod |
|
102 |
def get_create_schema(cls): |
|
103 |
base_schema = cls.get_doc_json_schema() |
|
104 |
base_schema['properties'].update({ |
|
105 |
'display_id': {'type': 'string'}, |
|
106 |
'email': {'type': 'string'}, |
|
107 |
'code_insee': {'type': 'string'}, |
|
108 |
}) |
|
109 |
base_schema.setdefault('required', []).append('display_id') |
|
110 |
return base_schema |
|
111 | ||
112 |
def _handle_create(self, request): |
|
113 |
try: |
|
114 |
raw_payload = json.loads(request.body) |
|
115 |
except (TypeError, ValueError): |
|
116 |
raise APIError('Invalid payload format: JSON expected') |
|
117 |
payload = json.unflatten(raw_payload) |
|
118 |
extra = payload.pop('extra', {}) |
|
119 |
# w.c.s. pass non form fields in extra |
|
120 |
if isinstance(extra, dict): |
|
121 |
payload.update(extra) |
|
122 |
try: |
|
123 |
json.validate(payload, self.get_create_schema()) |
|
124 |
except json.ValidationError as e: |
|
125 |
raise APIError('Invalid payload format: %s' % e) |
|
126 | ||
127 |
reference = 'A-' + payload['display_id'] |
|
128 |
demand = self.demand_set.create( |
|
129 |
reference=reference, |
|
130 |
step=1, |
|
131 |
data=payload) |
|
132 |
self.add_job('push_demand', demand_id=demand.id) |
|
133 |
return self.status(request, demand) |
|
134 | ||
135 |
def push_demand(self, demand_id): |
|
136 |
demand = self.demand_set.get(id=demand_id) |
|
137 |
if not demand.push(): |
|
138 |
raise SkipJob(after_timestamp=3600 * 6) |
|
139 | ||
140 |
@endpoint(perm='can_access', |
|
141 |
methods=['get'], |
|
142 |
description=_('Demand status'), |
|
143 |
pattern=r'(?P<demand_id>\d+)/$') |
|
144 |
def demand(self, request, demand_id): |
|
145 |
try: |
|
146 |
demand = self.demand_set.get(id=demand_id) |
|
147 |
except self.demand_set.model.DoesNotExist: |
|
148 |
raise APIError('demand not found', http_status=404) |
|
149 |
return self.status(request, demand) |
|
150 | ||
151 |
def status(self, request, demand): |
|
152 |
return { |
|
153 |
'id': demand.id, |
|
154 |
'status': demand.status, |
|
155 |
'url': request.build_absolute_uri(demand.status_url), |
|
156 |
'zip_url': request.build_absolute_uri(demand.zip_url), |
|
157 |
} |
|
158 | ||
159 |
@endpoint(perm='can_access', |
|
160 |
methods=['get'], |
|
161 |
description=_('Demand document'), |
|
162 |
pattern=r'(?P<demand_id>\d+)/.*$') |
|
163 |
def document(self, request, demand_id): |
|
164 |
try: |
|
165 |
demand = self.demand_set.get(id=demand_id) |
|
166 |
except self.demand_set.model.DoesNotExist: |
|
167 |
raise APIError('demand not found', http_status=404) |
|
168 |
response = HttpResponse(demand.zip_content, content_type='application/octet-stream') |
|
169 |
response['Content-Disposition'] = 'inline; filename=%s' % demand.zip_name |
|
170 |
return response |
|
171 | ||
172 | ||
173 |
@six.python_2_unicode_compatible |
|
174 |
class Demand(models.Model): |
|
175 |
STATUS_PENDING = 'pending' |
|
176 |
STATUS_PUSHED = 'pushed' |
|
177 | ||
178 |
STATUSES = [ |
|
179 |
(STATUS_PENDING, _('pending')), |
|
180 |
(STATUS_PUSHED, _('pushed')), |
|
181 |
] |
|
182 |
for mdel_status in MDEL_STATUSES: |
|
183 |
STATUSES.append((mdel_status.slug, mdel_status.label)) |
|
184 | ||
185 |
created_at = models.DateTimeField(auto_now_add=True) |
|
186 |
updated_at = models.DateTimeField(auto_now=True) |
|
187 |
reference = models.CharField(max_length=32, null=False, unique=True) |
|
188 |
status = models.CharField(max_length=32, null=True, choices=STATUSES, default=STATUS_PENDING) |
|
189 |
pushed = models.BooleanField(default=False) |
|
190 |
step = models.IntegerField(default=0) |
|
191 |
data = jsonfield.JSONField() |
|
192 | ||
193 |
def push(self): |
|
194 |
if not self.resource.outcoming_sftp: |
|
195 |
return False |
|
196 |
try: |
|
197 |
with self.resource.outcoming_sftp.client() as client: |
|
198 |
with client.open(self.zip_name, mode='w') as fd: |
|
199 |
fd.write(self.zip_content) |
|
200 |
except sftp.paramiko.SSHException as e: |
|
201 |
self.resource.logger.error('push of demand %s failed: %s', self, e) |
|
202 |
else: |
|
203 |
self.status = self.STATUS_PUSHED |
|
204 |
self.save() |
|
205 | ||
206 |
@functional.cached_property |
|
207 |
def zip_template(self): |
|
208 |
return ZipTemplate(self.resource.zip_manifest, ctx={ |
|
209 |
'reference': self.reference, |
|
210 |
'flow_type': self.resource.flow_type, |
|
211 |
'doc_type': self.resource.doc_type, |
|
212 |
'step': self.step, |
|
213 |
'siret': self.resource.recipient_siret, |
|
214 |
'service': self.resource.recipient_service, |
|
215 |
'guichet': self.resource.recipient_guichet, |
|
216 |
'code_insee': self.data.get('code_insee', self.resource.code_insee), |
|
217 |
'document': self.document, |
|
218 |
'code_insee_id': self.resource.code_insee_id, |
|
219 |
'date': timezone.now().isoformat(), |
|
220 |
'email': self.data.get('email', ''), |
|
221 |
}) |
|
222 | ||
223 |
@property |
|
224 |
def zip_name(self): |
|
225 |
return self.zip_template.name |
|
226 | ||
227 |
@property |
|
228 |
def zip_content(self): |
|
229 |
return self.zip_template.render_to_bytes() |
|
230 | ||
231 |
@property |
|
232 |
def document(self): |
|
233 |
xml_schema = self.resource.get_doc_xml_schema() |
|
234 |
return ET.tostring( |
|
235 |
xml_schema.elements[self.resource.xsd_root_element].encode( |
|
236 |
self.data[self.resource.xsd_root_element])) |
|
237 | ||
238 |
@property |
|
239 |
def status_url(self): |
|
240 |
return reverse( |
|
241 |
'generic-endpoint', |
|
242 |
kwargs={ |
|
243 |
'connector': self.resource.get_connector_slug(), |
|
244 |
'slug': self.resource.slug, |
|
245 |
'endpoint': 'demand', |
|
246 |
'rest': '%s/' % self.id, |
|
247 |
}) |
|
248 | ||
249 |
@property |
|
250 |
def zip_url(self): |
|
251 |
return reverse( |
|
252 |
'generic-endpoint', |
|
253 |
kwargs={ |
|
254 |
'connector': self.resource.get_connector_slug(), |
|
255 |
'slug': self.resource.slug, |
|
256 |
'endpoint': 'document', |
|
257 |
'rest': '%s/%s' % (self.id, self.zip_name) |
|
258 |
}) |
|
259 | ||
260 |
def __str__(self): |
|
261 |
return '<Demand %s reference:%s flow_type:%s>' % ( |
|
262 |
self.id, |
|
263 |
self.reference, |
|
264 |
self.flow_type) |
|
265 | ||
266 |
class Meta: |
|
267 |
abstract = True |
passerelle/apps/mdel_ddpacs/migrations/0001_initial.py | ||
---|---|---|
1 |
# -*- coding: utf-8 -*- |
|
2 |
# Generated by Django 1.11.20 on 2019-10-18 08:53 |
|
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(max_length=32, null=True)), |
|
28 |
('pushed', models.BooleanField(default=False)), |
|
29 |
('step', models.IntegerField(default=0)), |
|
30 |
('data', jsonfield.fields.JSONField(default=dict)), |
|
31 |
], |
|
32 |
options={ |
|
33 |
'verbose_name': 'MDEL compatible DDPACS request', |
|
34 |
}, |
|
35 |
), |
|
36 |
migrations.CreateModel( |
|
37 |
name='Resource', |
|
38 |
fields=[ |
|
39 |
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), |
|
40 |
('title', models.CharField(max_length=50, verbose_name='Title')), |
|
41 |
('description', models.TextField(verbose_name='Description')), |
|
42 |
('slug', models.SlugField(unique=True, verbose_name='Identifier')), |
|
43 |
('outcoming_sftp', passerelle.utils.sftp.SFTPField(blank=True, default=None, verbose_name='Outcoming SFTP')), |
|
44 |
('incoming_sftp', passerelle.utils.sftp.SFTPField(blank=True, default=None, verbose_name='Incoming SFTP')), |
|
45 |
('recipient_siret', models.CharField(max_length=128, verbose_name='SIRET')), |
|
46 |
('recipient_service', models.CharField(max_length=128, verbose_name='Service')), |
|
47 |
('recipient_guichet', models.CharField(max_length=128, verbose_name='Guichet')), |
|
48 |
('code_insee', models.CharField(max_length=6, verbose_name='INSEE Code')), |
|
49 |
('users', models.ManyToManyField(blank=True, related_name='_resource_users_+', related_query_name='+', to='base.ApiUser')), |
|
50 |
], |
|
51 |
options={ |
|
52 |
'verbose_name': 'MDEL compatible DDPACS request builder', |
|
53 |
}, |
|
54 |
), |
|
55 |
migrations.AddField( |
|
56 |
model_name='demand', |
|
57 |
name='resource', |
|
58 |
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='mdel_ddpacs.Resource'), |
|
59 |
), |
|
60 |
] |
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 |
def create(self, request): |
|
41 |
return self._handle_create(request) |
|
42 | ||
43 | ||
44 |
class Demand(abstract.Demand): |
|
45 |
resource = models.ForeignKey(Resource) |
|
46 | ||
47 |
class Meta: |
|
48 |
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 | ||
---|---|---|
227 | 227 |
full_path = os.path.join(str(path), self.name) |
228 | 228 |
with atomic_write(full_path, dir=tmp_dir) as fd: |
229 | 229 |
self.render_to_file(fd) |
230 | ||
231 | ||
232 |
def diff_zip(one, two): |
|
233 |
differences = [] |
|
234 | ||
235 |
if not hasattr(one, 'read'): |
|
236 |
one = open(one) |
|
237 |
with one: |
|
238 |
if not hasattr(two, 'read'): |
|
239 |
two = open(two) |
|
240 |
with two: |
|
241 |
with zipfile.ZipFile(one) as one_zip: |
|
242 |
with zipfile.ZipFile(two) as two_zip: |
|
243 |
one_nl = set(one_zip.namelist()) |
|
244 |
two_nl = set(two_zip.namelist()) |
|
245 |
for name in one_nl - two_nl: |
|
246 |
differences.append('File %s only in %s' % (name, one)) |
|
247 |
for name in two_nl - one_nl: |
|
248 |
differences.append('File %s only in %s' % (name, two)) |
|
249 |
for name in one_nl & two_nl: |
|
250 |
with one_zip.open(name) as fd_one: |
|
251 |
with two_zip.open(name) as fd_two: |
|
252 |
if fd_one.read() != fd_two.read(): |
|
253 |
differences.append('File %s differs' % name) |
|
254 |
return differences |
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 zipfile |
|
22 | ||
23 |
import pytest |
|
24 |
import utils |
|
25 | ||
26 |
from passerelle.apps.mdel_ddpacs.models import Resource, Demand |
|
27 | ||
28 |
from passerelle.utils import json |
|
29 |
from passerelle.utils.zip import diff_zip |
|
30 | ||
31 | ||
32 |
@pytest.fixture(autouse=True) |
|
33 |
def resource(db): |
|
34 |
return utils.setup_access_rights(Resource.objects.create( |
|
35 |
slug='test', |
|
36 |
code_insee='66666', |
|
37 |
recipient_siret='999999', |
|
38 |
recipient_service='SERVICE', |
|
39 |
recipient_guichet='GUICHET')) |
|
40 | ||
41 | ||
42 |
@pytest.fixture |
|
43 |
def ddpacs_payload(): |
|
44 |
xmlschema = Resource.get_doc_xml_schema() |
|
45 |
return json.flatten({'PACS': xmlschema.to_dict('tests/data/pacs-doc.xml')}) |
|
46 | ||
47 | ||
48 |
def test_create_demand(app, resource, ddpacs_payload, freezer): |
|
49 |
freezer.move_to('2019-01-01') |
|
50 | ||
51 |
# Push new demand |
|
52 |
payload = { |
|
53 |
'display_id': '1-1', |
|
54 |
} |
|
55 |
payload.update(ddpacs_payload) |
|
56 |
assert Demand.objects.count() == 0 |
|
57 |
assert resource.jobs_set().count() == 0 |
|
58 |
resp = app.post_json('/mdel-ddpacs/test/create?raise=1', params=payload) |
|
59 |
assert resp.json['err'] == 0 |
|
60 |
assert resp.json['status'] == 'pending' |
|
61 |
assert Demand.objects.count() == 1 |
|
62 |
assert resource.jobs_set().count() == 1 |
|
63 | ||
64 |
url = resp.json['url'] |
|
65 |
zip_url = resp.json['zip_url'] |
|
66 | ||
67 |
# Check demand status URL |
|
68 |
status = app.get(url) |
|
69 |
assert status.json['err'] == 0 |
|
70 |
assert status.json == resp.json |
|
71 | ||
72 |
# Check demand document URL |
|
73 |
zip_document = app.get(zip_url) |
|
74 |
with io.BytesIO(zip_document.body) as fd: |
|
75 |
differences = diff_zip('tests/data/mdel_ddpacs_expected.zip', fd) |
|
76 |
assert not differences, differences |
|
77 | ||
78 |
# Check job is skipped as no SFTP is configured |
|
79 |
assert resource.jobs_set().get().after_timestamp is None |
|
80 |
resource.jobs() |
|
81 |
assert resource.jobs_set().get().after_timestamp is not None |
|
82 | ||
83 | ||
84 | ||
0 |
- |