Projet

Général

Profil

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

Nicolas Roche, 03 août 2021 19:03

Télécharger (19,8 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   | 107 +++++++++++
 passerelle/contrib/toulouse_smart/schemas.py  |  45 +++++
 tests/test_toulouse_smart.py                  | 172 +++++++++++++++++-
 4 files changed, 371 insertions(+), 1 deletion(-)
 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
23 25
from django.db.transaction import atomic
24 26
from django.urls import reverse
27
from django.utils.six.moves.urllib import parse as urlparse
25 28
from django.utils.text import slugify
26 29
from django.utils.timezone import now
27 30
from django.utils.translation import ugettext_lazy as _
28 31
from requests import RequestException
29 32

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

  
35 39
from . import schemas
36 40

  
37 41

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

  
41 45
    webservice_base_url = models.URLField(_('Webservice Base URL'))
......
236 240
            }
237 241
        }
238 242

  
239 243
    def create_intervention_job(self, *args, **kwargs):
240 244
        wcs_request = self.wcs_requests.get(pk=kwargs['pk'])
241 245
        if not wcs_request.push():
242 246
            raise SkipJob()
243 247

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

  
288
    def update_intervention_job(self, *args, **kwargs):
289
        smart_request = SmartRequest.objects.get(id=kwargs['id'])
290
        if not smart_request.push():
291
            raise SkipJob()
292

  
244 293

  
245 294
class Cache(models.Model):
246 295
    resource = models.ForeignKey(
247 296
        verbose_name=_('Resource'),
248 297
        to=ToulouseSmartResource,
249 298
        on_delete=models.CASCADE,
250 299
        related_name='cache_entries',
251 300
    )
......
291 340
        except ValueError:
292 341
            err_desc = 'invalid json, got: %s' % response.text
293 342
            self.result = err_desc
294 343
            self.save()
295 344
            return False
296 345
        self.status = 'sent'
297 346
        self.save()
298 347
        return True
348

  
349

  
350
class SmartRequest(models.Model):
351
    resource = models.ForeignKey(
352
        verbose_name=_('SmartRequest'),
353
        to=WcsRequest,
354
        on_delete=models.CASCADE,
355
        related_name='smart_requests',
356
    )
357
    payload = JSONField(default=dict)
358
    result = JSONField(default=dict)
359
    status = models.CharField(
360
        max_length=20,
361
        default='registered',
362
        choices=(
363
            ('registered', _('Registered')),
364
            ('sent', _('Sent')),
365
        ),
366
    )
367

  
368
    class Meta:
369
        ordering = ['pk', 'status']
370

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

  
387
        wcs_api = WcsApi(base_url, orig=service.get('orig'), key=service.get('secret'))
388
        headers = {
389
            'Content-Type': 'application/json',
390
            'Accept': 'application/json',
391
        }
392
        try:
393
            result = wcs_api.post_json(self.payload, [], headers=headers)
394
        except WcsApiError as e:
395
            try:
396
                result = json.loads(e.args[3])
397
            except (ValueError):
398
                return False
399
        self.result = result
400
        if result['err']:
401
            self.save()
402
            return False
403
        self.status = 'sent'
404
        self.save()
405
        return True
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
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)
......
522 536
)
523 537
@mock.patch("django.db.models.fields.UUIDField.get_default", return_value=UUID)
524 538
def test_create_intervention_content_error(mocked_uuid, app, freezer, smart):
525 539
    freezer.move_to('2021-07-08 00:00:00')
526 540
    resp = app.post_json(URL + 'create-intervention/', params=CREATE_INTERVENTION_PAYLOAD)
527 541
    wcs_request = smart.wcs_requests.get(uuid=UUID)
528 542
    assert wcs_request.status == 'registered'
529 543
    assert 'invalid json' in wcs_request.result
544

  
545

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

  
558

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

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

  
583

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

  
599
    mocked_push = mock.patch(
600
        "passerelle.contrib.toulouse_smart.models.SmartRequest.push",
601
        return_value=False,
602
    )
603
    mocked_push.start()
604

  
605
    assert Job.objects.count() == 0
