From 4134e6771d58d7c43086b39290e9933f6d69ce3f Mon Sep 17 00:00:00 2001
From: Nicolas ROCHE <nroche@entrouvert.com>
Date: Tue, 27 Jul 2021 17:38:10 +0200
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

diff --git a/passerelle/contrib/toulouse_smart/migrations/0003_smartrequest.py b/passerelle/contrib/toulouse_smart/migrations/0003_smartrequest.py
new file mode 100644
index 00000000..7b6a4672
--- /dev/null
+++ b/passerelle/contrib/toulouse_smart/migrations/0003_smartrequest.py
@@ -0,0 +1,54 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.29 on 2021-07-26 14:50
+from __future__ import unicode_literals
+
+import django.contrib.postgres.fields.jsonb
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('toulouse_smart', '0002_auto_20210726_1648'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='SmartRequest',
+            fields=[
+                (
+                    'id',
+                    models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
+                ),
+                ('trigger', models.CharField(blank=True, max_length=64, null=True, verbose_name='Trigger')),
+                ('payload', django.contrib.postgres.fields.jsonb.JSONField(default=dict)),
+                ('result', django.contrib.postgres.fields.jsonb.JSONField(default=dict)),
+                (
+                    'status',
+                    models.CharField(
+                        choices=[
+                            ('registered', 'Registered'),
+                            ('sent', 'Sent'),
+                            ('service-error', 'Service error'),
+                            ('wcs-error', 'WCS error'),
+                        ],
+                        default='registered',
+                        max_length=20,
+                    ),
+                ),
+                (
+                    'resource',
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE,
+                        related_name='smart_requests',
+                        to='toulouse_smart.WcsRequest',
+                        verbose_name='SmartRequest',
+                    ),
+                ),
+            ],
+            options={
+                'ordering': ['pk', 'status'],
+            },
+        ),
+    ]
diff --git a/passerelle/contrib/toulouse_smart/models.py b/passerelle/contrib/toulouse_smart/models.py
index 30f52ff8..3542f8d4 100644
--- a/passerelle/contrib/toulouse_smart/models.py
+++ b/passerelle/contrib/toulouse_smart/models.py
@@ -10,30 +10,34 @@
 # but WITHOUT ANY WARRANTY; without even the implied warranty of
 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 # GNU Affero General Public License for more details.
 #
 # You should have received a copy of the GNU Affero General Public License
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
 import datetime
+import json
 from uuid import uuid4
 
 import lxml.etree as ET
+from django.conf import settings
 from django.contrib.postgres.fields import JSONField
-from django.db import models
+from django.db import models, transaction
+from django.utils.six.moves.urllib import parse as urlparse
 from django.utils.text import slugify
 from django.utils.timezone import now
 from django.utils.translation import ugettext_lazy as _
 from requests import RequestException
 
 from passerelle.base.models import BaseResource, HTTPResource, SkipJob
 from passerelle.utils import xml
 from passerelle.utils.api import endpoint
 from passerelle.utils.jsonresponse import APIError
+from passerelle.utils.wcs import WcsApi, WcsApiError
 
 from . import schemas
 
 
 class ToulouseSmartResource(BaseResource, HTTPResource):
     category = _('Business Process Connectors')
 
     webservice_base_url = models.URLField(_('Webservice Base URL'))
@@ -245,16 +249,110 @@ class ToulouseSmartResource(BaseResource, HTTPResource):
             wcs_request.status = 'error'
             err_desc = 'invalid json, got: %s' % response.text
             wcs_request.result = err_desc
             wcs_request.save()
             raise APIError(err_desc)
         wcs_request.status = 'sent'
         wcs_request.save()
 
