Projet

Général

Profil

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

Nicolas Roche, 31 juillet 2021 01:56

Télécharger (19,7 ko)

Voir les différences:

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

 .../migrations/0003_smartrequest.py           |  48 +++++
 passerelle/contrib/toulouse_smart/models.py   | 108 +++++++++++
 passerelle/contrib/toulouse_smart/schemas.py  |  45 +++++
 tests/test_toulouse_smart.py                  | 168 ++++++++++++++++++
 4 files changed, 369 insertions(+)
 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-30 22:37
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_20210731_0031'),
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
                ('payload', django.contrib.postgres.fields.jsonb.JSONField(default=dict)),
25
                ('result', django.contrib.postgres.fields.jsonb.JSONField(default=dict)),
26
                (
27
                    'status',
28
                    models.CharField(
29
                        choices=[('registered', 'Registered'), ('sent', 'Sent')],
30
                        default='registered',
31
                        max_length=20,
32
                    ),
33
                ),
34
                (
35
                    'resource',
36
                    models.ForeignKey(
37
                        on_delete=django.db.models.deletion.CASCADE,
38
                        related_name='smart_requests',
39
                        to='toulouse_smart.WcsRequest',
40
                        verbose_name='SmartRequest',
41
                    ),
42
                ),
43
            ],
44
            options={
45
                'ordering': ['pk', 'status'],
46
            },
47
        ),
48
    ]
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 24
from django.db import models
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 ToulouseSmartErrorRetry(APIError):
37 41
    pass
38 42

  
39 43

  
44
class ToulouseSmartErrorStop(APIError):
45
    pass
46

  
47

  
40 48
class ToulouseSmartResource(BaseResource, HTTPResource):
41 49
    category = _('Business Process Connectors')
42 50

  
43 51
    webservice_base_url = models.URLField(_('Webservice Base URL'))
44 52

  
45 53
    log_requests_errors = False
46 54

  
47 55
    class Meta:
......
241 249

  
242 250
    def create_intervention_job(self, *args, **kwargs):
243 251
        wcs_request = self.wcs_requests.get(pk=kwargs['pk'])
244 252
        try:
245 253
            wcs_request.push()
246 254
        except ToulouseSmartErrorRetry:
247 255
            raise SkipJob()
248 256

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

  
293
    def update_intervention_job(self, *args, **kwargs):
294
        smart_request = SmartRequest.objects.get(id=kwargs['id'])
295
        try:
296
            smart_request.push()
297
        except ToulouseSmartErrorRetry:
298
            raise SkipJob()
299

  
249 300

  
250 301
class Cache(models.Model):
251 302
    resource = models.ForeignKey(
252 303
        verbose_name=_('Resource'),
253 304
        to=ToulouseSmartResource,
254 305
        on_delete=models.CASCADE,
255 306
        related_name='cache_entries',
256 307
    )
......
295 346
            self.result = response.json()
296 347
        except ValueError:
297 348
            err_desc = 'invalid json, got: %s' % response.text
298 349
            self.result = err_desc
299 350
            self.save()
300 351
            raise ToulouseSmartErrorRetry(err_desc)
301 352
        self.status = 'sent'
302 353
        self.save()
354

  
355

  
356
class SmartRequest(models.Model):
357
    resource = models.ForeignKey(
358
        verbose_name=_('SmartRequest'),
359
        to=WcsRequest,
360
        on_delete=models.CASCADE,
361
        related_name='smart_requests',
362
    )
363
    payload = JSONField(default=dict)
364
    result = JSONField(default=dict)
365
    status = models.CharField(
366
        max_length=20,
367
        default='registered',
368
        choices=(
369
            ('registered', _('Registered')),
370
            ('sent', _('Sent')),
371
        ),
372
    )
373

  
374
    class Meta:
375
        ordering = ['pk', 'status']
376

  
377
    def push(self):
378
        base_url = '%shooks/update-intervention/' % (self.resource.wcs_form_api_url)
379
        scheme, netloc, path, params, query, fragment = urlparse.urlparse(base_url)
380
        services = settings.KNOWN_SERVICES.get('wcs', {})
381
        service = None
382
        for service in services.values():
