Projet

Général

Profil

0007-toulouse_smart-add-update-intervention-endpoint-5523.patch

Nicolas Roche, 27 juillet 2021 19:25

Télécharger (20,7 ko)

Voir les différences:

Subject: [PATCH 7/7] toulouse_smart: add update-intervention endpoint (#55230)

 .../migrations/0003_smartrequest.py           |  54 ++++++
 passerelle/contrib/toulouse_smart/models.py   | 125 +++++++++++++-
 passerelle/contrib/toulouse_smart/schemas.py  |  45 +++++
 tests/test_toulouse_smart.py                  | 160 +++++++++++++++++-
 4 files changed, 382 insertions(+), 2 deletions(-)
 create mode 100644 passerelle/contrib/toulouse_smart/migrations/0003_smartrequest.py
passerelle/contrib/toulouse_smart/migrations/0003_smartrequest.py
1
# -*- coding: utf-8 -*-
2
# Generated by Django 1.11.29 on 2021-07-26 14:50
3
from __future__ import unicode_literals
4

  
5
import django.contrib.postgres.fields.jsonb
6
import django.db.models.deletion
7
from django.db import migrations, models
8

  
9

  
10
class Migration(migrations.Migration):
11

  
12
    dependencies = [
13
        ('toulouse_smart', '0002_auto_20210726_1648'),
14
    ]
15

  
16
    operations = [
17
        migrations.CreateModel(
18
            name='SmartRequest',
19
            fields=[
20
                (
21
                    'id',
22
                    models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
23
                ),
24
                ('trigger', models.CharField(blank=True, max_length=64, null=True, verbose_name='Trigger')),
25
                ('payload', django.contrib.postgres.fields.jsonb.JSONField(default=dict)),
26
                ('result', django.contrib.postgres.fields.jsonb.JSONField(default=dict)),
27
                (
28
                    'status',
29
                    models.CharField(
30
                        choices=[
31
                            ('registered', 'Registered'),
32
                            ('sent', 'Sent'),
33
                            ('service-error', 'Service error'),
34
                            ('wcs-error', 'WCS error'),
35
                        ],
36
                        default='registered',
37
                        max_length=20,
38
                    ),
39
                ),
40
                (
41
                    'resource',
42
                    models.ForeignKey(
43
                        on_delete=django.db.models.deletion.CASCADE,
44
                        related_name='smart_requests',
45
                        to='toulouse_smart.WcsRequest',
46
                        verbose_name='SmartRequest',
47
                    ),
48
                ),
49
            ],
50
            options={
51
                'ordering': ['pk', 'status'],
52
            },
53
        ),
54
    ]
passerelle/contrib/toulouse_smart/models.py
10 10
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12 12
# GNU Affero General Public License for more details.
13 13
#
14 14
# You should have received a copy of the GNU Affero General Public License
15 15
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
16 16

  
17 17
import datetime
18
import json
18 19
from uuid import uuid4
19 20

  
20 21
import lxml.etree as ET
22
from django.conf import settings
21 23
from django.contrib.postgres.fields import JSONField
22
from django.db import models
24
from django.db import models, transaction
25
from django.utils.six.moves.urllib import parse as urlparse
23 26
from django.utils.text import slugify
24 27
from django.utils.timezone import now
25 28
from django.utils.translation import ugettext_lazy as _
26 29
from requests import RequestException
27 30

  
28 31
from passerelle.base.models import BaseResource, HTTPResource, SkipJob
29 32
from passerelle.utils import xml
30 33
from passerelle.utils.api import endpoint
31 34
from passerelle.utils.jsonresponse import APIError
35
from passerelle.utils.wcs import WcsApi, WcsApiError
32 36

  
33 37
from . import schemas
34 38

  
35 39

  
36 40
class ToulouseSmartResource(BaseResource, HTTPResource):
37 41
    category = _('Business Process Connectors')
38 42

  
39 43
    webservice_base_url = models.URLField(_('Webservice Base URL'))
......
245 249
            wcs_request.status = 'error'
246 250
            err_desc = 'invalid json, got: %s' % response.text
247 251
            wcs_request.result = err_desc
248 252
            wcs_request.save()
249 253
            raise APIError(err_desc)
250 254
        wcs_request.status = 'sent'
251 255
        wcs_request.save()
252 256

  
257
    @endpoint(
258
        name='update-intervention',
259
        methods=['post'],
260
        description=_('Update an intervention status'),
261
        perm='can_access',
262
        parameters={
263
            'id': {'description': _('Intervention identifier')},
264
            'trigger': {'description': _('W.C.S trigger')},
265
        },
266
        post={'request_body': {'schema': {'application/json': schemas.UPDATE_SCHEMA}}},
267
    )
268
    def update_intervention(self, request, uuid, trigger, post_data):
269
        try:
270
            wcs_request = self.wcs_requests.get(uuid=uuid)
271
        except WcsRequest.DoesNotExist:
272
            raise APIError("Cannot find intervention '%s'" % uuid, http_status=400)
273
        smart_request = wcs_request.smart_requests.create(
274
            trigger=trigger,
275
            payload=post_data,
276
        )
277
        smart_request.status = 'registered'
278
        smart_request.save()
279
        try:
280
            self.update_intervention_job(
281
                wcs_request_pk=wcs_request.pk,
282
                smart_request_id=smart_request.id,
283
            )
284
        except SkipJob:
285
            self.add_job(
286
                'update_intervention_job',
287
                wcs_request_pk=wcs_request.pk,
288
                smart_request_id=smart_request.id,
289
            )
290
        return {
291
            'data': {
292
                'wcs_form_url': wcs_request.wcs_form_url,
293
                'wcs_form_number': wcs_request.wcs_form_number,
294
                'uuid': wcs_request.uuid,
295
                'trigger': smart_request.trigger,
296
                'payload': smart_request.payload,
297
                'result': smart_request.result,
298
                'status': smart_request.status,
299
            }
300
        }
301

  
302
    def update_intervention_job(self, *args, **kwargs):
303
        smarts_requests = SmartRequest.objects.select_for_update().filter(
304
            resource_id=kwargs['wcs_request_pk'],
305
            status='registered',
306
        )
307
        exception = None
308
        with transaction.atomic():
309
            smart_request = smarts_requests[0]
310
            if smart_request.id != kwargs['smart_request_id']:
311
                # wait previous triggers registered are sent
312
                raise SkipJob()
313

  
314
        base_url = '%sjump/trigger/%s' % (smart_request.resource.wcs_form_url, smart_request.trigger)
315
        scheme, netloc, path, params, query, fragment = urlparse.urlparse(base_url)
316
        services = settings.KNOWN_SERVICES.get('wcs', {})
317
        service = None
318
        for service in services.values():
319
            remote_url = service.get('url')
320
            r_scheme, r_netloc, r_path, r_params, r_query, r_fragment = urlparse.urlparse(remote_url)
321
            if r_scheme == scheme and r_netloc == netloc:
322
                break
323
        else:
324
            smart_request.status = 'service-error'
325
            err_desc = 'Cannot find wcs service for %s' % base_url
326
            smart_request.result = err_desc
327
            smart_request.save()
328
            raise APIError(err_desc)
329

  
330
        wcs_api = WcsApi(base_url, orig=service.get('orig'), key=service.get('secret'))
331
        headers = {
332
            'Content-Type': 'application/json',
333
            'Accept': 'application/json',
334
        }
335
        try:
336
            result = wcs_api.post_json(smart_request.payload, [], headers=headers)
337
        except WcsApiError as e:
338
            try:
339
                result = json.loads(e.args[3])
340
            except TypeError:
341
                # retry on transport error
342
                raise SkipJob()
343
        smart_request.result = result
344
        if result['err']:
345
            smart_request.status = 'wcs-error'
346
            smart_request.save()
347
            raise APIError(result.get('err_class', 'wcs error'))
348
        smart_request.status = 'sent'
349
        smart_request.save()
350

  
253 351

  
254 352
class Cache(models.Model):
255 353
    resource = models.ForeignKey(
256 354
        verbose_name=_('Resource'),
257 355
        to=ToulouseSmartResource,
258 356
        on_delete=models.CASCADE,
259 357
        related_name='cache_entries',
260 358
    )
......
283 381
        default='registered',
284 382
        choices=(
285 383
            ('registered', _('Registered')),
286 384
            ('sent', _('Sent')),
287 385
            ('service-error', _('Service error')),
288 386
            ('wcs-error', _('WCS error')),
289 387
        ),
290 388
    )
