From af6e1a9642e1c1649cbcad8cab15e4651b1dc294 Mon Sep 17 00:00:00 2001
From: Nicolas ROCHE <nroche@entrouvert.com>
Date: Mon, 19 Oct 2020 15:16:42 +0200
Subject: [PATCH] sigerly: add sigerly connector (#47856)

---
 .../sigerly/migrations/0001_initial.py        |  37 ++++
 .../contrib/sigerly/migrations/__init__.py    |   0
 passerelle/contrib/sigerly/models.py          | 157 +++++++++++++++++
 passerelle/contrib/sigerly/models.py~         |  80 +++++++++
 passerelle/settings.py                        |   1 +
 tests/data/sigerly/getIntervention_1.json     |  48 +++++
 tests/settings.py                             |   1 +
 tests/test_sigerly.py                         | 164 ++++++++++++++++++
 8 files changed, 488 insertions(+)
 create mode 100644 passerelle/contrib/sigerly/migrations/0001_initial.py
 create mode 100644 passerelle/contrib/sigerly/migrations/__init__.py
 create mode 100644 passerelle/contrib/sigerly/models.py
 create mode 100644 passerelle/contrib/sigerly/models.py~
 create mode 100644 tests/data/sigerly/getIntervention_1.json
 create mode 100644 tests/test_sigerly.py

diff --git a/passerelle/contrib/sigerly/migrations/0001_initial.py b/passerelle/contrib/sigerly/migrations/0001_initial.py
new file mode 100644
index 00000000..73fa94a4
--- /dev/null
+++ b/passerelle/contrib/sigerly/migrations/0001_initial.py
@@ -0,0 +1,37 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.18 on 2020-10-19 13:26
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    initial = True
+
+    dependencies = [
+        ('base', '0022_auto_20200715_1033'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='Sigerly',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('title', models.CharField(max_length=50, verbose_name='Title')),
+                ('slug', models.SlugField(unique=True, verbose_name='Identifier')),
+                ('description', models.TextField(verbose_name='Description')),
+                ('basic_auth_username', models.CharField(blank=True, max_length=128, verbose_name='Basic authentication username')),
+                ('basic_auth_password', models.CharField(blank=True, max_length=128, verbose_name='Basic authentication password')),
+                ('client_certificate', models.FileField(blank=True, null=True, upload_to='', verbose_name='TLS client certificate')),
+                ('trusted_certificate_authorities', models.FileField(blank=True, null=True, upload_to='', verbose_name='TLS trusted CAs')),
+                ('verify_cert', models.BooleanField(default=True, verbose_name='TLS verify certificates')),
+                ('http_proxy', models.CharField(blank=True, max_length=128, verbose_name='HTTP and HTTPS proxy')),
+                ('base_url', models.CharField(help_text='example: https://sig.sigerly.fr/syecl_intervention/webservicev2/', max_length=256, verbose_name='Service URL')),
+                ('users', models.ManyToManyField(blank=True, related_name='_sigerly_users_+', related_query_name='+', to='base.ApiUser')),
+            ],
+            options={
+                'verbose_name': 'Sigerly',
+            },
+        ),
+    ]
diff --git a/passerelle/contrib/sigerly/migrations/__init__.py b/passerelle/contrib/sigerly/migrations/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/passerelle/contrib/sigerly/models.py b/passerelle/contrib/sigerly/models.py
new file mode 100644
index 00000000..c118a47e
--- /dev/null
+++ b/passerelle/contrib/sigerly/models.py
@@ -0,0 +1,157 @@
+# passerelle - uniform access to multiple data sources and services
+# Copyright (C) 2020  Entr'ouvert
+#
+# This program is free software: you can redistribute it and/or modify it
+# under the terms of the GNU Affero General Public License as published
+# by the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# 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/>.
+
+from urllib.parse import urljoin
+
+from django.db import models
+from django.utils.translation import ugettext_lazy as _
+
+from passerelle.base.models import BaseResource, HTTPResource
+from passerelle.utils.api import endpoint
+from passerelle.utils.jsonresponse import APIError
+
+CREATE_SCHEMA = {
+    '$schema': 'http://json-schema.org/draft-04/schema#',
+    "type": "object",
+    'required': ['demandeur', 'id_typeinterv', 'id_urgence', 'elements'],
+    'properties': {
+        'demandeur': {
+            'description': "Nom du demandeur",
+            'type': 'string',
+        },
+        'id_typeinterv': {
+            'description': "Type de l'intervention",
+            'type': 'string',
+        },
+        'id_urgence': {
+            'description': 'Urgence',
+            'type': 'string',
+        },
+        'id_qualification': {
+            'description': 'Qualification',
+            'type': 'string',
+        },
+        'observations': {
+            'description': 'Observations',
+            'type': 'string',
+        },
+        'elements': {
+            'description': "Identifiant de l'objet : liste séparée par ':'",
+            'type': 'string',
+            'pattern': r'^[0-9A-Z :]+$'
+        },
+    }
+}
+
+QUERY_SCHEMA = {
+    '$schema': 'http://json-schema.org/draft-04/schema#',
+    "type": "object",
+    'properties': {
+        'id_intervention': {
+            'description': 'Rechercher une intervention par son numéro' \
+            ' (non cumulable avec les autres filtres)',
+            'type': 'string',
+        },
+        'date_debut_demande': {
+            'description': 'Recherche toutes les interventions dont la date de demande' \
+            ' est supérieure ou égale à la date renseignée (YYYY-MM-DD)',
+            'type': 'string',
+        },
+        'date_fin_demande': {
+            'description': 'Recherche toutes les interventions dont la date de demande' \
+            ' est inférieure ou égale à la date renseignée (YYYY-MM-DD)',
+            'type': 'string',
+        },
+        'insee': {
+            'description': "Code insee de la commune : liste séparée par ':'",
+            'type': 'string',
+            'pattern': r'^[0-9A-Z :]+$'
+        },
+    }
+}
+
+
+class Sigerly(BaseResource, HTTPResource):
+    base_url = models.CharField(
+        max_length=256, blank=False,
+        verbose_name=_('Service URL'),
+        help_text=_('example: https://sig.sigerly.fr/syecl_intervention/webservicev2/')
+    )
+
+    category = _('Business Process Connectors')
+
+    class Meta:
+        verbose_name = _('Sigerly')
+
+    def request(self, uri, json):
+        url = urljoin(self.base_url, uri)
+        headers = {'Accept': 'application/json'}
+        response = self.requests.post(url, json=json, headers=headers)
+
+        if response.status_code // 100 != 2:
+            try:
+                json_content = response.json()
+            except ValueError:
+                json_content = None
+            raise APIError('error status:%s %r, content:%r' %
+                           (response.status_code, response.reason, response.content[:1024]),
+                           data={'status_code': response.status_code,
+                                 'json_content': json_content})
+        try:
+            return response.json()
+        except ValueError:
+            raise APIError('invalid JSON content:%r' % response.content[:1024])
+
+    @endpoint(
+        display_order=1,
+        perm='can_access', methods=['post'],
+        description=_("Versement d'une demande d'éclairage public dans l'extranet du SIGERLY"),
+        post={'request_body': {'schema': {'application/json': CREATE_SCHEMA}}})
+    def create(self, request, post_data):
+        post_data['id_typeinterv'] = int(post_data['id_typeinterv'])
+        post_data['id_urgence'] = int(post_data['id_urgence'])
+        post_data['id_qualification'] = int(post_data['id_qualification'])
+        post_data['elements'] = [
+            x.strip() for x in post_data['elements'].split(':') if x.strip()]
+
+        response = self.request('createIntervention.php/', json=post_data)
+        if not response.get('success', None):
+            raise APIError(response.get('message', None))
+        if not response.get('message', None):
+            raise APIError('No intervention id returned')
+        return {'data': response}
+
+    @endpoint(
+        display_order=2,
+        perm='can_access', methods=['post'],
+        description=_("Interroger le SIGERLY sur l'etat d'avancement d'une demande"),
+        post={'request_body': {'schema': {'application/json': QUERY_SCHEMA}}})
+    def query(self, request, post_data):
+        if post_data.get('id_intervention', None):
+            post_data['id_intervention'] = int(post_data['id_intervention'])
+            post_data.pop('date_debut_demande', None)
+            post_data.pop('date_fin_demande', None)
+            post_data.pop('insee', None)
+        else:
+            post_data['insee'] = [
+                x.strip() for x in post_data['insee'].split(':') if x.strip()]
+            post_data.pop('id_intervention', None)
+
+        response = self.request('getIntervention.php/', json=post_data)
+        for record in response:
+            record['id'] = record.get('code_inter')
+            record['text'] = '%(code_inter)s: %(libelle_intervention)s' % record
+        return {'data': response}
diff --git a/passerelle/contrib/sigerly/models.py~ b/passerelle/contrib/sigerly/models.py~
new file mode 100644
index 00000000..bebfd3c1
--- /dev/null
+++ b/passerelle/contrib/sigerly/models.py~
@@ -0,0 +1,80 @@
+# passerelle - uniform access to multiple data sources and services
+# Copyright (C) 2020  Entr'ouvert
+#
+# This program is free software: you can redistribute it and/or modify it
+# under the terms of the GNU Affero General Public License as published
+# by the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# 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/>.
+
+from urllib.parse import urljoin
+
+from django.db import models
+from django.utils.translation import ugettext_lazy as _
+
+from passerelle.base.models import BaseResource, HTTPResource
+from passerelle.utils.api import endpoint
+from passerelle.utils.jsonresponse import APIError
+
+CREATE_SCHEMA = {
+    '$schema': 'http://json-schema.org/draft-04/schema#',
+    "type": "object",
+    'required': ['demandeur', 'id_typeinterv', 'id_urgence', 'elements'],
+    'properties': {
+        'demandeur': {
+            'description': "Nom du demandeur",
+            'type': 'string',
+        },
+        'id_typeinterv': {
+            'description': "Type de l'intervention",
+            'type': 'string',
+        },
+        'id_urgence': {
+            'description': 'Urgence',
+            'type': 'string',
+        },
+        'observations': {
+            'description': 'Observations',
+            'type': 'string',
+        },
+        'elements': {
+            'description': "Identifiant de l'objet : liste séparée par ':'",
+            'type': 'string',
+            'pattern': r'^[0-9A-Z :]+$'
+        },
+    }
+}
+
+GET_SCHEMA = {
+    '$schema': 'http://json-schema.org/draft-04/schema#',
+    "type": "object",
+    'properties': {
+        'id_intervention': {
+            'description': 'Rechercher une intervention par son numéro' \
+            ' (non cumulable avec les autres filtres)',
+            'type': 'string',
+        },
+        'date_debut_demande': {
+            'description': 'Recherche toutes les interventions dont la date de demande' \
+            ' est supérieure ou égale à la date renseignée (YYYY-MM-DD)',
+            'type': 'string',
+        },
+        'date_fin_demande': {
+            'description': 'Recherche toutes les interventions dont la date de demande' \
+            ' est inférieure ou égale à la date renseignée (YYYY-MM-DD)',
+            'type': 'string',
+        },
+        'insee': {
+            'description': "Code insee de la commune : liste séparée par ':'",
+            'type': 'string',
+            'pattern': r'^[0-9A-Z :]+$'
+        },
+    }
+}
diff --git a/passerelle/settings.py b/passerelle/settings.py
index a2a9de1a..008f0886 100644
--- a/passerelle/settings.py
+++ b/passerelle/settings.py
@@ -155,16 +155,17 @@ INSTALLED_APPS = (
     'passerelle.apps.ovh',
     'passerelle.apps.oxyd',
     'passerelle.apps.phonecalls',
     'passerelle.apps.solis',
     'passerelle.apps.twilio',
     'passerelle.apps.vivaticket',
     # backoffice templates and static
     'gadjo',
+    'passerelle.contrib.sigerly',  # testing purpose, to remove
 )
 
 # disable some applications for now
 PASSERELLE_APP_BDP_ENABLED = False
 PASSERELLE_APP_GDC_ENABLED = False
 PASSERELLE_APP_STRASBOURG_EU_ENABLED = False
 
 # Authentication settings