+    @endpoint(
+        name='update-intervention',
+        methods=['post'],
+        description=_('Update an intervention status'),
+        perm='can_access',
+        parameters={
+            'id': {'description': _('Intervention identifier')},
+            'trigger': {'description': _('W.C.S trigger')},
+        },
+        post={'request_body': {'schema': {'application/json': schemas.UPDATE_SCHEMA}}},
+    )
+    def update_intervention(self, request, uuid, trigger, post_data):
+        try:
+            wcs_request = self.wcs_requests.get(uuid=uuid)
+        except WcsRequest.DoesNotExist:
+            raise APIError("Cannot find intervention '%s'" % uuid, http_status=400)
+        smart_request = wcs_request.smart_requests.create(
+            trigger=trigger,
+            payload=post_data,
+        )
+        smart_request.status = 'registered'
+        smart_request.save()
+        try:
+            self.update_intervention_job(
+                wcs_request_pk=wcs_request.pk,
+                smart_request_id=smart_request.id,
+            )
+        except SkipJob:
+            self.add_job(
+                'update_intervention_job',
+                wcs_request_pk=wcs_request.pk,
+                smart_request_id=smart_request.id,
+            )
+        return {
+            'data': {
+                'wcs_form_url': wcs_request.wcs_form_url,
+                'wcs_form_number': wcs_request.wcs_form_number,
+                'uuid': wcs_request.uuid,
+                'trigger': smart_request.trigger,
+                'payload': smart_request.payload,
+                'result': smart_request.result,
+                'status': smart_request.status,
+            }
+        }
+
+    def update_intervention_job(self, *args, **kwargs):
+        smarts_requests = SmartRequest.objects.select_for_update().filter(
+            resource_id=kwargs['wcs_request_pk'],
+            status='registered',
+        )
+        exception = None
+        with transaction.atomic():
+            smart_request = smarts_requests[0]
+            if smart_request.id != kwargs['smart_request_id']:
+                # wait previous triggers registered are sent
+                raise SkipJob()
+
+        base_url = '%sjump/trigger/%s' % (smart_request.resource.wcs_form_url, smart_request.trigger)
+        scheme, netloc, path, params, query, fragment = urlparse.urlparse(base_url)
+        services = settings.KNOWN_SERVICES.get('wcs', {})
+        service = None
+        for service in services.values():
+            remote_url = service.get('url')
+            r_scheme, r_netloc, r_path, r_params, r_query, r_fragment = urlparse.urlparse(remote_url)
+            if r_scheme == scheme and r_netloc == netloc:
+                break
+        else:
+            smart_request.status = 'service-error'
+            err_desc = 'Cannot find wcs service for %s' % base_url
+            smart_request.result = err_desc
+            smart_request.save()
+            raise APIError(err_desc)
+
+        wcs_api = WcsApi(base_url, orig=service.get('orig'), key=service.get('secret'))
+        headers = {
+            'Content-Type': 'application/json',
+            'Accept': 'application/json',
+        }
+        try:
+            result = wcs_api.post_json(smart_request.payload, [], headers=headers)
+        except WcsApiError as e:
+            try:
+                result = json.loads(e.args[3])
+            except TypeError:
+                # retry on transport error
+                raise SkipJob()
+        smart_request.result = result
+        if result['err']:
+            smart_request.status = 'wcs-error'
+            smart_request.save()
+            raise APIError(result.get('err_class', 'wcs error'))
+        smart_request.status = 'sent'
+        smart_request.save()
+
 
 class Cache(models.Model):
     resource = models.ForeignKey(
         verbose_name=_('Resource'),
         to=ToulouseSmartResource,
         on_delete=models.CASCADE,
         related_name='cache_entries',
     )
@@ -283,8 +381,33 @@ class WcsRequest(models.Model):
         default='registered',
         choices=(
             ('registered', _('Registered')),
             ('sent', _('Sent')),
             ('service-error', _('Service error')),
             ('wcs-error', _('WCS error')),
         ),
     )
+
+
+class SmartRequest(models.Model):
+    resource = models.ForeignKey(
+        verbose_name=_('SmartRequest'),
+        to=WcsRequest,
+        on_delete=models.CASCADE,
+        related_name='smart_requests',
+    )
+    trigger = models.CharField(_('Trigger'), blank=True, null=True, max_length=64)
+    payload = JSONField(default=dict)
+    result = JSONField(default=dict)
+    status = models.CharField(
+        max_length=20,
+        default='registered',
+        choices=(
+            ('registered', _('Registered')),
+            ('sent', _('Sent')),
+            ('service-error', _('Service error')),
+            ('wcs-error', _('WCS error')),
+        ),
+    )
+
+    class Meta:
+        ordering = ['pk', 'status']
diff --git a/passerelle/contrib/toulouse_smart/schemas.py b/passerelle/contrib/toulouse_smart/schemas.py
index be0491ee..3ad5afb5 100644
--- a/passerelle/contrib/toulouse_smart/schemas.py
+++ b/passerelle/contrib/toulouse_smart/schemas.py
@@ -119,8 +119,53 @@ CREATE_SCHEMA = {
         'submitterType',
         'external_number',
         'external_status',
         'address',
         'form_url',
     ],
     'merge_extra': True,
 }