389

  
390

  
391
class SmartRequest(models.Model):
392
    resource = models.ForeignKey(
393
        verbose_name=_('SmartRequest'),
394
        to=WcsRequest,
395
        on_delete=models.CASCADE,
396
        related_name='smart_requests',
397
    )
398
    trigger = models.CharField(_('Trigger'), blank=True, null=True, max_length=64)
399
    payload = JSONField(default=dict)
400
    result = JSONField(default=dict)
401
    status = models.CharField(
402
        max_length=20,
403
        default='registered',
404
        choices=(
405
            ('registered', _('Registered')),
406
            ('sent', _('Sent')),
407
            ('service-error', _('Service error')),
408
            ('wcs-error', _('WCS error')),
409
        ),
410
    )
411

  
412
    class Meta:
413
        ordering = ['pk', 'status']
passerelle/contrib/toulouse_smart/schemas.py
119 119
        'submitterType',
120 120
        'external_number',
121 121
        'external_status',
122 122
        'address',
123 123
        'form_url',
124 124
    ],
125 125
    'merge_extra': True,
126 126
}
127

  
128
UPDATE_SCHEMA = {
129
    '$schema': 'http://json-schema.org/draft-04/schema#',
130
    'type': 'object',
131
    'properties': {
132
        'data': {
133
            'type': 'object',
134
            'properties': {
135
                'status': {
136
                    'description': "Nouveau Statut de l'intervention",
137
                    'type': 'string',
138
                    'maxLength': 20,
139
                },
140
                'date_planification': {
141
                    'description': "Date de planification de l'intervention",
142
                    'type': 'string',
143
                    'pattern': '[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}',
144
                },
145
                'date_mise_en_securite': {
146
                    'description': "Date de mise en securite de l'intervention",
147
                    'type': 'string',
148
                    'pattern': '[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}',
149
                },
150
                'type_retour': {
151
                    'description': "Type du retour",
152
                    'type': 'string',
153
                },
154
                'type_retour_cloture': {
155
                    'description': "Type du retour de la clôture",
156
                    'type': 'string',
157
                },
158
                'libelle_cloture': {
159
                    'description': "Libellé de la clôture",
160
                    'type': 'string',
161
                },
162
                'commentaire_cloture': {
163
                    'description': "Commentaire de la clôture",
164
                    'type': 'string',
165
                },
166
            },
167
            'required': ['status'],
168
        },
169
    },
170
    'required': ['data'],
171
}
tests/test_toulouse_smart.py
25 25
import httmock
26 26
import lxml.etree as ET
27 27
import mock
28 28
import pytest
29 29
import utils
30 30
from test_manager import login
31 31

  
32 32
from passerelle.base.models import Job
33
from passerelle.contrib.toulouse_smart.models import ToulouseSmartResource, WcsRequest
33
from passerelle.contrib.toulouse_smart.models import SmartRequest, ToulouseSmartResource, WcsRequest
34 34

  
35 35
TEST_BASE_DIR = os.path.join(os.path.dirname(__file__), 'data', 'toulouse_smart')
36 36

  
37 37

  
38 38
@pytest.fixture
39 39
def smart(db):
40 40
    return utils.make_resource(
41 41
        ToulouseSmartResource,
......
43 43
        slug='test',
44 44
        description='Test',
45 45
        webservice_base_url='https://smart.example.com/',
46 46
        basic_auth_username='username',
47 47
        basic_auth_password='password',
48 48
    )