383
            remote_url = service.get('url')
384
            r_scheme, r_netloc, r_path, r_params, r_query, r_fragment = urlparse.urlparse(remote_url)
385
            if r_scheme == scheme and r_netloc == netloc:
386
                break
387
        else:
388
            err_desc = 'Cannot find wcs service for %s' % base_url
389
            self.result = err_desc
390
            self.save()
391
            raise ToulouseSmartErrorStop(err_desc)
392

  
393
        wcs_api = WcsApi(base_url, orig=service.get('orig'), key=service.get('secret'))
394
        headers = {
395
            'Content-Type': 'application/json',
396
            'Accept': 'application/json',
397
        }
398
        try:
399
            result = wcs_api.post_json(self.payload, [], headers=headers)
400
        except WcsApiError as e:
401
            try:
402
                result = json.loads(e.args[3])
403
            except (ValueError):
404
                raise ToulouseSmartErrorRetry()
405
        self.result = result
406
        if result['err']:
407
            self.save()
408
            raise ToulouseSmartErrorStop(result.get('err_class', 'wcs error'))
409
        self.status = 'sent'
410
        self.save()
passerelle/contrib/toulouse_smart/schemas.py
119 119
        'submitterType',
120 120
        'external_number',
121 121
        'external_status',
122 122
        'address',
123 123
        'form_api_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
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 33
from passerelle.contrib.toulouse_smart.models import (
34
    SmartRequest,
34 35
    ToulouseSmartErrorRetry,
35 36
    ToulouseSmartResource,
36 37
    WcsRequest,
37 38
)
38 39

  
39 40
TEST_BASE_DIR = os.path.join(os.path.dirname(__file__), 'data', 'toulouse_smart')
40 41

  
41 42

  
......
47 48
        slug='test',
48 49
        description='Test',
49 50
        webservice_base_url='https://smart.example.com/',
50 51
        basic_auth_username='username',
51 52
        basic_auth_password='password',
52 53
    )
53 54

  
54 55

  
56
@pytest.fixture
57
def wcs_service(settings):
58
    wcs_service = {
59
        'default': {
60
            'title': 'test',
61
            'url': 'https://wcs.example.com',
62
            'secret': 'xxx',
63
            'orig': 'passerelle',
64
        },
65
    }
66
    settings.KNOWN_SERVICES = {'wcs': wcs_service}
67
    return wcs_service
68

  
69

  
55 70
def mock_response(*path_contents):
56 71
    def decorator(func):
57 72
        @httmock.urlmatch()
58 73
        def error(url, request):
59 74
            assert False, 'request to %s' % url.geturl()
60 75

  
61 76
        def register(path, payload, content, status_code=200):
62 77
            @httmock.urlmatch(path=path)
......
518 533
)
519 534
@mock.patch("django.db.models.fields.UUIDField.get_default", return_value=UUID)
520 535
def test_create_intervention_content_error(mocked_uuid, app, freezer, smart):
521 536
    freezer.move_to('2021-07-08 00:00:00')
522 537
    resp = app.post_json(URL + 'create-intervention/', params=CREATE_INTERVENTION_PAYLOAD)
523 538
    wcs_request = smart.wcs_requests.get(uuid=UUID)
524 539
    assert wcs_request.status == 'registered'
525 540
    assert 'invalid json' in wcs_request.result
541

  
542

  
543
UPDATE_INTERVENTION_PAYLOAD = {
544
    'data': {
545
        'status': 'close manque info',
546
        'type_retour_cloture': 'Smart non Fait',
547
        'libelle_cloture': "rien à l'adresse indiquée",
548
        'commentaire_cloture': 'le commentaire',
549
    }
550
}
551
UPDATE_INTERVENTION_QUERY = UPDATE_INTERVENTION_PAYLOAD
552
WCS_RESPONSE_SUCCESS = '{"err": 0, "url": null}'
553
WCS_RESPONSE_ERROR = '{"err": 1, "err_class": "Access denied", "err_desc": null}'
554

  
555

  
556
@mock_response(
557
    ['/v1/type-intervention', None, INTERVENTION_TYPES],
558
    ['/v1/intervention', CREATE_INTERVENTION_QUERY, get_json_file('create_intervention')],
559
    ['/api/forms/foo/2/hooks/update-intervention/', UPDATE_INTERVENTION_QUERY, WCS_RESPONSE_SUCCESS],
560
)
561
@mock.patch("django.db.models.fields.UUIDField.get_default", return_value=UUID)
562
def test_update_intervention(mocked_uuid, app, smart, wcs_service):
563
    resp = app.post_json(URL + 'create-intervention/', params=CREATE_INTERVENTION_PAYLOAD)