+
+UPDATE_SCHEMA = {
+    '$schema': 'http://json-schema.org/draft-04/schema#',
+    'type': 'object',
+    'properties': {
+        'data': {
+            'type': 'object',
+            'properties': {
+                'status': {
+                    'description': "Nouveau Statut de l'intervention",
+                    'type': 'string',
+                    'maxLength': 20,
+                },
+                'date_planification': {
+                    'description': "Date de planification de l'intervention",
+                    'type': 'string',
+                    'pattern': '[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}',
+                },
+                'date_mise_en_securite': {
+                    'description': "Date de mise en securite de l'intervention",
+                    'type': 'string',
+                    'pattern': '[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}',
+                },
+                'type_retour': {
+                    'description': "Type du retour",
+                    'type': 'string',
+                },
+                'type_retour_cloture': {
+                    'description': "Type du retour de la clôture",
+                    'type': 'string',
+                },
+                'libelle_cloture': {
+                    'description': "Libellé de la clôture",
+                    'type': 'string',
+                },
+                'commentaire_cloture': {
+                    'description': "Commentaire de la clôture",
+                    'type': 'string',
+                },
+            },
+            'required': ['status'],
+        },
+    },
+    'required': ['data'],
+}
diff --git a/tests/test_toulouse_smart.py b/tests/test_toulouse_smart.py
index b76981ff..677f7e18 100644
--- a/tests/test_toulouse_smart.py
+++ b/tests/test_toulouse_smart.py
@@ -25,17 +25,17 @@ from copy import deepcopy
 import httmock
 import lxml.etree as ET
 import mock
 import pytest
 import utils
 from test_manager import login
 
 from passerelle.base.models import Job
-from passerelle.contrib.toulouse_smart.models import ToulouseSmartResource, WcsRequest
+from passerelle.contrib.toulouse_smart.models import SmartRequest, ToulouseSmartResource, WcsRequest
 
 TEST_BASE_DIR = os.path.join(os.path.dirname(__file__), 'data', 'toulouse_smart')
 
 
 @pytest.fixture
 def smart(db):
     return utils.make_resource(
         ToulouseSmartResource,
@@ -43,16 +43,30 @@ def smart(db):
         slug='test',
         description='Test',
         webservice_base_url='https://smart.example.com/',
         basic_auth_username='username',
         basic_auth_password='password',
     )
 
 
+@pytest.fixture
+def wcs_service(settings):
+    wcs_service = {
+        'default': {
+            'title': 'test',
+            'url': 'https://wcs.example.com',
+            'secret': 'xxx',
+            'orig': 'passerelle',
+        },
+    }
+    settings.KNOWN_SERVICES = {'wcs': wcs_service}
+    return wcs_service
+
+
 def mock_response(*path_contents):
     def decorator(func):
         @httmock.urlmatch()
         def error(url, request):
             assert False, 'request to %s' % url.geturl()
 
         def register(path, payload, content, status_code=200):
             @httmock.urlmatch(path=path)