49 49

  
50 50

  
51
@pytest.fixture
52
def wcs_service(settings):
53
    wcs_service = {
54
        'default': {
55
            'title': 'test',
56
            'url': 'https://wcs.example.com',
57
            'secret': 'xxx',
58
            'orig': 'passerelle',
59
        },
60
    }
61
    settings.KNOWN_SERVICES = {'wcs': wcs_service}
62
    return wcs_service
63

  
64

  
51 65
def mock_response(*path_contents):
52 66
    def decorator(func):
53 67
        @httmock.urlmatch()
54 68
        def error(url, request):
55 69
            assert False, 'request to %s' % url.geturl()
56 70

  
57 71
        def register(path, payload, content, status_code=200):
58 72
            @httmock.urlmatch(path=path)
......
480 494
@mock.patch("django.db.models.fields.UUIDField.get_default", return_value=UUID)
481 495
def test_create_intervention_content_error(mocked_uuid, app, freezer, smart):
482 496
    freezer.move_to('2021-07-08 00:00:00')
483 497
    resp = app.post_json(URL + 'create-intervention/', params=CREATE_INTERVENTION_PAYLOAD)
484 498
    assert resp.json['err']
485 499
    wcs_request = smart.wcs_requests.get(uuid=UUID)
486 500
    assert wcs_request.status == 'error'
487 501
    assert 'invalid json' in wcs_request.result