564
    assert not resp.json['err']
565
    assert CREATE_INTERVENTION_QUERY['notificationUrl'] == 'update-intervention?uuid=%s' % str(UUID)
566
    wcs_request = smart.wcs_requests.get(uuid=UUID)
567
    assert wcs_request.status == 'sent'
568

  
569
    url = URL + 'update-intervention?uuid=%s' % str(UUID)
570
    resp = app.post_json(url, params=UPDATE_INTERVENTION_PAYLOAD)
571
    assert not resp.json['err']
572
    assert resp.json['data']['uuid'] == str(UUID)
573
    assert resp.json['data']['payload']['data']['type_retour_cloture'] == 'Smart non Fait'
574
    smart_request = smart.wcs_requests.get(uuid=UUID).smart_requests.get()
575
    assert smart_request.status == 'sent'
576
    assert smart_request.result == {'err': 0, 'url': None}
577

  
578

  
579
@mock_response(
580
    ['/v1/type-intervention', None, INTERVENTION_TYPES],
581
    ['/v1/intervention', CREATE_INTERVENTION_QUERY, get_json_file('create_intervention')],
582
    ['/api/forms/foo/2/hooks/update-intervention/', UPDATE_INTERVENTION_QUERY, WCS_RESPONSE_SUCCESS],
583
)
584
@mock.patch("django.db.models.fields.UUIDField.get_default", return_value=UUID)
585
def test_update_intervention_async(mocked_uuid, app, smart, wcs_service):
586
    resp = app.post_json(URL + 'create-intervention/', params=CREATE_INTERVENTION_PAYLOAD)
587
    assert not resp.json['err']
588
    assert CREATE_INTERVENTION_QUERY['notificationUrl'] == 'update-intervention?uuid=%s' % str(UUID)
589
    wcs_request = smart.wcs_requests.get(uuid=UUID)
590
    assert wcs_request.status == 'sent'
591

  
592
    mocked_push = mock.patch(
593
        "passerelle.contrib.toulouse_smart.models.SmartRequest.push",
594
        side_effect=ToulouseSmartErrorRetry(),
595
    )
596
    mocked_push.start()
597

  
598
    assert Job.objects.count() == 0
599
    url = URL + 'update-intervention?uuid=%s' % str(UUID)
600
    resp = app.post_json(url, params=UPDATE_INTERVENTION_PAYLOAD)
601
    assert not resp.json['err']
602
    assert resp.json['data']['uuid'] == str(UUID)
603
    assert resp.json['data']['payload']['data']['type_retour_cloture'] == 'Smart non Fait'
604
    smart_request = smart.wcs_requests.get(uuid=UUID).smart_requests.get()
605
    assert smart_request.status == 'registered'
606

  
607
    mocked_push.stop()
608
    assert Job.objects.count() == 1
609
    job = Job.objects.get(method_name='update_intervention_job')
610
    assert job.status == 'registered'
611
    smart.jobs()
612
    job = Job.objects.get(method_name='update_intervention_job')
613
    assert job.status == 'completed'
614
    smart_request = smart.wcs_requests.get(uuid=UUID).smart_requests.get()
615
    assert smart_request.status == 'sent'
616
    assert smart_request.result == {'err': 0, 'url': None}
617

  
618

  
619
def test_update_intervention_wrong_uuid(app, smart):
620
    with pytest.raises(WcsRequest.DoesNotExist):
621
        smart.wcs_requests.get(uuid=UUID)
622

  
623
    url = URL + 'update-intervention?uuid=%s' % str(UUID)
624
    resp = app.post_json(url, params=UPDATE_INTERVENTION_PAYLOAD, status=400)
625
    assert resp.json['err']
