Projet

Général

Profil

0001-gesbac-initial-connector-35325.patch

Serghei Mihai (congés, retour 15/05), 20 septembre 2019 18:29

Télécharger (24,1 ko)

Voir les différences:

Subject: [PATCH] gesbac: initial connector (#35325)

 passerelle/apps/gesbac/__init__.py            |   0
 .../apps/gesbac/migrations/0001_initial.py    |  57 +++
 passerelle/apps/gesbac/migrations/__init__.py |   0
 passerelle/apps/gesbac/models.py              | 461 ++++++++++++++++++
 passerelle/settings.py                        |   1 +
 passerelle/utils/__init__.py                  |   1 +
 tests/test_gesbac.py                          | 154 ++++++
 7 files changed, 674 insertions(+)
 create mode 100644 passerelle/apps/gesbac/__init__.py
 create mode 100644 passerelle/apps/gesbac/migrations/0001_initial.py
 create mode 100644 passerelle/apps/gesbac/migrations/__init__.py
 create mode 100644 passerelle/apps/gesbac/models.py
 create mode 100644 tests/test_gesbac.py
passerelle/apps/gesbac/migrations/0001_initial.py
1
# -*- coding: utf-8 -*-
2
# Generated by Django 1.11.20 on 2019-09-19 15:23
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', '0014_auto_20190820_0914'),
17
    ]
18

  
19
    operations = [
20
        migrations.CreateModel(
21
            name='Form',
22
            fields=[
23
                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
24
                ('form_id', models.CharField(max_length=64)),
25
                ('creation_datetime', models.DateTimeField(auto_now_add=True)),
26
                ('filename', models.CharField(max_length=128, null=True)),
27
                ('status', models.CharField(choices=[(b'new', b'New'), (b'sent', b'Sent'), (b'closed', b'Closed')], default=b'new', max_length=8)),
28
                ('demand_data', jsonfield.fields.JSONField(default=dict)),
29
                ('card_data', jsonfield.fields.JSONField(default=dict)),
30
            ],
31
            options={
32
                'get_latest_by': 'creation_datetime',
33
            },
34
        ),
35
        migrations.CreateModel(
36
            name='Gesbac',
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(default=None, verbose_name='Outcoming SFTP')),
43
                ('incoming_sftp', passerelle.utils.sftp.SFTPField(default=None, verbose_name='Incoming SFTP')),
44
                ('output_files_prefix', models.CharField(max_length=32, verbose_name='Output files prefix')),
45
                ('input_files_prefix', models.CharField(max_length=32, verbose_name='Input files prefix')),
46
                ('users', models.ManyToManyField(blank=True, related_name='_gesbac_users_+', related_query_name='+', to='base.ApiUser')),
47
            ],
48
            options={
49
                'verbose_name': 'Gesbac',
50
            },
51
        ),
52
        migrations.AddField(
53
            model_name='form',
54
            name='resource',
55
            field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='gesbac.Gesbac'),
56
        ),
57
    ]