502

  
503

  
504
UPDATE_INTERVENTION_PAYLOAD = {
505
    'data': {
506
        'status': 'close manque info',
507
        'type_retour_cloture': 'Smart non Fait',
508
        'libelle_cloture': "rien à l'adresse indiquée",
509
        'commentaire_cloture': 'le commentaire',
510
    }
511
}
512
UPDATE_INTERVENTION_QUERY = UPDATE_INTERVENTION_PAYLOAD
513
WCS_RESPONSE_SUCCESS = '{"err": 0, "url": null}'
514
WCS_RESPONSE_ERROR = '{"err": 1, "err_class": "Access denied", "err_desc": null}'
515

  
516

  
517
@mock_response(
518
    ['/v1/type-intervention', None, INTERVENTION_TYPES],
519
    ['/v1/intervention', CREATE_INTERVENTION_QUERY, get_json_file('create_intervention')],
520
    ['/foo/2/jump/trigger/my-trigger', UPDATE_INTERVENTION_QUERY, WCS_RESPONSE_SUCCESS],
521
)
522
@mock.patch("django.db.models.fields.UUIDField.get_default", return_value=UUID)
523
def test_update_intervention(mocked_uuid, app, smart, wcs_service):
524
    resp = app.post_json(URL + 'create-intervention/', params=CREATE_INTERVENTION_PAYLOAD)
525
    assert not resp.json['err']
526
    assert CREATE_INTERVENTION_QUERY['notificationUrl'] == 'update-intervention?uuid=%s' % str(UUID)
527
    wcs_request = smart.wcs_requests.get(uuid=UUID)
528
    assert wcs_request.status == 'sent'
529

  
530
    url = URL + 'update-intervention?uuid=%s&trigger=my-trigger' % str(UUID)
531
    resp = app.post_json(url, params=UPDATE_INTERVENTION_PAYLOAD)
532
    assert not resp.json['err']
533
    assert resp.json['data']['uuid'] == str(UUID)
534
    assert resp.json['data']['trigger'] == 'my-trigger'
535
    assert resp.json['data']['payload']['data']['type_retour_cloture'] == 'Smart non Fait'
536
    smart_request = smart.wcs_requests.get(uuid=UUID).smart_requests.get()
537
    assert smart_request.status == 'sent'
538
    assert smart_request.trigger == 'my-trigger'
539
    assert smart_request.result == {'err': 0, 'url': None}
540

  
541

  
542
def test_update_intervention_wrong_uuid(app, smart):
543
    with pytest.raises(WcsRequest.DoesNotExist):
544
        smart.wcs_requests.get(uuid=UUID)
545

  
546
    url = URL + 'update-intervention?uuid=%s&trigger=my-trigger' % str(UUID)
547
    resp = app.post_json(url, params=UPDATE_INTERVENTION_PAYLOAD, status=400)
548
    assert resp.json['err']
549
    assert 'Cannot find intervention' in resp.json['err_desc']
550
    assert SmartRequest.objects.count() == 0
551

  
552

  
553
@mock_response(
554
    ['/v1/type-intervention', None, INTERVENTION_TYPES],
555
    ['/v1/intervention', CREATE_INTERVENTION_QUERY, get_json_file('create_intervention')],
556
)
557
@mock.patch("django.db.models.fields.UUIDField.get_default", return_value=UUID)
558
def test_update_intervention_job_wrong_service(mocked_uuid, app, smart, wcs_service):
559
    resp = app.post_json(URL + 'create-intervention/', params=CREATE_INTERVENTION_PAYLOAD)
560
    assert not resp.json['err']
561

  
562
    wcs_service['default']['url'] = 'http://wrong.example.com'
563
    url = URL + 'update-intervention?uuid=%s&trigger=my-trigger' % str(UUID)
564
    resp = app.post_json(url, params=UPDATE_INTERVENTION_PAYLOAD)
565
    assert resp.json['err']
566
    smart_request = smart.wcs_requests.get(uuid=UUID).smart_requests.get()
567
    assert smart_request.status == 'service-error'
568
    assert 'Cannot find wcs service' in smart_request.result
569

  
570

  
571
@mock_response(
572
    ['/v1/type-intervention', None, INTERVENTION_TYPES],
573
    ['/v1/intervention', CREATE_INTERVENTION_QUERY, get_json_file('create_intervention')],
574
    ['/foo/2/jump/trigger/inexistant-trigger', UPDATE_INTERVENTION_QUERY, WCS_RESPONSE_ERROR, 403],
575
)
576
@mock.patch("django.db.models.fields.UUIDField.get_default", return_value=UUID)
577
def test_update_intervention_job_wcs_error(mocked_uuid, app, smart, wcs_service):
578
    resp = app.post_json(URL + 'create-intervention/', params=CREATE_INTERVENTION_PAYLOAD)