626
    assert 'Cannot find intervention' in resp.json['err_desc']
627
    assert SmartRequest.objects.count() == 0
628

  
629

  
630
@mock_response(
631
    ['/v1/type-intervention', None, INTERVENTION_TYPES],
632
    ['/v1/intervention', CREATE_INTERVENTION_QUERY, get_json_file('create_intervention')],
633
)
634
@mock.patch("django.db.models.fields.UUIDField.get_default", return_value=UUID)
635
def test_update_intervention_job_wrong_service(mocked_uuid, app, smart, wcs_service):
636
    resp = app.post_json(URL + 'create-intervention/', params=CREATE_INTERVENTION_PAYLOAD)
637
    assert not resp.json['err']
638

  
639
    wcs_service['default']['url'] = 'http://wrong.example.com'
640
    url = URL + 'update-intervention?uuid=%s' % str(UUID)
641
    resp = app.post_json(url, params=UPDATE_INTERVENTION_PAYLOAD)
642
    assert resp.json['err']
643
    smart_request = smart.wcs_requests.get(uuid=UUID).smart_requests.get()
644
    assert smart_request.status == 'registered'
645
    assert 'Cannot find wcs service' in smart_request.result
646

  
647

  
648
@mock_response(
649
    ['/v1/type-intervention', None, INTERVENTION_TYPES],
650
    ['/v1/intervention', CREATE_INTERVENTION_QUERY, get_json_file('create_intervention')],
651
    ['/api/forms/foo/2/hooks/update-intervention/', UPDATE_INTERVENTION_QUERY, WCS_RESPONSE_ERROR, 403],
652
)
653
@mock.patch("django.db.models.fields.UUIDField.get_default", return_value=UUID)
654
def test_update_intervention_job_wcs_error(mocked_uuid, app, smart, wcs_service):
655
    resp = app.post_json(URL + 'create-intervention/', params=CREATE_INTERVENTION_PAYLOAD)
656
    assert not resp.json['err']
657

  
658
    url = URL + 'update-intervention?uuid=%s' % str(UUID)
659
    resp = app.post_json(url, params=UPDATE_INTERVENTION_PAYLOAD)
660
    assert resp.json['err']
661
    smart_request = smart.wcs_requests.get(uuid=UUID).smart_requests.get()
662
    assert smart_request.status == 'registered'
663
    assert smart_request.result == {'err': 1, 'err_class': 'Access denied', 'err_desc': None}
664

  
665

  
666
@mock_response(
667
    ['/v1/type-intervention', None, INTERVENTION_TYPES],
668
    ['/v1/intervention', CREATE_INTERVENTION_QUERY, get_json_file('create_intervention')],
669
    ['/api/forms/foo/2/hooks/update-intervention/', UPDATE_INTERVENTION_QUERY, 'bla', 500],
670
)
671
@mock.patch("django.db.models.fields.UUIDField.get_default", return_value=UUID)
672
def test_update_intervention_job_transport_error(mocked_uuid, app, freezer, smart, wcs_service):
673
    freezer.move_to('2021-07-08 00:00:00')
674
    resp = app.post_json(URL + 'create-intervention/', params=CREATE_INTERVENTION_PAYLOAD)
675
    assert not resp.json['err']
676

  
677
    url = URL + 'update-intervention?uuid=%s' % str(UUID)
678
    resp = app.post_json(url, params=UPDATE_INTERVENTION_PAYLOAD)
679
    assert not resp.json['err']
680
    job = Job.objects.get(method_name='update_intervention_job')
681
    assert job.status == 'registered'
682
    smart_request = smart.wcs_requests.get(uuid=UUID).smart_requests.get()
683
    assert smart_request.status == 'registered'
684
    assert smart_request.result == {}
685

  
686
    freezer.move_to('2021-07-08 00:00:03')
687
    smart.jobs()
688
    job = Job.objects.get(method_name='update_intervention_job')
689
    assert job.status == 'registered'
690
    assert job.update_timestamp > job.creation_timestamp
691
    smart_request = smart.wcs_requests.get(uuid=UUID).smart_requests.get()
692
    assert smart_request.status == 'registered'
693
    assert smart_request.result == {}
526
-