From e56d60e512c3390d883f3bf63cfb5f6de447201f Mon Sep 17 00:00:00 2001
From: Nicolas ROCHE <nroche@entrouvert.com>
Date: Fri, 9 Jul 2021 19:27:22 +0200
Subject: [PATCH 7/7] toulouse_smart: add update-intervention endpoint (#55230)

---
 passerelle/contrib/toulouse_smart/models.py |  74 ++++++++-
 tests/test_toulouse_smart.py                | 164 ++++++++++++++++++++
 2 files changed, 237 insertions(+), 1 deletion(-)

diff --git a/passerelle/contrib/toulouse_smart/models.py b/passerelle/contrib/toulouse_smart/models.py
index 821d3d92..3d89bb2b 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.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
+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'))
@@ -216,16 +220,84 @@ class ToulouseSmartResource(BaseResource, HTTPResource):
             raise APIError('invalid json, got: %s' % response.text)
 
         wcs_request.payload = payload
         wcs_request.result = result
         wcs_request.status = 'sent'
         wcs_request.save()
         return {'data': result, 'notificationUrl': notificationUrl}
 
+    @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')},
+        },
+    )
+    def update_intervention(self, request, uuid, trigger):
+        try:
+            wcs_request = self.wcs_requests.get(uuid=uuid)
+        except WcsRequest.DoesNotExist:
+            raise APIError("Cannot find intervention '%s'" % uuid)
+        if wcs_request.status == 'updating':
+            raise APIError(
+                "'%s' intervention is still triggering on '%s'"
+                % (wcs_request.wcs_form_number, wcs_request.trigger)
+            )
+        wcs_request.status = 'updating'
+        wcs_request.trigger = trigger
+        wcs_request.save()
+        self.add_job(
+            'update_intervention_job',
+            wcs_form_number=wcs_request.wcs_form_number,
+            trigger=trigger,
+        )
+        return {'err': 0}
+
+    def update_intervention_job(self, *args, **kwargs):
+        wcs_form_number = kwargs['wcs_form_number']
+        trigger = kwargs['trigger']
+        wcs_request = self.wcs_requests.get(wcs_form_number=wcs_form_number)
+        base_url = '%sjump/trigger/%s' % (wcs_request.wcs_form_url, 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:
+            wcs_request.status = 'failed'
+            wcs_request.save()
+            raise Exception('Cannot find wcs service for %s' % base_url)
+
+        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({}, [], headers=headers)
+        except WcsApiError as e:
+            try:
+                result = json.loads(e.args[3])
+            except TypeError:
+                raise SkipJob()  # transport error
+        if result['err']:
+            wcs_request.status = 'failed'
+            wcs_request.save()
+            raise Exception(result.get('err_class', 'wcs error'))
+        wcs_request.status = 'updated'
+        wcs_request.save()
+
 
 class Cache(models.Model):
     resource = models.ForeignKey(
         verbose_name=_('Resource'),
         to=ToulouseSmartResource,
         on_delete=models.CASCADE,
         related_name='cache_entries',
     )
diff --git a/tests/test_toulouse_smart.py b/tests/test_toulouse_smart.py
index 013cfabd..002e474d 100644
--- a/tests/test_toulouse_smart.py
+++ b/tests/test_toulouse_smart.py
@@ -24,16 +24,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
 
 TEST_BASE_DIR = os.path.join(os.path.dirname(__file__), 'data', 'toulouse_smart')
 
 
 @pytest.fixture
 def smart(db):
     return utils.make_resource(
@@ -42,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)
@@ -440,8 +455,157 @@ def test_create_intervention_error_content(mocked_uuid, app, smart):
 
     assert smart.wcs_requests.count() == 1
     wcs_request = smart.wcs_requests.get(uuid=UUID)
     assert wcs_request.status == 'received'
 
     # same uuid is re-used after an error
     resp = app.post_json(URL + 'create-intervention/', params=CREATE_INTERVENTION_PAYLOAD)
     assert smart.wcs_requests.count() == 1
+
+
+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-slug', None, WCS_RESPONSE_SUCCESS],
+)
+@mock.patch("django.db.models.fields.UUIDField.get_default", return_value=UUID)
+def test_update_intervention(mocked_uuid, app, freezer, 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)
+
+    freezer.move_to('2021-07-08 00:00:00')
+    assert Job.objects.filter(method_name='update_intervention_job').count() == 0
+    url = URL + 'update-intervention?uuid=%s&trigger=my-trigger-slug' % str(UUID)
+    resp = app.post_json(url)
+    assert not resp.json['err']
+    assert Job.objects.filter(method_name='update_intervention_job').count() == 1
+    job = Job.objects.get(method_name='update_intervention_job')
+    assert job.status == 'registered'
+    wcs_request = smart.wcs_requests.get(uuid=UUID)
+    assert wcs_request.status == 'updating'
+    assert wcs_request.trigger == 'my-trigger-slug'
+
+    freezer.move_to('2021-07-08 00:00:03')
+    smart.jobs()
+    job = Job.objects.get(method_name='update_intervention_job')
+    assert job.status == 'completed'
+    wcs_request = smart.wcs_requests.get(uuid=UUID)
+    assert wcs_request.status == 'updated'
+    assert wcs_request.trigger == 'my-trigger-slug'
+
+
+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-slug' % str(UUID)
+    resp = app.post_json(url)
+    assert resp.json['err']
+    assert 'Cannot find intervention' in resp.json['err_desc']
+
+
+@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_wrong_service(mocked_uuid, app, freezer, smart, wcs_service):
+    wcs_service['default']['url'] = 'http://wrong.example.com'
+
+    resp = app.post_json(URL + 'create-intervention/', params=CREATE_INTERVENTION_PAYLOAD)
+    assert not resp.json['err']
+
+    freezer.move_to('2021-07-08 00:00:00')
+    url = URL + 'update-intervention?uuid=%s&trigger=my-trigger-slug' % str(UUID)
+    resp = app.post_json(url)
+    assert not resp.json['err']
+    assert Job.objects.filter(method_name='update_intervention_job').count() == 1
+
+    freezer.move_to('2021-07-08 00:00:03')
+    smart.jobs()
+    job = Job.objects.get(method_name='update_intervention_job')
+    assert job.status == 'failed'
+    assert 'Cannot find wcs service' in job.status_details['error_summary']
+    wcs_request = smart.wcs_requests.get(uuid=UUID)
+    assert wcs_request.status == 'failed'
+
+
+@mock_response(
+    ['/v1/type-intervention', None, INTERVENTION_TYPES],
+    ['/v1/intervention', CREATE_INTERVENTION_QUERY, get_json_file('create_intervention')],
+    ['/foo/2/jump/trigger/my-trigger-slug', None, WCS_RESPONSE_ERROR, 403],
+)
+@mock.patch("django.db.models.fields.UUIDField.get_default", return_value=UUID)
+def test_update_intervention_wrong_trigger(mocked_uuid, app, freezer, smart, wcs_service):
+    resp = app.post_json(URL + 'create-intervention/', params=CREATE_INTERVENTION_PAYLOAD)
+    assert not resp.json['err']
+
+    freezer.move_to('2021-07-08 00:00:00')
+    url = URL + 'update-intervention?uuid=%s&trigger=my-trigger-slug' % str(UUID)
+    resp = app.post_json(url)
+    assert not resp.json['err']
+
+    freezer.move_to('2021-07-08 00:00:03')
+    smart.jobs()
+    job = Job.objects.get(method_name='update_intervention_job')
+    assert job.status == 'failed'
+    assert 'Access denied' in job.status_details['error_summary']
+    wcs_request = smart.wcs_requests.get(uuid=UUID)
+    assert wcs_request.status == 'failed'
+
+
+@mock_response(
+    ['/v1/type-intervention', None, INTERVENTION_TYPES],
+    ['/v1/intervention', CREATE_INTERVENTION_QUERY, get_json_file('create_intervention')],
+    ['/foo/2/jump/trigger/my-trigger-slug', None, None, 500],
+)
+@mock.patch("django.db.models.fields.UUIDField.get_default", return_value=UUID)
+def test_update_intervention_error_wcs(mocked_uuid, app, freezer, smart, wcs_service):
+    resp = app.post_json(URL + 'create-intervention/', params=CREATE_INTERVENTION_PAYLOAD)
+    assert not resp.json['err']
+
+    freezer.move_to('2021-07-08 00:00:00')
+    url = URL + 'update-intervention?uuid=%s&trigger=my-trigger-slug' % str(UUID)
+    resp = app.post_json(url)
+    assert not resp.json['err']
+
+    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
+    wcs_request = smart.wcs_requests.get(uuid=UUID)
+    assert wcs_request.status == 'updating'
+
+
+@mock_response(
+    ['/v1/type-intervention', None, INTERVENTION_TYPES],
+    ['/v1/intervention', CREATE_INTERVENTION_QUERY, get_json_file('create_intervention')],
+    ['/foo/2/jump/trigger/trigger-1', None, None, 500],
+)
+@mock.patch("django.db.models.fields.UUIDField.get_default", return_value=UUID)
+def test_update_intervention_pending(mocked_uuid, app, freezer, smart, wcs_service):
+    resp = app.post_json(URL + 'create-intervention/', params=CREATE_INTERVENTION_PAYLOAD)
+    assert not resp.json['err']
+
+    freezer.move_to('2021-07-08 00:00:00')
+    url = URL + 'update-intervention?uuid=%s&trigger=trigger-1' % str(UUID)
+    resp = app.post_json(url)
+    assert not resp.json['err']
+
+    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
+    wcs_request = smart.wcs_requests.get(uuid=UUID)
+    assert wcs_request.status == 'updating'
+
+    url = URL + 'update-intervention?uuid=%s&trigger=trigger-2' % str(UUID)
+    resp = app.post_json(url)
+    assert resp.json['err']
+    assert "still triggering on 'trigger-1'" in resp.json['err_desc']
-- 
2.30.2