579
    assert not resp.json['err']
580

  
581
    url = URL + 'update-intervention?uuid=%s&trigger=inexistant-trigger' % str(UUID)
582
    resp = app.post_json(url, params=UPDATE_INTERVENTION_PAYLOAD)
583
    assert resp.json['err']
584
    smart_request = smart.wcs_requests.get(uuid=UUID).smart_requests.get()
585
    assert smart_request.status == 'wcs-error'
586
    assert smart_request.result == {'err': 1, 'err_class': 'Access denied', 'err_desc': None}
587

  
588

  
589
@mock_response(
590
    ['/v1/type-intervention', None, INTERVENTION_TYPES],
591
    ['/v1/intervention', CREATE_INTERVENTION_QUERY, get_json_file('create_intervention')],
592
    ['/foo/2/jump/trigger/my-trigger', UPDATE_INTERVENTION_QUERY, None, 500],
593
)
594
@mock.patch("django.db.models.fields.UUIDField.get_default", return_value=UUID)
595
def test_update_intervention_job_transport_error(mocked_uuid, app, freezer, smart, wcs_service):
596
    freezer.move_to('2021-07-08 00:00:00')
597
    resp = app.post_json(URL + 'create-intervention/', params=CREATE_INTERVENTION_PAYLOAD)
598
    assert not resp.json['err']
599

  
600
    url = URL + 'update-intervention?uuid=%s&trigger=my-trigger' % str(UUID)
601
    resp = app.post_json(url, params=UPDATE_INTERVENTION_PAYLOAD)
602
    assert not resp.json['err']
603
    job = Job.objects.get(method_name='update_intervention_job')
604
    assert job.status == 'registered'
605
    smart_request = smart.wcs_requests.get(uuid=UUID).smart_requests.get()
606
    assert smart_request.status == 'registered'
607
    assert smart_request.result == {}
608

  
609
    freezer.move_to('2021-07-08 00:00:03')
610
    smart.jobs()
611
    job = Job.objects.get(method_name='update_intervention_job')
612
    assert job.status == 'registered'
613
    assert job.update_timestamp > job.creation_timestamp
614
    smart_request = smart.wcs_requests.get(uuid=UUID).smart_requests.get()
615
    assert smart_request.status == 'registered'
616
    assert smart_request.result == {}
617

  
618

  
619
@mock_response(
620
    ['/v1/type-intervention', None, INTERVENTION_TYPES],
621
    ['/v1/intervention', CREATE_INTERVENTION_QUERY, get_json_file('create_intervention')],
622
    ['/foo/2/jump/trigger/trigger-1', UPDATE_INTERVENTION_QUERY, None, 500],
623
)
624
@mock.patch("django.db.models.fields.UUIDField.get_default", return_value=UUID)
625
def test_update_intervention_job_order(mocked_uuid, app, freezer, smart, wcs_service):
626
    freezer.move_to('2021-07-08 00:00:00')
627
    resp = app.post_json(URL + 'create-intervention/', params=CREATE_INTERVENTION_PAYLOAD)
628
    assert not resp.json['err']
629

  
630
    url = URL + 'update-intervention?uuid=%s&trigger=trigger-1' % str(UUID)
631
    resp = app.post_json(url, params=UPDATE_INTERVENTION_PAYLOAD)
632
    assert not resp.json['err']
633
    url = URL + 'update-intervention?uuid=%s&trigger=trigger-2' % str(UUID)
634
    resp = app.post_json(url, params=UPDATE_INTERVENTION_PAYLOAD)
635
    assert not resp.json['err']
636
    assert smart.wcs_requests.get(uuid=UUID).smart_requests.count() == 2
637

  
638
    freezer.move_to('2021-07-08 00:00:03')
639
    jobs = Job.objects.filter(method_name='update_intervention_job')
640
    jobs[1].run()
641
    jobs = Job.objects.filter(method_name='update_intervention_job')
642
    assert jobs[0].status == 'registered'
643
    assert jobs[0].update_timestamp == jobs[0].creation_timestamp
644
    assert jobs[1].status == 'registered'
645
    assert jobs[1].update_timestamp > jobs[1].creation_timestamp
488
-