Projet

Général

Profil

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

Nicolas Roche, 27 juillet 2021 17:48

Télécharger (20,6 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   | 117 ++++++++++++-
 passerelle/contrib/toulouse_smart/schemas.py  |  45 +++++
 tests/test_toulouse_smart.py                  | 160 +++++++++++++++++-
 4 files changed, 374 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 24
from django.core import serializers
23
from django.db import models
25
from django.db import models, transaction
26
from django.utils.six.moves.urllib import parse as urlparse
24 27
from django.utils.text import slugify
25 28
from django.utils.timezone import now
26 29
from django.utils.translation import ugettext_lazy as _
27 30
from requests import RequestException
28 31

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

  
34 38
from . import schemas
35 39

  
36 40

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

  
40 44
    webservice_base_url = models.URLField(_('Webservice Base URL'))
......
238 242
            wcs_request.status = 'error'
239 243
            err_desc = 'invalid json, got: %s' % response.text
240 244
            wcs_request.result = err_desc
241 245
            wcs_request.save()
242 246
            raise APIError(err_desc)
243 247
        wcs_request.status = 'sent'
244 248
        wcs_request.save()
245 249

  
250
    @endpoint(
251
        name='update-intervention',
252
        methods=['post'],
253
        description=_('Update an intervention status'),
254
        perm='can_access',
255
        parameters={
256
            'id': {'description': _('Intervention identifier')},
257
            'trigger': {'description': _('W.C.S trigger')},
258
        },
259
        post={'request_body': {'schema': {'application/json': schemas.UPDATE_SCHEMA}}},
260
    )
261
    def update_intervention(self, request, uuid, trigger, post_data):
262
        try:
263
            wcs_request = self.wcs_requests.get(uuid=uuid)
264
        except WcsRequest.DoesNotExist:
265
            raise APIError("Cannot find intervention '%s'" % uuid, http_status=400)
266
        smart_request = wcs_request.smart_requests.create(
267
            trigger=trigger,
268
            payload=post_data,
269
        )
270
        smart_request.status = 'registered'
271
        smart_request.save()
272
        try:
273
            self.update_intervention_job(
274
                wcs_request_pk=wcs_request.pk,
275
                smart_request_id=smart_request.id,
276
            )
277
        except SkipJob:
278
            self.add_job(
279
                'update_intervention_job',
280
                wcs_request_pk=wcs_request.pk,
281
                smart_request_id=smart_request.id,
282
            )
283
        wcs_data = serializers.serialize('python', [wcs_request])[0]
284
        smart_data = serializers.serialize('python', [smart_request])[0]
285
        return {'data': {'payload': post_data, 'wcs_request': wcs_data, 'smart_request': smart_data}}
286

  
287
    def update_intervention_job(self, *args, **kwargs):
288
        smarts_requests = SmartRequest.objects.select_for_update().filter(
289
            resource_id=kwargs['wcs_request_pk'],
290
            status='registered',
291
        )
292
        exception = None
293
        with transaction.atomic():
294
            smart_request = smarts_requests[0]
295
            if smart_request.id != kwargs['smart_request_id']:
296
                # wait previous triggers registered are sent
297
                raise SkipJob()
298

  
299
        base_url = '%sjump/trigger/%s' % (smart_request.resource.wcs_form_url, smart_request.trigger)
300
        scheme, netloc, path, params, query, fragment = urlparse.urlparse(base_url)
301
        services = settings.KNOWN_SERVICES.get('wcs', {})
302
        service = None
303
        for service in services.values():
304
            remote_url = service.get('url')
305
            r_scheme, r_netloc, r_path, r_params, r_query, r_fragment = urlparse.urlparse(remote_url)
306
            if r_scheme == scheme and r_netloc == netloc:
307
                break
308
        else:
309
            smart_request.status = 'service-error'
310
            err_desc = 'Cannot find wcs service for %s' % base_url
311
            smart_request.result = err_desc
312
            smart_request.save()
313
            raise APIError(err_desc)
314

  
315
        wcs_api = WcsApi(base_url, orig=service.get('orig'), key=service.get('secret'))
316
        headers = {
317
            'Content-Type': 'application/json',
318
            'Accept': 'application/json',
319
        }
320
        try:
321
            result = wcs_api.post_json(smart_request.payload, [], headers=headers)
322
        except WcsApiError as e:
323
            try:
324
                result = json.loads(e.args[3])
325
            except TypeError:
326
                # retry on transport error
327
                raise SkipJob()
328
        smart_request.result = result
329
        if result['err']:
330
            smart_request.status = 'wcs-error'
331
            smart_request.save()
332
            raise APIError(result.get('err_class', 'wcs error'))
333
        smart_request.status = 'sent'
334
        smart_request.save()
335

  
246 336

  
247 337
class Cache(models.Model):
248 338
    resource = models.ForeignKey(
249 339
        verbose_name=_('Resource'),
250 340
        to=ToulouseSmartResource,
251 341
        on_delete=models.CASCADE,
252 342
        related_name='cache_entries',
253 343
    )
......
276 366
        default='registered',
277 367
        choices=(
278 368
            ('registered', _('Registered')),
279 369
            ('sent', _('Sent')),
280 370
            ('service-error', _('Service error')),
281 371
            ('wcs-error', _('WCS error')),
282 372
        ),
283 373
    )
374

  
375

  
376
class SmartRequest(models.Model):
377
    resource = models.ForeignKey(
378
        verbose_name=_('SmartRequest'),
379
        to=WcsRequest,
380
        on_delete=models.CASCADE,
381
        related_name='smart_requests',
382
    )
383
    trigger = models.CharField(_('Trigger'), blank=True, null=True, max_length=64)
384
    payload = JSONField(default=dict)
385
    result = JSONField(default=dict)
386
    status = models.CharField(
387
        max_length=20,
388
        default='registered',
389
        choices=(
390
            ('registered', _('Registered')),
391
            ('sent', _('Sent')),
392
            ('service-error', _('Service error')),
393
            ('wcs-error', _('WCS error')),
394
        ),
395
    )
396

  
397
    class Meta:
398
        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)
......
477 491
@mock.patch("django.db.models.fields.UUIDField.get_default", return_value=UUID)
478 492
def test_create_intervention_content_error(mocked_uuid, app, freezer, smart):
479 493
    freezer.move_to('2021-07-08 00:00:00')
480 494
    resp = app.post_json(URL + 'create-intervention/', params=CREATE_INTERVENTION_PAYLOAD)
481 495
    assert resp.json['err']
482 496
    wcs_request = smart.wcs_requests.get(uuid=UUID)
483 497
    assert wcs_request.status == 'error'
484 498
    assert 'invalid json' in wcs_request.result
499

  
500

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

  
513

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

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

  
538

  
539
def test_update_intervention_wrong_uuid(app, smart):
540
    with pytest.raises(WcsRequest.DoesNotExist):
541
        smart.wcs_requests.get(uuid=UUID)
542

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

  
549

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

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

  
567

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

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

  
585

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

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

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

  
615

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

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

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