606
    url = URL + 'update-intervention?uuid=%s' % str(UUID)
607
    resp = app.post_json(url, params=UPDATE_INTERVENTION_PAYLOAD)
608
    assert not resp.json['err']
609
    assert resp.json['data']['uuid'] == str(UUID)
610
    assert resp.json['data']['payload']['data']['type_retour_cloture'] == 'Smart non Fait'
611
    smart_request = smart.wcs_requests.get(uuid=UUID).smart_requests.get()
612
    assert smart_request.status == 'registered'
613

  
614
    mocked_push.stop()
615
    assert Job.objects.count() == 1
616
    job = Job.objects.get(method_name='update_intervention_job')
617
    assert job.status == 'registered'
618
    smart.jobs()
619
    job = Job.objects.get(method_name='update_intervention_job')
620
    assert job.status == 'completed'
621
    smart_request = smart.wcs_requests.get(uuid=UUID).smart_requests.get()
622
    assert smart_request.status == 'sent'
623
    assert smart_request.result == {'err': 0, 'url': None}
624

  
625

  
626
def test_update_intervention_wrong_uuid(app, smart):
627
    with pytest.raises(WcsRequest.DoesNotExist):
628
        smart.wcs_requests.get(uuid=UUID)
629

  
630
    url = URL + 'update-intervention?uuid=%s' % str(UUID)
631
    resp = app.post_json(url, params=UPDATE_INTERVENTION_PAYLOAD, status=400)
632
    assert resp.json['err']
633
    assert 'Cannot find intervention' in resp.json['err_desc']
634
    assert SmartRequest.objects.count() == 0
635

  
636

  
637
@mock_response(
638
    ['/v1/type-intervention', None, INTERVENTION_TYPES],
639
    ['/v1/intervention', CREATE_INTERVENTION_QUERY, get_json_file('create_intervention')],
640
)
641
@mock.patch("django.db.models.fields.UUIDField.get_default", return_value=UUID)
642
def test_update_intervention_job_wrong_service(mocked_uuid, app, smart, wcs_service):
643
    resp = app.post_json(URL + 'create-intervention/', params=CREATE_INTERVENTION_PAYLOAD)
644
    assert not resp.json['err']
645

  
646
    wcs_service['default']['url'] = 'http://wrong.example.com'
647
    url = URL + 'update-intervention?uuid=%s' % str(UUID)
648
    resp = app.post_json(url, params=UPDATE_INTERVENTION_PAYLOAD)
649
    assert resp.json['err']
650
    assert 'Cannot find wcs service' in resp.json['err_desc']
651
    assert SmartRequest.objects.count() == 0
652

  
653

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

  
664
    url = URL + 'update-intervention?uuid=%s' % str(UUID)
665
    resp = app.post_json(url, params=UPDATE_INTERVENTION_PAYLOAD)
666
    assert not resp.json['err']
667
    smart_request = smart.wcs_requests.get(uuid=UUID).smart_requests.get()
668
    assert smart_request.status == 'registered'
669
    assert smart_request.result == {'err': 1, 'err_class': 'Access denied', 'err_desc': None}
670

  
671

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

  
683
    url = URL + 'update-intervention?uuid=%s' % str(UUID)
684
    resp = app.post_json(url, params=UPDATE_INTERVENTION_PAYLOAD)
685
    assert not resp.json['err']
686
    job = Job.objects.get(method_name='update_intervention_job')
687
    assert job.status == 'registered'
688
    smart_request = smart.wcs_requests.get(uuid=UUID).smart_requests.get()
689
    assert smart_request.status == 'registered'
690
    assert smart_request.result == {}
691

  
692
    freezer.move_to('2021-07-08 00:00:03')
693
    smart.jobs()
694
    job = Job.objects.get(method_name='update_intervention_job')
695
    assert job.status == 'registered'
696
    assert job.update_timestamp > job.creation_timestamp
697
    smart_request = smart.wcs_requests.get(uuid=UUID).smart_requests.get()
698
    assert smart_request.status == 'registered'
699
    assert smart_request.result == {}
530
-