diff --git a/tests/data/sigerly/getIntervention_1.json b/tests/data/sigerly/getIntervention_1.json
new file mode 100644
index 00000000..36bc9533
--- /dev/null
+++ b/tests/data/sigerly/getIntervention_1.json
@@ -0,0 +1,48 @@
+[
+   {
+      "code_inter" : "DP.20.003.5",
+      "date_cr1" : "2020-02-27",
+      "date_emission" : "2020-02-10",
+      "date_inter_plannif1" : "2020-02-20",
+      "date_inter_plannif2" : "2020-02-25",
+      "date_valid" : "2020-02-18",
+      "elements" : [
+         {
+            "ident" : "ALBE009A",
+            "libelle_objetgeo" : "Luminaire",
+            "travaux" : [
+               {
+                  "code_travaux" : "3.000.2",
+                  "quantite" : 1
+               },
+               {
+                  "code_travaux" : "3.000.3",
+                  "quantite" : 2
+               }
+            ]
+         },
+         {
+            "ident" : "ALBE010A",
+            "libelle_objetgeo" : "Luminaire",
+            "travaux" : [
+               {
+                  "code_travaux" : "3.000.7",
+                  "quantite" : 1
+               }
+            ]
+         }
+      ],
+      "heure_valid" : "10:10",
+      "libelle_intervention" : "DEPANNAGE EP",
+      "valid_entreprise" : true,
+      "valid_sydev" : true
+   },
+   {
+      "code_inter" : "DP.20.007.2",
+      "date_emission" : "2020-02-01",
+      "date_valid" : "2020-02-02",
+      "elements" : [],
+      "heure_valid" : "17:10",
+      "libelle_intervention" : "DEPANNAGE EP"
+   }
+]
diff --git a/tests/settings.py b/tests/settings.py
index ae8f8c66..de0a762d 100644
--- a/tests/settings.py
+++ b/tests/settings.py
@@ -23,16 +23,17 @@ INSTALLED_APPS += (
     'passerelle.contrib.greco',
     'passerelle.contrib.grenoble_gru',
     'passerelle.contrib.iparapheur',
     'passerelle.contrib.iws',
     'passerelle.contrib.lille_urban_card',
     'passerelle.contrib.mdph13',
     'passerelle.contrib.nancypoll',
     'passerelle.contrib.planitech',
+    #'passerelle.contrib.sigerly',
     'passerelle.contrib.solis_apa',
     'passerelle.contrib.solis_afi_mss',
     'passerelle.contrib.strasbourg_eu',
     'passerelle.contrib.stub_invoices',
     'passerelle.contrib.teamnet_axel',
     'passerelle.contrib.tcl',
     'passerelle.contrib.toulouse_axel',
     'passerelle.contrib.lille_kimoce',
diff --git a/tests/test_sigerly.py b/tests/test_sigerly.py
new file mode 100644
index 00000000..716e257e
--- /dev/null
+++ b/tests/test_sigerly.py
@@ -0,0 +1,164 @@
+# passerelle - uniform access to multiple data sources and services
+# Copyright (C) 2020  Entr'ouvert
+#
+# This program is free software: you can redistribute it and/or modify it
+# under the terms of the GNU Affero General Public License as published
+# by the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# 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 json
+import os
+
+import mock
+import pytest
+
+import utils
+
+from passerelle.contrib.sigerly.models import Sigerly
+from passerelle.utils.jsonresponse import APIError
+
+@pytest.fixture
+def connector(db):
+    return utils.setup_access_rights(Sigerly.objects.create(
+        slug='test',
+        base_url='https://dummy-server.org'
+    ))
+
+
+TEST_BASE_DIR = os.path.join(os.path.dirname(__file__), 'data', 'sigerly')
+
+def json_get_data(filename):
+    with open(os.path.join(TEST_BASE_DIR, "%s.json" % filename)) as fd:
+        return json.dumps(json.load(fd))
+
+def response(status_code, content):
+    return utils.FakedResponse(content=content, status_code=status_code)
+
+CREATE = response(200, json.dumps({
+    'success': True,
+    'message': '123',  # intervention id
+}))
+
+CREATE_ERROR_1 = response(200, json.dumps({
+    'success': False,
+    'message': 'XXX',  # error message
+}))
+
+CREATE_ERROR_2 = response(200, json.dumps({
+    'success': True,
+    'message': '',
+}))
+
+
+QUERY_1 = response(200, json_get_data('getIntervention_1'))
+
+
+def get_endpoint(name):
+    return utils.generic_endpoint_url('sigerly', name)
+
+
+@mock.patch('passerelle.utils.Request.post')
+@pytest.mark.parametrize('status_code, json_content, a_dict', [
+    (200, 'not json', None),
+    (500, '{"message": "help"}', {'message': 'help'}),
+    (500, 'not json', None),
+])
+def test_request_error(mocked_post, app, connector, status_code, json_content, a_dict):
+    mocked_post.side_effect = [response(status_code, json_content)]
+    with pytest.raises(APIError) as exc:
+        connector.request('some-url', json=None)
+    assert exc.value.err
+    if status_code == 200:
+        assert exc.value.http_status == 200
+        assert exc.value.args[0] == "invalid JSON content:'%s'" % json_content
+    else:
+        assert exc.value.data['status_code'] == status_code
+        assert exc.value.data['json_content'] == a_dict
+
+
+@mock.patch('passerelle.utils.Request.post')
+def test_create(mocked_post, app, connector):
+    mocked_post.side_effect = [CREATE]
+    endpoint = get_endpoint('create')
+    payload = {
+        'demandeur': 'Test webservice',
+        'id_typeinterv': '5',
+        'id_urgence': '1',
+        'id_qualification': '8',
+        'observations': 'Test webservice',
+        'elements': 'LIMW003D:LIMWW003C',
+    }
+    resp = app.post_json(endpoint, params=payload)
+    assert mocked_post.mock_calls == [mock.call(
+        'https://dummy-server.org/createIntervention.php/',
+        headers={'Accept': 'application/json'},
+        json={
+            'demandeur': 'Test webservice',
+            'id_typeinterv': 5,
+            'id_urgence': 1,
+            'id_qualification': 8,
+            'observations': 'Test webservice',
+            'elements': ['LIMW003D', 'LIMWW003C'],
+        })]
+    assert resp.json == {'err': 0, 'data': json.loads(CREATE.content)}
+
+
+@mock.patch('passerelle.utils.Request.post')
+@pytest.mark.parametrize('response, desc', [
+    (CREATE_ERROR_1, 'XXX'),
+    (CREATE_ERROR_2, 'No intervention id returned'),
+])
+def test_create_error(mocked_post, app, connector, response, desc):
+    mocked_post.side_effect = [response]
+    endpoint = get_endpoint('create')
+    payload = {
+        'demandeur': 'Test webservice',
+        'id_typeinterv': '5',
+        'id_urgence': '1',
+        'id_qualification': '8',
+        'observations': 'Test webservice',
+        'elements': 'LIMW003D:LIMWW003C',
+    }
+    resp = app.post_json(endpoint, params=payload)
+    assert resp.json['err']
+    assert resp.json['err_desc'] == desc
+
+
+@mock.patch('passerelle.utils.Request.post')
+def test_query(mocked_post, app, connector):
+    mocked_post.return_value = QUERY_1
+    endpoint = get_endpoint('query')
+    payload = {
+        'id_intervention': '25523',
+    }
+    resp = app.post_json(endpoint, params=payload)
+    assert mocked_post.mock_calls[0] == mock.call(
+        'https://dummy-server.org/getIntervention.php/',
+        headers={'Accept': 'application/json'},
+        json={'id_intervention': 25523})
+    assert not resp.json['err']
+    assert [x['id'] for x in resp.json['data']] == ['DP.20.003.5', 'DP.20.007.2']
+    assert [x['text'] for x in resp.json['data']] == [
+        'DP.20.003.5: DEPANNAGE EP', 'DP.20.007.2: DEPANNAGE EP']
+    payload = {
+        'date_debut_demande': '2019-01-01',
+        'date_fin_demande': '2019-01-31',
+        'insee': '069283:069293',
+    }
+    resp = app.post_json(endpoint, params=payload)
+    assert mocked_post.mock_calls[1] == mock.call(
+        'https://dummy-server.org/getIntervention.php/',
+        headers={'Accept': 'application/json'},
+        json={
+            'date_debut_demande': '2019-01-01',
+            'date_fin_demande': '2019-01-31',
+            'insee': ['069283', '069293'],
+        })
-- 
2.28.0

