From 9326b7f2a6665c2551c56d280c6c597564a4f09a 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           |  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

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..9fea303b
--- /dev/null
+++ b/passerelle/contrib/toulouse_smart/migrations/0003_smartrequest.py
@@ -0,0 +1,48 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.29 on 2021-07-30 22:37
+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_20210731_0031'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='SmartRequest',
+            fields=[
+                (
+                    'id',
+                    models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
+                ),
+                ('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')],
+                        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 fd92df06..79fa084a 100644
--- a/passerelle/contrib/toulouse_smart/models.py
+++ b/passerelle/contrib/toulouse_smart/models.py
@@ -10,38 +10,46 @@
 # 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.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 ToulouseSmartErrorRetry(APIError):
     pass
 
 
+class ToulouseSmartErrorStop(APIError):
+    pass
+
+
 class ToulouseSmartResource(BaseResource, HTTPResource):
     category = _('Business Process Connectors')
 
     webservice_base_url = models.URLField(_('Webservice Base URL'))
 
     log_requests_errors = False
 
     class Meta:
@@ -241,16 +249,59 @@ class ToulouseSmartResource(BaseResource, HTTPResource):
 
     def create_intervention_job(self, *args, **kwargs):
         wcs_request = self.wcs_requests.get(pk=kwargs['pk'])
         try:
             wcs_request.push()
         except ToulouseSmartErrorRetry:
             raise SkipJob()
 
+    @endpoint(
+        name='update-intervention',
+        methods=['post'],
+        description=_('Update an intervention status'),
+        parameters={
+            'id': {'description': _('Intervention identifier')},
+        },
+        post={'request_body': {'schema': {'application/json': schemas.UPDATE_SCHEMA}}},
+    )
+    def update_intervention(self, request, uuid, 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(payload=post_data)
+        smart_request.status = 'registered'
+        smart_request.save()
+        try:
+            smart_request.push()
+        except ToulouseSmartErrorRetry:
+            self.add_job(
+                'update_intervention_job',
+                id=smart_request.id,
+                natural_id='smart-request-%s' % smart_request.id,
+            )
+        return {
+            'data': {
+                'wcs_form_api_url': wcs_request.wcs_form_api_url,
+                'wcs_form_number': wcs_request.wcs_form_number,
+                'uuid': wcs_request.uuid,
+                'payload': smart_request.payload,
+                'result': smart_request.result,
+                'status': smart_request.status,
+            }
+        }
+
+    def update_intervention_job(self, *args, **kwargs):
+        smart_request = SmartRequest.objects.get(id=kwargs['id'])
+        try:
+            smart_request.push()
+        except ToulouseSmartErrorRetry:
+            raise SkipJob()
+
 
 class Cache(models.Model):
     resource = models.ForeignKey(
         verbose_name=_('Resource'),
         to=ToulouseSmartResource,
         on_delete=models.CASCADE,
         related_name='cache_entries',
     )
@@ -295,8 +346,65 @@ class WcsRequest(models.Model):
             self.result = response.json()
         except ValueError:
             err_desc = 'invalid json, got: %s' % response.text
             self.result = err_desc
             self.save()
             raise ToulouseSmartErrorRetry(err_desc)
         self.status = 'sent'
         self.save()
+
+
+class SmartRequest(models.Model):
+    resource = models.ForeignKey(
+        verbose_name=_('SmartRequest'),
+        to=WcsRequest,
+        on_delete=models.CASCADE,
+        related_name='smart_requests',
+    )
+    payload = JSONField(default=dict)
+    result = JSONField(default=dict)
+    status = models.CharField(
+        max_length=20,
+        default='registered',
+        choices=(
+            ('registered', _('Registered')),
+            ('sent', _('Sent')),
+        ),
+    )
+
+    class Meta:
+        ordering = ['pk', 'status']
+
+    def push(self):
+        base_url = '%shooks/update-intervention/' % (self.resource.wcs_form_api_url)
+        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:
+            err_desc = 'Cannot find wcs service for %s' % base_url
+            self.result = err_desc
+            self.save()
+            raise ToulouseSmartErrorStop(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(self.payload, [], headers=headers)
+        except WcsApiError as e:
+            try:
+                result = json.loads(e.args[3])
+            except (ValueError):
+                raise ToulouseSmartErrorRetry()
+        self.result = result
+        if result['err']:
+            self.save()
+            raise ToulouseSmartErrorStop(result.get('err_class', 'wcs error'))
+        self.status = 'sent'
+        self.save()
diff --git a/passerelle/contrib/toulouse_smart/schemas.py b/passerelle/contrib/toulouse_smart/schemas.py
index dbabf017..e002b7a8 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_api_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 e59ec7e8..b8292bab 100644
--- a/tests/test_toulouse_smart.py
+++ b/tests/test_toulouse_smart.py
@@ -26,16 +26,17 @@ 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 (
+    SmartRequest,
     ToulouseSmartErrorRetry,
     ToulouseSmartResource,
     WcsRequest,
 )
 
 TEST_BASE_DIR = os.path.join(os.path.dirname(__file__), 'data', 'toulouse_smart')
 
 
@@ -47,16 +48,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)
@@ -518,8 +533,161 @@ 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)
     wcs_request = smart.wcs_requests.get(uuid=UUID)
     assert wcs_request.status == 'registered'
     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')],
+    ['/api/forms/foo/2/hooks/update-intervention/', 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' % 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']['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.result == {'err': 0, 'url': None}
+
+
+@mock_response(
+    ['/v1/type-intervention', None, INTERVENTION_TYPES],
+    ['/v1/intervention', CREATE_INTERVENTION_QUERY, get_json_file('create_intervention')],
+    ['/api/forms/foo/2/hooks/update-intervention/', UPDATE_INTERVENTION_QUERY, WCS_RESPONSE_SUCCESS],
+)
+@mock.patch("django.db.models.fields.UUIDField.get_default", return_value=UUID)
+def test_update_intervention_async(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'
+
+    mocked_push = mock.patch(
+        "passerelle.contrib.toulouse_smart.models.SmartRequest.push",
+        side_effect=ToulouseSmartErrorRetry(),
+    )
+    mocked_push.start()
+
+    assert Job.objects.count() == 0
+    url = URL + 'update-intervention?uuid=%s' % 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']['payload']['data']['type_retour_cloture'] == 'Smart non Fait'
+    smart_request = smart.wcs_requests.get(uuid=UUID).smart_requests.get()
+    assert smart_request.status == 'registered'
+
+    mocked_push.stop()
+    assert Job.objects.count() == 1
+    job = Job.objects.get(method_name='update_intervention_job')
+    assert job.status == 'registered'
+    smart.jobs()
+    job = Job.objects.get(method_name='update_intervention_job')
+    assert job.status == 'completed'
+    smart_request = smart.wcs_requests.get(uuid=UUID).smart_requests.get()
+    assert smart_request.status == 'sent'
+    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' % 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' % 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 == 'registered'
+    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')],
+    ['/api/forms/foo/2/hooks/update-intervention/', 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' % 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 == 'registered'
+    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')],
+    ['/api/forms/foo/2/hooks/update-intervention/', UPDATE_INTERVENTION_QUERY, 'bla', 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' % 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 == {}
-- 
2.30.2