@@ -480,8 +494,152 @@ def test_create_intervention_inconsistency_id_error(mocked_uuid4, app, freezer,
 @mock.patch("django.db.models.fields.UUIDField.get_default", return_value=UUID)
 def test_create_intervention_content_error(mocked_uuid, app, freezer, smart):
     freezer.move_to('2021-07-08 00:00:00')
     resp = app.post_json(URL + 'create-intervention/', params=CREATE_INTERVENTION_PAYLOAD)
     assert resp.json['err']
     wcs_request = smart.wcs_requests.get(uuid=UUID)
     assert wcs_request.status == 'error'
     assert 'invalid json' in wcs_request.result
+
+
+UPDATE_INTERVENTION_PAYLOAD = {
+    'data': {
+        'status': 'close manque info',
+        'type_retour_cloture': 'Smart non Fait',
+        'libelle_cloture': "rien à l'adresse indiquée",
+        'commentaire_cloture': 'le commentaire',
+    }
+}
+UPDATE_INTERVENTION_QUERY = UPDATE_INTERVENTION_PAYLOAD
+WCS_RESPONSE_SUCCESS = '{"err": 0, "url": null}'
+WCS_RESPONSE_ERROR = '{"err": 1, "err_class": "Access denied", "err_desc": null}'
+
+
+@mock_response(
+    ['/v1/type-intervention', None, INTERVENTION_TYPES],
+    ['/v1/intervention', CREATE_INTERVENTION_QUERY, get_json_file('create_intervention')],
+    ['/foo/2/jump/trigger/my-trigger', UPDATE_INTERVENTION_QUERY, WCS_RESPONSE_SUCCESS],
+)
+@mock.patch("django.db.models.fields.UUIDField.get_default", return_value=UUID)
+def test_update_intervention(mocked_uuid, app, smart, wcs_service):
+    resp = app.post_json(URL + 'create-intervention/', params=CREATE_INTERVENTION_PAYLOAD)
+    assert not resp.json['err']
+    assert CREATE_INTERVENTION_QUERY['notificationUrl'] == 'update-intervention?uuid=%s' % str(UUID)
+    wcs_request = smart.wcs_requests.get(uuid=UUID)
+    assert wcs_request.status == 'sent'
+
+    url = URL + 'update-intervention?uuid=%s&trigger=my-trigger' % str(UUID)
+    resp = app.post_json(url, params=UPDATE_INTERVENTION_PAYLOAD)
+    assert not resp.json['err']
+    assert resp.json['data']['uuid'] == str(UUID)
+    assert resp.json['data']['trigger'] == 'my-trigger'
+    assert resp.json['data']['payload']['data']['type_retour_cloture'] == 'Smart non Fait'
+    smart_request = smart.wcs_requests.get(uuid=UUID).smart_requests.get()
+    assert smart_request.status == 'sent'
+    assert smart_request.trigger == 'my-trigger'
+    assert smart_request.result == {'err': 0, 'url': None}
+
+
+def test_update_intervention_wrong_uuid(app, smart):
+    with pytest.raises(WcsRequest.DoesNotExist):
+        smart.wcs_requests.get(uuid=UUID)
+
+    url = URL + 'update-intervention?uuid=%s&trigger=my-trigger' % str(UUID)
+    resp = app.post_json(url, params=UPDATE_INTERVENTION_PAYLOAD, status=400)
+    assert resp.json['err']
+    assert 'Cannot find intervention' in resp.json['err_desc']
+    assert SmartRequest.objects.count() == 0
+
+
+@mock_response(
+    ['/v1/type-intervention', None, INTERVENTION_TYPES],
+    ['/v1/intervention', CREATE_INTERVENTION_QUERY, get_json_file('create_intervention')],
+)
+@mock.patch("django.db.models.fields.UUIDField.get_default", return_value=UUID)
+def test_update_intervention_job_wrong_service(mocked_uuid, app, smart, wcs_service):
+    resp = app.post_json(URL + 'create-intervention/', params=CREATE_INTERVENTION_PAYLOAD)
+    assert not resp.json['err']
+
+    wcs_service['default']['url'] = 'http://wrong.example.com'
+    url = URL + 'update-intervention?uuid=%s&trigger=my-trigger' % str(UUID)
+    resp = app.post_json(url, params=UPDATE_INTERVENTION_PAYLOAD)
+    assert resp.json['err']
+    smart_request = smart.wcs_requests.get(uuid=UUID).smart_requests.get()
+    assert smart_request.status == 'service-error'
+    assert 'Cannot find wcs service' in smart_request.result
+
+
+@mock_response(
+    ['/v1/type-intervention', None, INTERVENTION_TYPES],
+    ['/v1/intervention', CREATE_INTERVENTION_QUERY, get_json_file('create_intervention')],
+    ['/foo/2/jump/trigger/inexistant-trigger', UPDATE_INTERVENTION_QUERY, WCS_RESPONSE_ERROR, 403],
+)
+@mock.patch("django.db.models.fields.UUIDField.get_default", return_value=UUID)
+def test_update_intervention_job_wcs_error(mocked_uuid, app, smart, wcs_service):
+    resp = app.post_json(URL + 'create-intervention/', params=CREATE_INTERVENTION_PAYLOAD)
+    assert not resp.json['err']
+
+    url = URL + 'update-intervention?uuid=%s&trigger=inexistant-trigger' % str(UUID)
+    resp = app.post_json(url, params=UPDATE_INTERVENTION_PAYLOAD)
+    assert resp.json['err']
+    smart_request = smart.wcs_requests.get(uuid=UUID).smart_requests.get()
+    assert smart_request.status == 'wcs-error'
+    assert smart_request.result == {'err': 1, 'err_class': 'Access denied', 'err_desc': None}
+
+
+@mock_response(
+    ['/v1/type-intervention', None, INTERVENTION_TYPES],
+    ['/v1/intervention', CREATE_INTERVENTION_QUERY, get_json_file('create_intervention')],
+    ['/foo/2/jump/trigger/my-trigger', UPDATE_INTERVENTION_QUERY, None, 500],
+)
+@mock.patch("django.db.models.fields.UUIDField.get_default", return_value=UUID)
+def test_update_intervention_job_transport_error(mocked_uuid, app, freezer, smart, wcs_service):
+    freezer.move_to('2021-07-08 00:00:00')
+    resp = app.post_json(URL + 'create-intervention/', params=CREATE_INTERVENTION_PAYLOAD)
+    assert not resp.json['err']
+
+    url = URL + 'update-intervention?uuid=%s&trigger=my-trigger' % str(UUID)
+    resp = app.post_json(url, params=UPDATE_INTERVENTION_PAYLOAD)
+    assert not resp.json['err']
+    job = Job.objects.get(method_name='update_intervention_job')
+    assert job.status == 'registered'
+    smart_request = smart.wcs_requests.get(uuid=UUID).smart_requests.get()
+    assert smart_request.status == 'registered'
+    assert smart_request.result == {}
+
+    freezer.move_to('2021-07-08 00:00:03')
+    smart.jobs()
+    job = Job.objects.get(method_name='update_intervention_job')
+    assert job.status == 'registered'
+    assert job.update_timestamp > job.creation_timestamp
+    smart_request = smart.wcs_requests.get(uuid=UUID).smart_requests.get()
+    assert smart_request.status == 'registered'
+    assert smart_request.result == {}
+
+
+@mock_response(
+    ['/v1/type-intervention', None, INTERVENTION_TYPES],
+    ['/v1/intervention', CREATE_INTERVENTION_QUERY, get_json_file('create_intervention')],
+    ['/foo/2/jump/trigger/trigger-1', UPDATE_INTERVENTION_QUERY, None, 500],
+)
+@mock.patch("django.db.models.fields.UUIDField.get_default", return_value=UUID)
+def test_update_intervention_job_order(mocked_uuid, app, freezer, smart, wcs_service):
+    freezer.move_to('2021-07-08 00:00:00')
+    resp = app.post_json(URL + 'create-intervention/', params=CREATE_INTERVENTION_PAYLOAD)
+    assert not resp.json['err']
+
+    url = URL + 'update-intervention?uuid=%s&trigger=trigger-1' % str(UUID)
+    resp = app.post_json(url, params=UPDATE_INTERVENTION_PAYLOAD)
+    assert not resp.json['err']
+    url = URL + 'update-intervention?uuid=%s&trigger=trigger-2' % str(UUID)
+    resp = app.post_json(url, params=UPDATE_INTERVENTION_PAYLOAD)
+    assert not resp.json['err']
+    assert smart.wcs_requests.get(uuid=UUID).smart_requests.count() == 2
+
+    freezer.move_to('2021-07-08 00:00:03')
+    jobs = Job.objects.filter(method_name='update_intervention_job')
+    jobs[1].run()
+    jobs = Job.objects.filter(method_name='update_intervention_job')
+    assert jobs[0].status == 'registered'
+    assert jobs[0].update_timestamp == jobs[0].creation_timestamp
+    assert jobs[1].status == 'registered'
+    assert jobs[1].update_timestamp > jobs[1].creation_timestamp
-- 
2.30.2