passerelle/apps/gesbac/models.py
1
# -*- coding: utf-8 -*-
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
import csv
18

  
19
from collections import OrderedDict
20

  
21
from django.db import models
22
from django.utils import six
23
from django.utils.translation import ugettext_lazy as _
24
from django.http import Http404
25

  
26
from passerelle.base.models import BaseResource
27
from passerelle.utils.api import endpoint, APIError
28
from passerelle.utils import SFTPField
29

  
30
from jsonfield import JSONField
31

  
32
CSV_DELIMITER = ';'
33

  
34
APPLICANT_SCHEMA = OrderedDict((
35
    ("form_id", {
36
        "type": "string",
37
        "required": True
38
    }),
39
    ("demand_date", {
40
        "type": "string",
41
        "pattern": "^[0-9]{8}$",
42
        "required": True
43
    }),
44
    ("demand_time", {
45
        "type": "string",
46
        "pattern": "^[0-9]{6}$",
47
        "required": True
48
    }),
49
    ("producer_code", {
50
        "type": "integer",
51
        "required": True
52
    }),
53
    ("invariant_number", {
54
        "type": "string",
55
        "required": False,
56
        "maxLength": 10,
57
        "default": ""
58
    }),
59
    ("city_insee_code", {
60
        "type": "string",
61
        "required": True
62
    }),
63
    ("street_rivoli_code", {
64
        "type": "string",
65
        "required": True
66
    }),
67
    ("street_name", {
68
        "type": "string",
69
        "required": False,
70
        "default": ""
71
    }),
72
    ("address_complement", {
73
        "type": "string",
74
        "maxLength": 32,
75
        "required": False,
76
        "default": ""
77
    }),
78
    ("street_number", {
79
        "type": "integer",
80
        "required": False,
81
        "default": 0
82
    }),
83
    ("bis_ter", {
84
        "type": "string",
85
        "maxLength": 3,
86
        "required": False,
87
        "default": ""
88
    }),
89
    ("building", {
90
        "type": "string",
91
        "maxLength": 5,
92
        "required": False,
93
        "default": ""
94
    }),
95
    ("hall", {
96
        "type": "string",
97
        "maxLength": 5,
98
        "required": False,
99
        "default": ""
100
    }),
101
    ("appartment_number", {
102
        "type": "string",
103
        "maxLength": 5,
104
        "required": False,
105
        "default": ""
106
    }),
107
    ("producer_social_reason", {
108
        "type": "string",
109
        "maxLength": 38,
110
        "required": False,
111
        "default": ""
112
    }),
113
    ("producer_title_code", {
114
        "type": "integer",
115
        "required": False,
116
        "default": 0
117
    }),
118
    ("producer_last_name", {
119
        "type": "string",
120
        "maxLength": 38,
121
        "required": False,
122
        "default": ""
123
    }),
124
    ("producer_first_name", {
125
        "type": "string",
126
        "maxLength": 32,
127
        "required": False,
128
        "default": ""
129
    }),
130
    ("producer_phone", {
131
        "type": "string",
132
        "maxLength": 20,
133
        "required": False,
134
        "default": ""
135
    }),
136
    ("producer_email", {
137
        "type": "string",
138
        "maxLength": 50,
139
        "required": False,
140
        "default": ""
141
    }),
142
    ("owner_last_name", {
143
        "type": "string",
144
        "maxLength": 38,
145
        "required": False,
146
        "default": ""
147
    }),
148
    ("owner_first_name", {
149
        "type": "string",
150
        "maxLength": 32,
151
        "required": False,
152
        "default": ""
153
    }),
154
    ("owner_phone", {
155
        "type": "string",
156
        "maxLength": 20,
157
        "required": False,
158
        "default": ""
159
    }),
160
    ("owner_email", {
161
        "type": "string",
162
        "maxLength": 50,
163
        "required": False,
164
        "default": ""
165
    }),
166
    ("activity_code", {
167
        "type": "integer",
168
        "required": False,
169
        "default": 0
170
    }),
171
    ("family_members_number", {
172
        "type": "integer",
173
        "required": False,
174
        "default": 0
175
    }),
176
    ("houses_number", {
177
        "type": "integer",
178
        "required": False,
179
        "default": 0
180
    }),
181
    ("t1_flats_number", {
182
        "type": "integer",
183
        "required": False,
184
        "default": 0
185
    }),
186
    ("t2_flats_number", {
187
        "type": "integer",
188
        "required": False,
189
        "default": 0
190
    }),
191
    ("t3_flats_number", {
192
        "type": "integer",
193
        "required": False,
194
        "default": 0
195
    }),
196
    ("t4_flats_number", {
197
        "type": "integer",
198
        "required": False,
199
        "default": 0
200
    }),
201
    ("t5_flats_number", {
202
        "type": "integer",
203
        "required": False,
204
        "default": 0
205
    }),
206
    ("t6_flats_number", {
207
        "type": "integer",
208
        "required": False,
209
        "default": 0
210
    }),
211
    ("shops_number", {
212
        "type": "integer",
213
        "required": False,
214
        "default": 0
215
    }),
216
    ("garden_size", {
217
        "type": "integer",
218
        "required": False,
219
        "default": 0
220
    }),
221
    ("expected_date", {
222
        "type": "string",
223
        "pattern": "^[0-9]{8}$",
224
        "required": False,
225
        "default": ""
226
    }),
227
    ("expected_time", {
228
        "type": "string",
229
        "pattern": "^[0-9]{4}$",
230
        "required": False,
231
        "default": ""
232
    }),
233
    ("modification_code", {
234
        "type": "integer",
235
        "required": False,
236
        "default": 0
237
    }),
238
    ("demand_reason_label", {
239
        "type": "string",
240
        "required": False,
241
        "default": ""
242
    }),
243
    ("comment", {
244
        "type": "string",
245
        "maxLength": 500,
246
        "required": False,
247
        "default": ""
248
    }))
249
)
250

  
251
CARD_SCHEMA = OrderedDict((
252
    ("card_subject", {
253
        "type": "integer",
254
        "required": True
255
    }),
256
    ("card_type", {
257
        "type": "integer",
258
        "required": True
259
    }),
260
    ("card_demand_reason", {
261
        "type": "integer",
262
        "required": True
263
    }),
264
    ("cards_quantity", {
265
        "type": "integer",
266
        "required": True,
267

  
268
    }),
269
    ("card_number", {
270
        "type": "string",
271
        "maxLength": 20,
272
        "required": False
273
    }),
274
    ("card_bar_code", {
275
        "type": "string",
276
        "maxLength": 20,
277
        "required": False,
278
        "default": "",
279
    }),
280
    ("card_code", {
281
        "type": "string",
282
        "maxLength": 20,
283
        "required": False,
284
        "default": "",
285
    }),
286
    ("card_validity_start_date", {
287
        "type": "string",
288
        "required": False,
289
        "pattern": "^[0-9]{8}$",
290
        "default": "",
291
    }),
292
    ("card_validity_end_date", {
293
        "type": "string",
294
        "required": False,
295
        "pattern": "^[0-9]{8}$",
296
        "default": "",
297
    }),
298
    ("card_comment", {
299
        "type": "string",
300
        "required": False,
301
        "maxLength": 100,
302
        "default": "",
303
    }))
304
)
305

  
306
DEMAND_SCHEMA = APPLICANT_SCHEMA.copy()
307
DEMAND_SCHEMA.update(CARD_SCHEMA)
308

  
309
SCHEMA = {
310
    "$schema": "http://json-schema.org/draft-03/schema#",
311
    "title": "Gesbac",
312
    "description": "",
313
    "type": "object",
314
    "properties": DEMAND_SCHEMA,
315
}
316

  
317

  
318
class Gesbac(BaseResource):
319
    outcoming_sftp = SFTPField(verbose_name=_('Outcoming SFTP'))
