Projet

Général

Profil

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

Nicolas Roche (absent jusqu'au 3 avril), 26 juillet 2021 18:08

Télécharger (20,4 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   | 110 +++++++++++-
 passerelle/contrib/toulouse_smart/schemas.py  |  45 +++++
 tests/test_toulouse_smart.py                  | 162 +++++++++++++++++-
 4 files changed, 369 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'))
......
233 237
            wcs_request.result = response.json()
234 238
        except ValueError:
235 239
            wcs_request.status = 'error'
236 240
            wcs_request.save()
237 241
            raise APIError('invalid json, got: %s' % response.text)
238 242
        wcs_request.status = 'sent'
239 243
        wcs_request.save()
240 244

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

  
277
    def update_intervention_job(self, *args, **kwargs):
278
        smarts_requests = SmartRequest.objects.select_for_update().filter(
279
            resource_id=kwargs['wcs_request_pk'],
280
            status='registered',
281
        )
282
        exception = None
283
        with transaction.atomic():
284
            smart_request = smarts_requests[0]
285
            if smart_request.id != kwargs['smart_request_id']:
286
                # wait previous triggers registered are sent
287
                raise SkipJob()
288

  
289
        base_url = '%sjump/trigger/%s' % (smart_request.resource.wcs_form_url, smart_request.trigger)
290
        scheme, netloc, path, params, query, fragment = urlparse.urlparse(base_url)
291
        services = settings.KNOWN_SERVICES.get('wcs', {})
292
        service = None
293
        for service in services.values():
294
            remote_url = service.get('url')
295
            r_scheme, r_netloc, r_path, r_params, r_query, r_fragment = urlparse.urlparse(remote_url)
296
            if r_scheme == scheme and r_netloc == netloc:
297
                break
298
        else:
299
            smart_request.status = 'service-error'
300
            smart_request.save()
301
            raise APIError('Cannot find wcs service for %s' % base_url)
302

  
303
        wcs_api = WcsApi(base_url, orig=service.get('orig'), key=service.get('secret'))
304
        headers = {
305
            'Content-Type': 'application/json',
306
            'Accept': 'application/json',
307
        }
308
        try:
309
            result = wcs_api.post_json(smart_request.payload, [], headers=headers)
310
        except WcsApiError as e:
311
            try:
312
                result = json.loads(e.args[3])
313
            except TypeError:
314
                # retry on transport error
315
                raise SkipJob()
316
        smart_request.result = result
317
        if result['err']:
318
            smart_request.status = 'wcs-error'
319
            smart_request.save()
320
            raise APIError(result.get('err_class', 'wcs error'))
321
        smart_request.status = 'sent'
322
        smart_request.save()
323

  
241 324

  
242 325
class Cache(models.Model):
243 326
    resource = models.ForeignKey(
244 327
        verbose_name=_('Resource'),
245 328
        to=ToulouseSmartResource,
246 329
        on_delete=models.CASCADE,
247 330
        related_name='cache_entries',
248 331
    )
......
271 354
        default='registered',
272 355
        choices=(
273 356
            ('registered', _('Registered')),
274 357
            ('sent', _('Sent')),
275 358
            ('service-error', _('Service error')),
276 359
            ('wcs-error', _('WCS error')),
277 360
        ),
278 361
    )
362

  
363

  
364
class SmartRequest(models.Model):
365
    resource = models.ForeignKey(
366
        verbose_name=_('SmartRequest'),
367
        to=WcsRequest,
368
        on_delete=models.CASCADE,
369
        related_name='smart_requests',
370
    )
371
    trigger = models.CharField(_('Trigger'), blank=True, null=True, max_length=64)
372
    payload = JSONField(default=dict)
373
    result = JSONField(default=dict)
374
    status = models.CharField(
375
        max_length=20,
376
        default='registered',
377
        choices=(
378
            ('registered', _('Registered')),
379
            ('sent', _('Sent')),
380
            ('service-error', _('Service error')),
381
            ('wcs-error', _('WCS error')),
382
        ),
383
    )
384

  
385
    class Meta:
386
        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)
......
476 490
    freezer.move_to('2021-07-08 00:00:00')
477 491
    resp = app.post_json(URL + 'create-intervention/', params=CREATE_INTERVENTION_PAYLOAD)
478 492
    assert not resp.json['err']
479 493
    job = Job.objects.get(method_name='create_intervention_job')
480 494
    assert job.status == 'failed'
481 495
    assert 'invalid json' in job.status_details['error_summary']
482 496
    wcs_request = smart.wcs_requests.get(uuid=UUID)
483 497
    assert wcs_request.status == 'error'
498

  
499

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

  
512

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

  
526
    assert Job.objects.filter(method_name='update_intervention_job').count() == 0
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
    job = Job.objects.get(method_name='update_intervention_job')
534
    assert job.status == 'completed'
535
    smart_request = smart.wcs_requests.get(uuid=UUID).smart_requests.get()
536
    assert smart_request.trigger == 'my-trigger'
537
    assert smart_request.status == 'sent'
538

  
539

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

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

  
550

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

  
560
    wcs_service['default']['url'] = 'http://wrong.example.com'
561
    url = URL + 'update-intervention?uuid=%s&trigger=my-trigger' % str(UUID)
562
    resp = app.post_json(url, params=UPDATE_INTERVENTION_PAYLOAD)
563
    assert not resp.json['err']
564
    job = Job.objects.get(method_name='update_intervention_job')
565
    assert job.status == 'failed'
566
    assert 'Cannot find wcs service' in job.status_details['error_summary']
567
    smart_request = smart.wcs_requests.get(uuid=UUID).smart_requests.get()
568
    assert smart_request.status == 'service-error'
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 not resp.json['err']
584
    job = Job.objects.get(method_name='update_intervention_job')
585
    assert job.status == 'failed'
586
    assert 'Access denied' in job.status_details['error_summary']
587
    smart_request = smart.wcs_requests.get(uuid=UUID).smart_requests.get()
588
    assert smart_request.status == 'wcs-error'
589

  
590

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

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

  
610
    freezer.move_to('2021-07-08 00:00:03')
611
    smart.jobs()
612
    job = Job.objects.get(method_name='update_intervention_job')
613
    assert job.status == 'registered'
614
    assert job.update_timestamp > job.creation_timestamp
615

  
616

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

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

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