From 5d37c1bd835c47b9e2e5d6db2a9ca364fdbe2f11 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Thomas=20NO=C3=8BL?= <tnoel@entrouvert.com>
Date: Sun, 10 Jan 2021 21:04:49 +0100
Subject: [PATCH] add Isere ENS connector (#50019)

---
 passerelle/contrib/isere_ens/__init__.py      |   0
 .../isere_ens/migrations/0001_initial.py      |  38 ++
 .../contrib/isere_ens/migrations/__init__.py  |   0
 passerelle/contrib/isere_ens/models.py        | 408 ++++++++++++++++++
 tests/settings.py                             |   1 +
 tests/test_isere_ens.py                       | 334 ++++++++++++++
 6 files changed, 781 insertions(+)
 create mode 100644 passerelle/contrib/isere_ens/__init__.py
 create mode 100644 passerelle/contrib/isere_ens/migrations/0001_initial.py
 create mode 100644 passerelle/contrib/isere_ens/migrations/__init__.py
 create mode 100644 passerelle/contrib/isere_ens/models.py
 create mode 100644 tests/test_isere_ens.py

diff --git a/passerelle/contrib/isere_ens/__init__.py b/passerelle/contrib/isere_ens/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/passerelle/contrib/isere_ens/migrations/0001_initial.py b/passerelle/contrib/isere_ens/migrations/0001_initial.py
new file mode 100644
index 00000000..69536a2d
--- /dev/null
+++ b/passerelle/contrib/isere_ens/migrations/0001_initial.py
@@ -0,0 +1,38 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.18 on 2021-01-19 13:09
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    initial = True
+
+    dependencies = [
+        ('base', '0028_rename_permissions'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='IsereENS',
+            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.URLField(help_text='Base API URL (before /api/...)', verbose_name='Webservice Base URL')),
+                ('token', models.CharField(max_length=128, verbose_name='Access token')),
+                ('users', models.ManyToManyField(blank=True, related_name='_isereens_users_+', related_query_name='+', to='base.ApiUser')),
+            ],
+            options={
+                'verbose_name': 'Espaces naturels sensibles du CD38',
+            },
+        ),
+    ]
diff --git a/passerelle/contrib/isere_ens/migrations/__init__.py b/passerelle/contrib/isere_ens/migrations/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/passerelle/contrib/isere_ens/models.py b/passerelle/contrib/isere_ens/models.py
new file mode 100644
index 00000000..8c68a2df
--- /dev/null
+++ b/passerelle/contrib/isere_ens/models.py
@@ -0,0 +1,408 @@
+# passerelle - uniform access to multiple data sources and services
+# Copyright (C) 2021 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 collections import OrderedDict
+import datetime
+
+from django.core.cache import cache
+from django.db import models
+from django.utils.formats import date_format
+from django.utils.six.moves.urllib import parse as urlparse
+from django.utils.translation import ugettext_lazy as _
+
+from passerelle.utils.conversion import simplify
+from passerelle.utils.jsonresponse import APIError
+from passerelle.utils.api import endpoint
+from passerelle.base.models import BaseResource, HTTPResource
+
+
+SITE_BOOKING_SCHOOL_SCHEMA = {
+    "$schema": "http://json-schema.org/draft-04/schema#",
+    "title": "ENS site/booking/school",
+    "description": "",
+    "type": "object",
+    "required": [
+        "code",
+        "status",
+        "beneficiary_id",
+        "beneficiary_first_name",
+        "beneficiary_last_name",
+        "beneficiary_email",
+        "beneficiary_phone",
+        "beneficiary_cellphone",
+        "entity_id",
+        "entity_name",
+        "entity_type",
+        "project",
+        "site",
+        "applicant",
+        "public",
+        "date",
+        "participants",
+        "morning",
+        "lunch",
+        "afternoon",
+        "pmr",
+        "grade_levels",
+        "animator",
+    ],
+    "properties": OrderedDict(
+        {
+            "code": {
+                "description": "booking code",
+                "type": "string",
+            },
+            "status": {
+                "description": "booking status",
+                "type": "string",
+            },
+            "beneficiary_id": {
+                "description": "beneficiary id",
+                "type": "string",
+            },
+            "beneficiary_first_name": {
+                "description": "beneficiary first name",
+                "type": "string",
+            },
+            "beneficiary_last_name": {
+                "description": "beneficiary last name",
+                "type": "string",
+            },
+            "beneficiary_email": {
+                "description": "beneficiary email",
+                "type": "string",
+            },
+            "beneficiary_phone": {
+                "description": "beneficiary phone number",
+                "type": "string",
+            },
+            "beneficiary_cellphone": {
+                "description": "beneficiary cell phone number",
+                "type": "string",
+            },
+            "entity_id": {
+                "description": "entity/school id (UAI/RNE)",
+                "type": "string",
+            },
+            "entity_name": {
+                "description": "entity/school name",
+                "type": "string",
+            },
+            "entity_type": {
+                "description": "entity/school type",
+                "type": "string",
+            },
+            "project": {
+                "description": "project code",
+                "type": "string",
+            },
+            "site": {
+                "description": "site id (code)",
+                "type": "string",
+            },
+            "applicant": {
+                "description": "applicant",
+                "type": "string",
+            },
+            "public": {
+                "description": "public",
+                "type": "string",
+            },
+            "date": {
+                "description": "booking date (format: YYYY-MM-DD)",
+                "type": "string",
+            },
+            "participants": {
+                "description": "number of participants",
+                "type": "string",
+                "pattern": "[0-9]+",
+            },
+            "morning": {
+                "description": "morning booking",
+                "type": "boolean",
+            },
+            "lunch": {
+                "description": "lunch booking",
+                "type": "boolean",
+            },
+            "afternoon": {
+                "description": "afternoon booking",
+                "type": "boolean",
+            },
+            "pmr": {
+                "description": "PMR",
+                "type": "boolean",
+            },
+            "grade_levels": {
+                "description": "grade levels",
+                "type": "array",
+                "items": {
+                    "type": "string",
+                    "description": "level",
+                },
+            },
+            "animator": {
+                "description": "animator id",
+                "type": "string",
+                "pattern": "[0-9]+",
+            },
+        }
+    ),
+}
+
+
+class IsereENS(BaseResource, HTTPResource):
+    category = _("Business Process Connectors")
+
+    base_url = models.URLField(
+        verbose_name=_("Webservice Base URL"),
+        help_text=_("Base API URL (before /api/...)"),
+    )
+    token = models.CharField(verbose_name=_("Access token"), max_length=128)
+
+    class Meta:
+        verbose_name = _("Espaces naturels sensibles de l'Isère")
+
+    def request(self, endpoint, params=None, json=None):
+        url = urlparse.urljoin(self.base_url, endpoint)
+        headers = {"token": self.token}
+        if json is not None:
+            response = self.requests.post(
+                url, params=params, json=json, headers=headers
+            )
+        else:
+            response = self.requests.get(url, params=params, 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,
+                },
+            )
+
+        if response.status_code == 204:  # 204 No Content
+            raise APIError("abnormal empty response")
+
+        try:
+            return response.json()
+        except ValueError:
+            raise APIError("invalid JSON in response: %r" % response.content[:1024])
+
+    @endpoint(
+        name="sites",
+        description=_("Sites"),
+        display_order=1,
+        perm="can_access",
+        parameters={
+            "q": {"description": _("Search text in name field")},
+            "id": {
+                "description": _("Returns site with code=id"),
+            },
+            "kind": {
+                "description": _("Select sites by bind: school_group or social"),
+            },
+        },
+    )
+    def sites(self, request, q=None, id=None, kind=None):
+        if id is not None:
+            site = self.request("api/1.0.0/site/" + id)
+            site["id"] = site["code"]
+            site["text"] = "%(name)s (%(city)s)" % site
+            sites = [site]
+        else:
+            cache_key = "isere-ens-sites-%d" % self.id
+            sites = cache.get(cache_key)
+            if not sites:
+                sites = self.request("api/1.0.0/site")
+                for site in sites:
+                    site["id"] = site["code"]
+                    site["text"] = "%(name)s (%(city)s)" % site
+                cache.set(cache_key, sites, 300)
+        if kind is not None:
+            sites = [site for site in sites if site.get(kind)]
+        if q is not None:
+            q = simplify(q)
+            sites = [site for site in sites if q in simplify(site["text"])]
+        return {"data": sites}
+
+    @endpoint(
+        name="animators",
+        description=_("Animators"),
+        display_order=2,
+        perm="can_access",
+        parameters={
+            "q": {"description": _("Search text in name field")},
+            "id": {
+                "description": _("Returns animator number id"),
+            },
+        },
+    )
+    def animators(self, request, q=None, id=None):
+        cache_key = "isere-ens-animators-%d" % self.id
+        animators = cache.get(cache_key)
+        if not animators:
+            animators = self.request("api/1.0.0/schoolAnimator")
+            for animator in animators:
+                animator["id"] = str(animator["id"])
+                animator["text"] = (
+                    "%(first_name)s %(last_name)s <%(email)s> (%(entity)s)" % animator
+                )
+            cache.set(cache_key, animators, 300)
+        if id is not None:
+            animators = [animator for animator in animators if animator["id"] == id]
+        if q is not None:
+            q = simplify(q)
+            animators = [
+                animator for animator in animators if q in simplify(animator["text"])
+            ]
+        return {"data": animators}
+
+    @endpoint(
+        name="site-calendar",
+        description=_("Available bookings for a site"),
+        display_order=3,
+        perm="can_access",
+        parameters={
+            "site": {"description": _("Site code (aka id)")},
+            "participants": {
+                "description": _("Number of participants"),
+            },
+            "start_date": {
+                "description": _(
+                    "First date of the calendar (format: YYYY-MM-DD, default: today)"
+                ),
+            },
+            "end_date": {
+                "description": _(
+                    "Last date of the calendar (format: YYYY-MM-DD, default: start_date + 92 days)"
+                ),
+            },
+        },
+    )
+    def site_calendar(
+        self, request, site, participants="1", start_date=None, end_date=None
+    ):
+        if start_date:
+            try:
+                start_date = datetime.datetime.strptime(start_date, "%Y-%m-%d").date()
+            except ValueError:
+                raise APIError(
+                    "bad start_date format (%s), should be YYYY-MM-DD" % start_date,
+                    http_status=400,
+                )
+        else:
+            start_date = datetime.date.today()
+        if end_date:
+            try:
+                end_date = datetime.datetime.strptime(end_date, "%Y-%m-%d").date()
+            except ValueError:
+                raise APIError(
+                    "bad end_date format (%s), should be YYYY-MM-DD" % end_date,
+                    http_status=400,
+                )
+        else:
+            end_date = start_date + datetime.timedelta(days=92)
+
+        params = {
+            "site": site,
+            "participants": participants,
+            "start_date": start_date.strftime("%Y-%m-%d"),
+            "end_date": end_date.strftime("%Y-%m-%d"),
+        }
+        dates = self.request("api/1.0.0/site/" + site + "/calendar", params=params)
+
+        for date in dates:
+            date["id"] = site + ":" + date["date"]
+            date["site"] = site
+            date_ = datetime.datetime.strptime(date["date"], "%Y-%m-%d").date()
+            date["date_format"] = date_format(date_, format="DATE_FORMAT")
+            date["disabled"] = False
+            date["color"] = "green"
+            for period in ("morning", "afternoon"):
+                if date[period] == "COMPLETE":
+                    date["color"] = "orange"
+                    date["%s_status" % period] = _("Complete")
+                else:
+                    date["%s_status" % period] = _("Available")
+            if date["morning"] == "COMPLETE" and date["afternoon"] == "COMPLETE":
+                date["disabled"] = True
+                date["color"] = "red"
+            if date["lunch"] == "CLOSE":
+                date["lunch_status"] = _("Complete")
+            else:
+                date["lunch_status"] = _("Available")
+            date["details"] = _(
+                "Morning (%(morning_status)s), Lunch (%(lunch_status)s), Afternoon (%(afternoon_status)s)"
+                % date
+            )
+            date["text"] = "%(date_format)s - %(details)s" % date
+        return {"data": dates}
+
+    @endpoint(
+        name="site-booking-school",
+        description=_("Booking a site for a school"),
+        display_order=4,
+        perm="can_access",
+        methods=["post"],
+        post={
+            "request_body": {
+                "schema": {
+                    "application/json": SITE_BOOKING_SCHOOL_SCHEMA,
+                }
+            }
+        },
+    )
+    def site_booking_school(self, request, post_data):
+        payload = {
+            "code": post_data["code"],
+            "status": post_data["status"],
+            "beneficiary": {
+                "id": post_data["beneficiary_id"],
+                "firstName": post_data["beneficiary_first_name"],
+                "lastName": post_data["beneficiary_last_name"],
+                "email": post_data["beneficiary_email"],
+                "phone": post_data["beneficiary_phone"],
+                "cellphone": post_data["beneficiary_cellphone"],
+            },
+            "entity": {
+                "id": post_data["entity_id"],
+                "name": post_data["entity_name"],
+                "type": post_data["entity_type"],
+            },
+            "booking": {
+                "projectCode": post_data.get("project"),
+                "siteCode": post_data["site"],
+                "applicant": post_data["applicant"],
+                "public": post_data["public"],
+                "bookingDate": post_data["date"],
+                "participants": int(post_data["participants"]),
+                "morning": post_data["morning"],
+                "lunch": post_data["lunch"],
+                "afternoon": post_data["afternoon"],
+                "pmr": post_data["pmr"],
+                "gradeLevels": post_data["grade_levels"],
+                "schoolAnimator": int(post_data["animator"]),
+            },
+        }
+        booking = self.request("api/1.0.0/booking", json=payload)
+        return {"data": booking}
diff --git a/tests/settings.py b/tests/settings.py
index c680f1ca..d3b738c1 100644
--- a/tests/settings.py
+++ b/tests/settings.py
@@ -22,6 +22,7 @@ INSTALLED_APPS += (
     'passerelle.contrib.grandlyon_streetsections',
     'passerelle.contrib.greco',
     'passerelle.contrib.grenoble_gru',
+    'passerelle.contrib.isere_ens',
     'passerelle.contrib.iparapheur',
     'passerelle.contrib.iws',
     'passerelle.contrib.lille_urban_card',
diff --git a/tests/test_isere_ens.py b/tests/test_isere_ens.py
new file mode 100644
index 00000000..7f05c8cd
--- /dev/null
+++ b/tests/test_isere_ens.py
@@ -0,0 +1,334 @@
+# -*- coding: utf-8 -*-
+# Passerelle - uniform access to data and services
+# Copyright (C) 2021  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; exclude 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.deepcopy 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 django.urls import reverse
+
+from passerelle.contrib.isere_ens.models import IsereENS
+from passerelle.utils.jsonresponse import APIError
+
+
+@pytest.fixture
+def setup(db):
+    return utils.setup_access_rights(
+        IsereENS.objects.create(
+            slug="test", base_url="https://ens38.example.net/", token="toktok"
+        )
+    )
+
+
+SITES_RESPONSE = """[
+  {
+    "name": "Save  - étangs de la Serre",
+    "code": "SD29a",
+    "city": "Arandon-Passins - Courtenay",
+    "school_group": true,
+    "social": true
+  },
+  {
+    "name": "Save - étangs de Passins",
+    "code": "SD29b",
+    "city": "Arandon-Passins",
+    "school_group": true,
+    "social": true
+  },
+  {
+    "name": "Save - lac de Save",
+    "code": "SD29c",
+    "city": "Arandon-Passins",
+    "school_group": true,
+    "social": false
+  }
+]"""
+
+SD29B_RESPONSE = """{
+  "name": "Save - étangs de Passins",
+  "code": "SD29b",
+  "type": "DEPARTMENTAL_ENS",
+  "city": "Arandon-Passins",
+  "natural_environment": "Etangs, tourbières, mares, forêts, rivière, pelouses sèches",
+  "lunch_equipment": null,
+  "pmr_access": "NO",
+  "school_group": true,
+  "social": true,
+  "user_informations": "<p>Un album jeunesse a &eacute;t&eacute; r&eacute;alis&eacute; sur l'ENS de la Save. Il permet aux enfants de s'approprier le site en amont d'une sortie.</p>",
+  "dogs": "LEASH",
+  "educational_equipments": null,
+  "eps_regulation": null,
+  "main_manager": {
+    "first_name": "Jo",
+    "last_name": "Smith",
+    "managing_entity": "Département de l'Isère",
+    "main_phone": "01 23 45 67 89",
+    "email": "jo.smith@example.net"
+  }
+}"""
+
+SITE_404_RESPONSE = """{
+  "user_message": "Impossible de trouver le site",
+  "message": "Site not found with code SD29x"
+}"""
+
+ANIMATORS_RESPONSE = """[
+  {
+    "id": 1,
+    "first_name": "Francis",
+    "last_name": "Kuntz",
+    "email": "fk@mail.grd",
+    "phone": "123",
+    "entity": "Association Nature Morte"
+  },
+  {
+    "id": 2,
+    "first_name": "Michael",
+    "last_name": "Kael",
+    "email": "mk@mail.grd",
+    "phone": "456",
+    "entity": "Association Porte de l'Enfer"
+  }
+]"""
+
+SITE_CALENDAR_RESPONSE = """[
+  {
+    "date": "2020-01-21",
+    "morning": "AVAILABLE",
+    "lunch": "CLOSE",
+    "afternoon": "AVAILABLE"
+  },
+  {
+    "date": "2020-01-22",
+    "morning": "AVAILABLE",
+    "lunch": "OPEN",
+    "afternoon": "COMPLETE"
+  },
+  {
+    "date": "2020-01-23",
+    "morning": "COMPLETE",
+    "lunch": "CLOSE",
+    "afternoon": "COMPLETE"
+  }
+]"""
+
+BOOK_RESPONSE = """{
+  "status": "OK"
+}"""
+
+
+@mock.patch("passerelle.utils.Request.get")
+def test_get_sites(mocked_get, app, setup):
+    mocked_get.return_value = utils.FakedResponse(
+        content=SITES_RESPONSE, status_code=200
+    )
+    endpoint = reverse(
+        "generic-endpoint",
+        kwargs={"connector": "isere-ens", "slug": setup.slug, "endpoint": "sites"},
+    )
+    response = app.get(endpoint)
+    assert mocked_get.call_args[0][0].endswith("api/1.0.0/site")
+    assert mocked_get.call_args[1]["headers"]["token"] == "toktok"
+    assert mocked_get.call_count == 1
+    assert "data" in response.json
+    assert response.json["err"] == 0
+    for item in response.json["data"]:
+        assert "id" in item
+        assert "text" in item
+        assert "city" in item
+        assert "code" in item
+
+    # test cache system
+    response = app.get(endpoint)
+    assert mocked_get.call_count == 1
+
+    response = app.get(endpoint + "?q=etangs")
+    assert len(response.json["data"]) == 2
+    response = app.get(endpoint + "?q=CourTe")
+    assert len(response.json["data"]) == 1
+    response = app.get(endpoint + "?kind=social")
+    assert len(response.json["data"]) == 2
+
+    mocked_get.return_value = utils.FakedResponse(
+        content=SD29B_RESPONSE, status_code=200
+    )
+    response = app.get(endpoint + "?id=SD29b")
+    assert mocked_get.call_args[0][0].endswith("api/1.0.0/site/SD29b")
+    assert len(response.json["data"]) == 1
+    assert response.json["data"][0]["id"] == "SD29b"
+    assert response.json["data"][0]["dogs"] == "LEASH"
+
+    # bad response for ENS API
+    mocked_get.return_value = utils.FakedResponse(
+        content=SITE_404_RESPONSE, status_code=404
+    )
+    response = app.get(endpoint + "?id=SD29x")
+    assert mocked_get.call_args[0][0].endswith("api/1.0.0/site/SD29x")
+    assert response.json["err"] == 1
+    assert response.json["err_class"].endswith("APIError")
+    assert response.json["err_desc"].startswith("error status:404")
+    assert response.json["data"]["status_code"] == 404
+    assert (
+        response.json["data"]["json_content"]["message"]
+        == "Site not found with code SD29x"
+    )
+    mocked_get.return_value = utils.FakedResponse(content="crash", status_code=500)
+    response = app.get(endpoint + "?id=foo500")
+    assert mocked_get.call_args[0][0].endswith("api/1.0.0/site/foo500")
+    assert response.json["err"] == 1
+    assert response.json["err_desc"].startswith("error status:500")
+    assert response.json["err_class"].endswith("APIError")
+    assert response.json["data"]["status_code"] == 500
+    assert response.json["data"]["json_content"] is None
+    mocked_get.return_value = utils.FakedResponse(content=None, status_code=204)
+    response = app.get(endpoint + "?id=foo204")
+    assert mocked_get.call_args[0][0].endswith("api/1.0.0/site/foo204")
+    assert response.json["err"] == 1
+    assert response.json["err_class"].endswith("APIError")
+    assert response.json["err_desc"] == "abnormal empty response"
+    mocked_get.return_value = utils.FakedResponse(content="not json", status_code=200)
+    response = app.get(endpoint + "?id=foo")
+    assert mocked_get.call_args[0][0].endswith("api/1.0.0/site/foo")
+    assert response.json["err"] == 1
+    assert response.json["err_class"].endswith("APIError")
+    assert response.json["err_desc"].startswith("invalid JSON in response:")
+
+
+@mock.patch("passerelle.utils.Request.get")
+def test_get_animators(mocked_get, app, setup):
+    mocked_get.return_value = utils.FakedResponse(
+        content=ANIMATORS_RESPONSE, status_code=200
+    )
+    endpoint = reverse(
+        "generic-endpoint",
+        kwargs={"connector": "isere-ens", "slug": setup.slug, "endpoint": "animators"},
+    )
+    response = app.get(endpoint)
+    assert mocked_get.call_args[0][0].endswith("api/1.0.0/schoolAnimator")
+    assert mocked_get.call_count == 1
+    assert "data" in response.json
+    assert response.json["err"] == 0
+    for item in response.json["data"]:
+        assert "id" in item
+        assert "text" in item
+        assert "first_name" in item
+        assert "email" in item
+
+    # test cache system
+    response = app.get(endpoint)
+    assert mocked_get.call_count == 1
+
+    response = app.get(endpoint + "?q=Kael")
+    assert len(response.json["data"]) == 1
+    response = app.get(endpoint + "?q=association")
+    assert len(response.json["data"]) == 2
+    response = app.get(endpoint + "?q=mail.grd")
+    assert len(response.json["data"]) == 2
+    response = app.get(endpoint + "?id=2")
+    assert len(response.json["data"]) == 1
+    assert response.json["data"][0]["first_name"] == "Michael"
+
+
+@mock.patch("passerelle.utils.Request.get")
+def test_get_site_calendar(mocked_get, app, setup, freezer):
+    freezer.move_to("2021-01-21 12:00:00")
+    mocked_get.return_value = utils.FakedResponse(
+        content=SITE_CALENDAR_RESPONSE, status_code=200
+    )
+    endpoint = reverse(
+        "generic-endpoint",
+        kwargs={
+            "connector": "isere-ens",
+            "slug": setup.slug,
+            "endpoint": "site-calendar",
+        },
+    )
+    response = app.get(endpoint + "?site=SD29b")
+    assert mocked_get.call_args[0][0].endswith("api/1.0.0/site/SD29b/calendar")
+    assert mocked_get.call_args[1]["params"]["start_date"] == "2021-01-21"
+    assert mocked_get.call_args[1]["params"]["end_date"] == "2021-04-23"
+    assert response.json["err"] == 0
+    assert len(response.json["data"]) == 3
+    response = app.get(endpoint + "?site=SD29b&start_date=2021-01-22")
+    assert mocked_get.call_args[1]["params"]["start_date"] == "2021-01-22"
+    assert mocked_get.call_args[1]["params"]["end_date"] == "2021-04-24"
+    assert response.json["err"] == 0
+    response = app.get(
+        endpoint + "?site=SD29b&start_date=2021-01-22&end_date=2021-01-30"
+    )
+    assert mocked_get.call_args[1]["params"]["start_date"] == "2021-01-22"
+    assert mocked_get.call_args[1]["params"]["end_date"] == "2021-01-30"
+    assert response.json["err"] == 0
+    response = app.get(endpoint + "?site=SD29b&start_date=foo", status=400)
+    assert response.json["err"] == 1
+    assert response.json["err_class"].endswith("APIError")
+    assert (
+        response.json["err_desc"] == "bad start_date format (foo), should be YYYY-MM-DD"
+    )
+    response = app.get(endpoint + "?site=SD29b&end_date=bar", status=400)
+    assert response.json["err"] == 1
+    assert response.json["err_class"].endswith("APIError")
+    assert (
+        response.json["err_desc"] == "bad end_date format (bar), should be YYYY-MM-DD"
+    )
+
+
+@mock.patch("passerelle.utils.Request.post")
+def test_post_book(mocked_post, app, setup):
+    mocked_post.return_value = utils.FakedResponse(
+        content=BOOK_RESPONSE, status_code=200
+    )
+    endpoint = reverse(
+        "generic-endpoint",
+        kwargs={
+            "connector": "isere-ens",
+            "slug": setup.slug,
+            "endpoint": "site-booking-school",
+        },
+    )
+    book = {
+        "code": "resa",
+        "status": "OK",
+        "beneficiary_id": "42",
+        "beneficiary_first_name": "Foo",
+        "beneficiary_last_name": "Bar",
+        "beneficiary_email": "foobar@example.net",
+        "beneficiary_phone": "9876",
+        "beneficiary_cellphone": "06",
+        "entity_id": "38420D",
+        "entity_name": "Ecole FooBar",
+        "entity_type": "school",
+        "project": "Publik",
+        "site": "SD29b",
+        "applicant": "app",
+        "public": "GS",
+        "date": "2020-01-22",
+        "participants": "50",
+        "morning": True,
+        "lunch": False,
+        "afternoon": False,
+        "pmr": True,
+        "grade_levels": ["CP", "CE1"],
+        "animator": "42",
+    }
+    response = app.post_json(endpoint, params=book)
+    assert mocked_post.call_args[0][0].endswith("api/1.0.0/booking")
+    assert mocked_post.call_count == 1
-- 
2.29.2