320
    incoming_sftp = SFTPField(verbose_name=_('Incoming SFTP'))
321
    output_files_prefix = models.CharField(_('Output files prefix'),
322
                                           blank=False, max_length=32)
323
    input_files_prefix = models.CharField(_('Input files prefix'),
324
                                          blank=False, max_length=32)
325

  
326
    category = _('Business Process Connectors')
327

  
328
    class Meta:
329
        verbose_name = u'Gesbac'
330

  
331
    def check_status(self):
332
        with self.outcoming_sftp.client() as out_sftp:
333
            out_sftp.listdir()
334
        with self.incoming_sftp.client() as in_sftp:
335
            in_sftp.listdir()
336

  
337
    def get_responses(self):
338
        data = []
339
        with self.incoming_sftp.client() as client:
340
            for csv_file in client.listdir():
341
                if not csv_file.startswith(self.input_files_prefix):
342
                    continue
343
                with client.open(csv_file) as fd:
344
                    for row in csv.reader(fd, delimiter=CSV_DELIMITER):
345
                        data.append(row)
346
        for card_data in data:
347
            for form in self.form_set.filter(status='sent'):
348
                if card_data[1] == form.get_gesbac_id():
349
                    form.card_data = card_data
350
                    form.status = 'closed'
351
                    form.save()
352

  
353
    def hourly(self):
354
        super(Gesbac, self).hourly()
355
        self.get_responses()
356

  
357
    def send_demand(self, form_id):
358
        form = Form.objects.get(id=form_id)
359
        form.send()
360

  
361
    @endpoint(name='create-demand',
362
              perm='can_access',
363
              description=_('Create demand'),
364
              post={
365
                  'description': _('Creates a demand file'),
366
                  'request_body': {
367
                      'schema': {
368
                          'application/json': SCHEMA
369
                      }
370
                  }
371
              }
372
    )
373
    def create_demand(self, request, post_data):
374
        form_id = post_data['form_id']
375

  
376
        form = Form.objects.create(resource=self, form_id=form_id)
377
        post_data['form_id'] = form.get_gesbac_id()
378
        data = []
379
        applicant_data = ['E']
380
        # get applicant attributes
381
        for name, value in APPLICANT_SCHEMA.items():
382
            if value['type'] == 'string':
383
                item = post_data.get(name, '')
384
            elif value['type'] == 'integer':
385
                item = post_data.get(name, 0)
386
            applicant_data.append(item)
387
        data.append(applicant_data)
388
        # get card attributes
389
        card_data = ['CARTE', post_data['form_id']]
390
        for name, value in CARD_SCHEMA.items():
391
            if value['type'] == 'string':
392
                item = post_data.get(name, '')
393
            elif value['type'] == 'integer':
394
                item = post_data.get(name, 0)
395
            card_data.append(item)
396
        data.append(card_data)
397
        form.demand_data = data
398
        form.save()
399
        self.add_job('send_demand', form_id=form.id)
400
        return {'data': {'filename': form.get_filename()}}
401

  
402
    @endpoint(name='get-response', perm='can_access',
403
              description=_('Get response'),
404
              parameters={
405
                  'form_id': {
406
                      'description': _('Form identifier'),
407
                      'example_value': '42-01'
408
                  }
409
              }
410
    )
411
    def get_response(self, request, form_id):
412
        try:
413
            response = self.form_set.filter(status='closed',
414
                                            form_id=form_id).latest()
415
            return {'data': response.card_data}
416
        except Form.DoesNotExist:
417
            raise Http404('No response found')
418

  
419

  
420
FORM_STATUSES = (
421
    ('new', 'New'),
422
    ('sent', 'Sent'),
423
    ('closed', 'Closed')
424
)
425

  
426

  
427
class Form(models.Model):
428
    resource = models.ForeignKey(Gesbac)
429
    form_id = models.CharField(max_length=64)
430
    creation_datetime = models.DateTimeField(auto_now_add=True)
431
    filename = models.CharField(max_length=128, null=True)
432
    status = models.CharField(max_length=8, default='new',
433
                              choices=FORM_STATUSES
434
                              )
435
    demand_data = JSONField()
436
    card_data = JSONField()
437

  
438
    class Meta:
439
        get_latest_by = 'creation_datetime'
440

  
441
    def get_gesbac_id(self):
442
        return self.form_id.replace('-', '%03d' % self.id)
443

  
444
    def get_filename(self):
445
        if not self.filename:
446
            timestamp = self.creation_datetime.strftime('%y%m%d-%H%M%S')
447
            self.filename = '%s%s-%s.csv' % (self.resource.output_files_prefix,
448
                                             timestamp, self.get_gesbac_id())
449
            self.save()
450
        return self.filename
451

  
452
    def send(self):
453
        with self.resource.outcoming_sftp.client() as client:
454
            with client.open(self.get_filename(), mode='w') as fd:
455
                writer = csv.writer(fd, delimiter=CSV_DELIMITER)
456
                for row in self.demand_data:
457
                    # encode strings to ASCII
458
                    writer.writerow([item.encode('latin-1') if isinstance(item, six.string_types) else item
459
                                     for item in row])
460
        self.status = 'sent'
461
        self.save()
passerelle/settings.py
136 136
    'passerelle.apps.family',
137 137
    'passerelle.apps.feeds',
138 138
    'passerelle.apps.gdc',
139
    'passerelle.apps.gesbac',
139 140
    'passerelle.apps.jsondatastore',
140 141
    'passerelle.apps.sp_fr',
141 142
    'passerelle.apps.mobyt',
passerelle/utils/__init__.py
337 337
# legacy import, other modules keep importing to_json from passerelle.utils
338 338
from .jsonresponse import to_json
339 339
from .soap import SOAPClient, SOAPTransport
340
from .sftp import SFTPField, SFTP
tests/test_gesbac.py
1
# -*- coding: utf-8 -*-
2

  
3
# tests/test_gesbac.py
4
# Copyright (C) 2019  Entr'ouvert
5
#
6
# This program is free software: you can redistribute it and/or modify it
7
# under the terms of the GNU Affero General Public License as published
8
# by the Free Software Foundation, either version 3 of the License, or
9
# (at your option) any later version.
10
#
11
# This program is distributed in the hope that it will be useful,
12
# but WITHOUT ANY WARRANTY; without even the implied warranty of
13
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14
# GNU Affero General Public License for more details.
15
#
16
# You should have received a copy of the GNU Affero General Public License
17
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
18

  
19
import datetime
20
import pytest
21

  
22
from django.utils.timezone import now
23
from .utils import make_resource
24

  
25
from passerelle.utils import SFTP
26
from passerelle.apps.gesbac.models import Gesbac, Form
27

  
28

  
29
@pytest.fixture
30
def resource(db, sftpserver):
31
    return make_resource(
32
        Gesbac,
33
        slug='test',
34
        title='Gesbac',
35
        description='gesbac',
36
        outcoming_sftp=SFTP(
37
            'sftp://foo:bar@{server.host}:{server.port}/output/'.format(server=sftpserver)),
38
        incoming_sftp=SFTP(
39
            'sftp://foo:bar@{server.host}:{server.port}/input/'.format(server=sftpserver)),
40
        output_files_prefix='output-',
41
        input_files_prefix='input-')
42

  
43

  
44
def test_check_status(app, resource, sftpserver):
45
    with sftpserver.serve_content({'input': {'test': 'content'},
46
                                   'output': {'file': 'content'}}):
47
        resource.check_status()
48

  
49

  
50
def test_create_demand(app, resource, freezer, sftpserver):
51
    assert resource.form_set.count() == 0
52
    timestamp = now()
53
    payload = {
54
        'form_id': '42-42',
55
        'demand_date': timestamp.strftime('%Y%m%d'),
56
        'demand_time': timestamp.strftime('%H%M%S'),
57
        'producer_code': 1,
58
        'city_insee_code': '75114',
59
        'street_name': 'Château',
60
        'street_rivoli_code': 'xxxx',
61
        'producer_social_reason': 'SCOP',
62
        'producer_last_name': 'Bar',
63
        'producer_first_name': 'Foo',
64
        'producer_email': 'foo@example.com',
65
        'owner_last_name': 'Bar',
66
        'owner_first_name': 'Foo',
67
        'owner_email': 'foo@example.com',
68
        'family_members_number': 5,
69
        'houses_number': 1,
70
        'card_type': 1,
71
        'card_subject': 1,
72
        'card_demand_reason': 1,
73
        'card_demand_purpose': 1,
74
        'cards_quantity': 1
75
    }
76
    response = app.post_json('/gesbac/test/create-demand/', params=payload)
77
    assert resource.form_set.filter(status='new').count() == 1
78
    form = resource.form_set.get(status='new')
79
    assert len(form.demand_data) == 2
80
    expected_filename = '%s%s-4200142.csv' % (resource.output_files_prefix,
81
                                              timestamp.strftime('%y%m%d-%H%M%S'))
82
    assert response.json['data']['filename'] == expected_filename
83
    assert resource.form_set.filter(status='new').count() == 1
84
    with sftpserver.serve_content({'output': {expected_filename: 'content'}}):
85
        resource.jobs()
86
    assert resource.form_set.filter(status='sent').count() == 1
87

  
88
    payload['owner_email'] = 'bar@example.com'
89
    response = app.post_json('/gesbac/test/create-demand/', params=payload)
90
    assert resource.form_set.filter(status='new').count() == 1
91
    form = resource.form_set.get(status='new')
92
    assert len(form.demand_data) == 2
93
    assert response.json['data']['filename'] == '%s%s-4200242.csv' % (resource.output_files_prefix,
94
                                                                      timestamp.strftime('%y%m%d-%H%M%S'))
95
    with sftpserver.serve_content({'output': {response.json['data']['filename']: 'content'}}):
96
        resource.jobs()
97
    assert resource.form_set.filter(status='sent').count() == 2
98

  
99

  
100
def test_get_demand_response(app, resource, freezer, sftpserver):
101
    response = app.get('/gesbac/test/get-response/',
102
                       params={'form_id': '42-43'}, status=404)
103
    timestamp = now()
104
    payload = {
105
        'form_id': '42-43',
106
        'demand_date': timestamp.strftime('%Y%m%d'),
107
        'demand_time': timestamp.strftime('%H%M%S'),
108
        'producer_code': 1,
109
        'city_insee_code': '75114',
110
        'street_name': 'Château',
111
        'street_rivoli_code': 'xxxx',
112
        'producer_social_reason': 'SCOP',
113
        'producer_last_name': 'Bar',
114
        'producer_first_name': 'Foo',
115
        'producer_email': 'foo@example.com',
116
        'owner_last_name': 'Bar',
117
        'owner_first_name': 'Foo',
118
        'owner_email': 'foo@example.com',
119
        'family_members_number': 5,
120
        'houses_number': 1,
121
        'card_type': 1,
122
        'card_subject': 1,
123
        'card_demand_reason': 1,
124
        'card_demand_purpose': 1,
125
        'cards_quantity': 1
126
    }
127
    response = app.post_json('/gesbac/test/create-demand/', params=payload)
128
    data = response.json['data']
129
    with sftpserver.serve_content({'output': {data['filename']: 'content'},
130
                                   'input': {}}):
131
        resource.jobs()
132
    assert resource.form_set.filter(status='closed').count() == 0
133
    assert resource.form_set.filter(status='sent').count() == 1
134

  
135
    data = resource.form_set.get(status='sent')
136
    gesbac_id = data.get_gesbac_id()
137
    response_filename = '%s91001-090300-%s.csv' % (
138
        resource.input_files_prefix, gesbac_id)
139
    assert resource.form_set.filter(
140
        form_id='42-43', filename=response_filename, status='closed').count() == 0
141

  
142
    with sftpserver.serve_content({'input': {response_filename: 'CARTE;%s;3;2;1234;;;;;;;;' % gesbac_id}}):
143
        resource.hourly()
144

  
145
    assert resource.form_set.filter(
146
        form_id='42-43', status='closed').count() == 1
147
    response = resource.form_set.get(status='closed')
148
    assert response.card_data == ['CARTE', gesbac_id,
149
                                  '3', '2', '1234', '', '', '', '', '', '', '', '']
150
    response = app.get('/gesbac/test/get-response/',
151
                       params={'form_id': '42-43'})
152
    assert response.json['err'] == 0
153
    assert response.json['data'] == ['CARTE', gesbac_id,
154
                                     '3', '2', '1234', '', '', '', '', '', '', '', '']
0
-