From 7536aacc6382d0be58154a49cc19c2fb0c57cf70 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Laur=C3=A9line=20Gu=C3=A9rin?= <zebuline@entrouvert.com>
Date: Tue, 4 May 2021 10:49:39 +0200
Subject: [PATCH 1/2] caluire-axel: code factorization (#53704)

---
 passerelle/contrib/toulouse_axel/models.py    | 100 ++++---
 passerelle/contrib/toulouse_axel/schemas.py   | 160 +----------
 passerelle/contrib/toulouse_axel/utils.py     |  84 ------
 passerelle/contrib/utils/__init__.py          |   0
 passerelle/contrib/utils/axel.py              | 264 ++++++++++++++++++
 .../exceptions.py => tests/test_axel_utils.py |  25 +-
 tests/test_toulouse_axel.py                   |  73 ++---
 tests/test_toulouse_axel_utils.py             |  17 +-
 8 files changed, 361 insertions(+), 362 deletions(-)
 create mode 100644 passerelle/contrib/utils/__init__.py
 create mode 100644 passerelle/contrib/utils/axel.py
 rename passerelle/contrib/toulouse_axel/exceptions.py => tests/test_axel_utils.py (57%)

diff --git a/passerelle/contrib/toulouse_axel/models.py b/passerelle/contrib/toulouse_axel/models.py
index 2c513af2..b5397947 100644
--- a/passerelle/contrib/toulouse_axel/models.py
+++ b/passerelle/contrib/toulouse_axel/models.py
@@ -32,7 +32,7 @@ from passerelle.utils.api import endpoint
 from passerelle.utils.jsonresponse import APIError
 from . import schemas
 from . import utils
-from .exceptions import AxelError
+from passerelle.contrib.utils import axel
 
 logger = logging.getLogger('passerelle.contrib.toulouse_axel')
 
@@ -120,7 +120,7 @@ class ToulouseAxel(BaseResource):
 
         try:
             result = schemas.ref_date_gestion_dui(self)
-        except AxelError as e:
+        except axel.AxelError as e:
             raise APIError(
                 'Axel error: %s' % e,
                 err_code='error',
@@ -141,7 +141,7 @@ class ToulouseAxel(BaseResource):
     def check_dui(self, post_data):
         try:
             result = schemas.ref_verif_dui(self, {'PORTAIL': {'DUI': post_data}})
-        except AxelError as e:
+        except axel.AxelError as e:
             raise APIError(
                 'Axel error: %s' % e,
                 err_code='error',
@@ -292,7 +292,7 @@ class ToulouseAxel(BaseResource):
     def get_family_data(self, dui, check_registrations=False, with_management_dates=False):
         try:
             result = schemas.ref_famille_dui(self, {'PORTAIL': {'DUI': {'IDDUI': dui}}})
-        except AxelError as e:
+        except axel.AxelError as e:
             raise APIError(
                 'Axel error: %s' % e,
                 err_code='error',
@@ -452,7 +452,7 @@ class ToulouseAxel(BaseResource):
         flags = sorted(schemas.UPDATE_FAMILY_FLAGS.keys())
         for flag in flags:
             flag_value = post_data.get(flag)
-            flag_value = utils.encode_bool(flag_value)
+            flag_value = axel.encode_bool(flag_value)
 
             # no update for the related block
             if flag_value == 'OUI':
@@ -579,7 +579,7 @@ class ToulouseAxel(BaseResource):
                 # transform ALLERGIE block
                 new_allergie = []
                 for key in ['ASTHME', 'MEDICAMENTEUSES', 'ALIMENTAIRES']:
-                    if utils.encode_bool(child['SANITAIRE']['ALLERGIE'][key]) == 'OUI':
+                    if axel.encode_bool(child['SANITAIRE']['ALLERGIE'][key]) == 'OUI':
                         new_allergie.append(
                             {
                                 'TYPE': key,
@@ -642,7 +642,7 @@ class ToulouseAxel(BaseResource):
 
         # prepare data
         post_data['IDDUI'] = link.dui
-        post_data['DATEDEMANDE'] = datetime.date.today().strftime(utils.json_date_format)
+        post_data['DATEDEMANDE'] = datetime.date.today().strftime(axel.json_date_format)
 
         self.sanitize_update_family_data(dui=link.dui, post_data=post_data)
 
@@ -653,7 +653,7 @@ class ToulouseAxel(BaseResource):
 
         try:
             result = schemas.form_maj_famille_dui(self, {'PORTAIL': {'DUI': post_data}})
-        except AxelError as e:
+        except axel.AxelError as e:
             raise APIError(
                 'Axel error: %s' % e,
                 err_code='error',
@@ -680,7 +680,7 @@ class ToulouseAxel(BaseResource):
 
         try:
             result = schemas.ref_facture_a_payer(self, {'PORTAIL': {'DUI': {'IDDUI': dui}}})
-        except AxelError as e:
+        except axel.AxelError as e:
             raise APIError(
                 'Axel error: %s' % e,
                 err_code='error',
@@ -701,7 +701,7 @@ class ToulouseAxel(BaseResource):
             result = schemas.list_dui_factures(
                 self, {'LISTFACTURE': {'NUMDUI': link.dui, 'DEBUT': '1970-01-01'}}
             )
-        except AxelError as e:
+        except axel.AxelError as e:
             raise APIError(
                 'Axel error: %s' % e,
                 err_code='error',
@@ -829,7 +829,7 @@ class ToulouseAxel(BaseResource):
             result = schemas.ref_facture_pdf(
                 self, {'PORTAIL': {'FACTUREPDF': {'IDFACTURE': int(invoice['display_id'])}}}
             )
-        except AxelError as e:
+        except axel.AxelError as e:
             raise APIError(
                 'Axel error: %s' % e,
                 err_code='error',
@@ -876,10 +876,10 @@ class ToulouseAxel(BaseResource):
 
         transaction_amount = invoice['amount']
         transaction_id = data['transaction_id']
-        transaction_date = utils.parse_datetime(data['transaction_date'])
+        transaction_date = axel.parse_datetime(data['transaction_date'])
         if transaction_date is None:
             raise APIError('invalid transaction_date')
-        transaction_date = utils.encode_datetime(transaction_date)
+        transaction_date = axel.encode_datetime(transaction_date)
         post_data = {
             'IDFACTURE': int(invoice_id),
             'IDREGIEENCAISSEMENT': '',
@@ -889,7 +889,7 @@ class ToulouseAxel(BaseResource):
         }
         try:
             schemas.form_paiement_dui(self, {'PORTAIL': {'DUI': post_data}})
-        except AxelError as e:
+        except axel.AxelError as e:
             raise APIError(
                 'Axel error: %s' % e,
                 err_code='error',
@@ -913,7 +913,7 @@ class ToulouseAxel(BaseResource):
                     }
                 },
             )
-        except AxelError as e:
+        except axel.AxelError as e:
             raise APIError(
                 'Axel error: %s' % e,
                 err_code='error',
@@ -993,8 +993,8 @@ class ToulouseAxel(BaseResource):
                 {
                     'IDACTIVITE': activity_id,
                     'ANNEEREFERENCE': str(reference_year),
-                    'DATEDEBUT': start_date.strftime(utils.json_date_format),
-                    'DATEDFIN': end_date.strftime(utils.json_date_format),
+                    'DATEDEBUT': start_date.strftime(axel.json_date_format),
+                    'DATEDFIN': end_date.strftime(axel.json_date_format),
                 }
             )
         try:
@@ -1012,7 +1012,7 @@ class ToulouseAxel(BaseResource):
                     }
                 },
             )
-        except AxelError as e:
+        except axel.AxelError as e:
             raise APIError(
                 'Axel error: %s' % e,
                 err_code='error',
@@ -1044,12 +1044,12 @@ class ToulouseAxel(BaseResource):
 
         for activity in child_activities.get('ACTIVITE', []):
             activity['id'] = activity['IDACTIVITE']
-            start_date = datetime.datetime.strptime(activity['DATEENTREE'], utils.json_date_format)
-            end_date = datetime.datetime.strptime(activity['DATESORTIE'], utils.json_date_format)
+            start_date = datetime.datetime.strptime(activity['DATEENTREE'], axel.json_date_format)
+            end_date = datetime.datetime.strptime(activity['DATESORTIE'], axel.json_date_format)
             activity['text'] = u'{} (inscription du {} au {})'.format(
                 activity['LIBELLEACTIVITE'],
-                start_date.strftime(utils.xml_date_format),
-                end_date.strftime(utils.xml_date_format),
+                start_date.strftime(axel.xml_date_format),
+                end_date.strftime(axel.xml_date_format),
             )
             activity['annee_reference'] = reference_year
             activity['annee_reference_short'] = str(reference_year)[2:]
@@ -1080,7 +1080,7 @@ class ToulouseAxel(BaseResource):
         # get pivot date
         try:
             pivot_date = datetime.datetime.strptime(
-                '%s-%s' % (reference_year, pivot_date), utils.json_date_format
+                '%s-%s' % (reference_year, pivot_date), axel.json_date_format
             ).date()
         except ValueError:
             raise APIError('bad date format, should be MM-DD', err_code='bad-request', http_status=400)
@@ -1094,7 +1094,7 @@ class ToulouseAxel(BaseResource):
                 'id': str(reference_year),
                 'text': '%s/%s' % (reference_year, reference_year + 1),
                 'type': 'encours',
-                'refdate': today.strftime(utils.json_date_format),
+                'refdate': today.strftime(axel.json_date_format),
             }
         ]
         if today < pivot_date:
@@ -1115,7 +1115,7 @@ class ToulouseAxel(BaseResource):
                 'id': str(reference_year + 1),
                 'text': '%s/%s' % (reference_year + 1, reference_year + 2),
                 'type': 'suivante',
-                'refdate': next_ref_date.strftime(utils.json_date_format),
+                'refdate': next_ref_date.strftime(axel.json_date_format),
             }
         )
         return {'data': data}
@@ -1133,7 +1133,7 @@ class ToulouseAxel(BaseResource):
     def clae_children_activities_info(self, request, NameID, booking_date):
         link = self.get_link(NameID)
         try:
-            booking_date = datetime.datetime.strptime(booking_date, utils.json_date_format)
+            booking_date = datetime.datetime.strptime(booking_date, axel.json_date_format)
         except ValueError:
             raise APIError('bad date format, should be YYYY-MM-DD', err_code='bad-request', http_status=400)
 
@@ -1159,8 +1159,8 @@ class ToulouseAxel(BaseResource):
     def clae_booking_activities_info(self, request, NameID, idpersonne, start_date, end_date):
         link = self.get_link(NameID)
         try:
-            start_date = datetime.datetime.strptime(start_date, utils.json_date_format).date()
-            end_date = datetime.datetime.strptime(end_date, utils.json_date_format).date()
+            start_date = datetime.datetime.strptime(start_date, axel.json_date_format).date()
+            end_date = datetime.datetime.strptime(end_date, axel.json_date_format).date()
         except ValueError:
             raise APIError('bad date format, should be YYYY-MM-DD', err_code='bad-request', http_status=400)
 
@@ -1174,8 +1174,8 @@ class ToulouseAxel(BaseResource):
             booking_data = {d['TYPEACTIVITE']: d for d in booking_data}
             start_date, end_date = utils.get_week_dates_from_date(week_start_date)
             week = 'week:%s:%s' % (
-                start_date.strftime(utils.json_date_format),
-                end_date.strftime(utils.json_date_format),
+                start_date.strftime(axel.json_date_format),
+                end_date.strftime(axel.json_date_format),
             )
             day_date = week_start_date
             while day_date <= week_end_date:
@@ -1187,7 +1187,7 @@ class ToulouseAxel(BaseResource):
                     booked = activity['booking']['days'][day]
                     if booked is not None:
                         yield {
-                            'day': day_date.strftime(utils.json_date_format),
+                            'day': day_date.strftime(axel.json_date_format),
                             'activity_id': activity['id'],
                             'activity_type': activity_type,
                             'activity_label': activity['LIBELLEACTIVITE'],
@@ -1218,8 +1218,8 @@ class ToulouseAxel(BaseResource):
         entree_dates = [act['DATEENTREE'] for act in child_activities.get('ACTIVITE', [])]
         sortie_dates = [act['DATESORTIE'] for act in child_activities.get('ACTIVITE', [])]
         return (
-            datetime.datetime.strptime(max(entree_dates), utils.json_date_format).date(),
-            datetime.datetime.strptime(min(sortie_dates), utils.json_date_format).date(),
+            datetime.datetime.strptime(max(entree_dates), axel.json_date_format).date(),
+            datetime.datetime.strptime(min(sortie_dates), axel.json_date_format).date(),
         )
 
     @endpoint(
@@ -1240,8 +1240,8 @@ class ToulouseAxel(BaseResource):
     ):
         link = self.get_link(NameID)
         try:
-            start_date = datetime.datetime.strptime(start_date, utils.json_date_format).date()
-            end_date = datetime.datetime.strptime(end_date, utils.json_date_format).date()
+            start_date = datetime.datetime.strptime(start_date, axel.json_date_format).date()
+            end_date = datetime.datetime.strptime(end_date, axel.json_date_format).date()
         except ValueError:
             raise APIError('bad date format, should be YYYY-MM-DD', err_code='bad-request', http_status=400)
         if activity_type not in ['MAT', 'MIDI', 'SOIR', 'GARD']:
@@ -1286,7 +1286,7 @@ class ToulouseAxel(BaseResource):
                 day = WEEKDAYS[day_date.weekday()]
                 activity_day = {
                     'id': '{}:{}:{}:{}'.format(
-                        idpersonne, activity_type, activity['id'], day_date.strftime(utils.json_date_format)
+                        idpersonne, activity_type, activity['id'], day_date.strftime(axel.json_date_format)
                     ),
                     'text': dateformat.format(day_date, 'l j F Y'),
                     'disabled': activity['booking']['days'][day] is None,
@@ -1328,7 +1328,7 @@ class ToulouseAxel(BaseResource):
     ):
         link = self.get_link(NameID)
         try:
-            booking_date = datetime.datetime.strptime(booking_date, utils.json_date_format).date()
+            booking_date = datetime.datetime.strptime(booking_date, axel.json_date_format).date()
         except ValueError:
             raise APIError('bad date format, should be YYYY-MM-DD', err_code='bad-request', http_status=400)
         if activity_type not in ['MAT', 'MIDI', 'SOIR', 'GARD']:
@@ -1408,12 +1408,10 @@ class ToulouseAxel(BaseResource):
         # check dates
         today = datetime.date.today()
         start_date_min = today + datetime.timedelta(days=8)
-        start_date = datetime.datetime.strptime(
-            post_data['booking_start_date'], utils.json_date_format
-        ).date()
+        start_date = datetime.datetime.strptime(post_data['booking_start_date'], axel.json_date_format).date()
         reference_year = utils.get_reference_year_from_date(start_date)
         end_date_max = datetime.date(reference_year + 1, 7, 31)
-        end_date = datetime.datetime.strptime(post_data['booking_end_date'], utils.json_date_format).date()
+        end_date = datetime.datetime.strptime(post_data['booking_end_date'], axel.json_date_format).date()
         if start_date > end_date:
             raise APIError(
                 'booking_start_date should be before booking_end_date',
@@ -1461,7 +1459,7 @@ class ToulouseAxel(BaseResource):
                     post_data['child_id'],
                     activity_type,
                     activity_id,
-                    day_date.strftime(utils.json_date_format),
+                    day_date.strftime(axel.json_date_format),
                 )
                 if key in post_data['booking_list_%s' % activity_type]:
                     week_pattern = (
@@ -1483,8 +1481,8 @@ class ToulouseAxel(BaseResource):
                 week_pattern = get_week_pattern(real_start_date, real_end_date, activity_type, activity_id)
                 activity['PERIODE'].append(
                     {
-                        'DATEDEBUT': real_start_date.strftime(utils.json_date_format),
-                        'DATEDFIN': real_end_date.strftime(utils.json_date_format),
+                        'DATEDEBUT': real_start_date.strftime(axel.json_date_format),
+                        'DATEDFIN': real_end_date.strftime(axel.json_date_format),
                         'SEMAINETYPE': week_pattern,
                     }
                 )
@@ -1494,7 +1492,7 @@ class ToulouseAxel(BaseResource):
         # build data
         data = {
             'IDDUI': link.dui,
-            'DATEDEMANDE': today.strftime(utils.json_date_format),
+            'DATEDEMANDE': today.strftime(axel.json_date_format),
             'ENFANT': [
                 {
                     'IDPERSONNE': post_data['child_id'],
@@ -1510,7 +1508,7 @@ class ToulouseAxel(BaseResource):
 
         try:
             result = schemas.reservation_annuelle(self, {'PORTAIL': {'DUI': data}})
-        except AxelError as e:
+        except axel.AxelError as e:
             raise APIError(
                 'Axel error: %s' % e,
                 err_code='error',
@@ -1562,7 +1560,7 @@ class ToulouseAxel(BaseResource):
         # build dates of the period
         today = datetime.date.today()
         start_date_min = today + datetime.timedelta(days=8)
-        start_date = datetime.datetime.strptime(post_data['booking_date'], utils.json_date_format).date()
+        start_date = datetime.datetime.strptime(post_data['booking_date'], axel.json_date_format).date()
         start_date = max(start_date, start_date_min)
         reference_year = utils.get_reference_year_from_date(start_date)
         end_date = datetime.date(reference_year + 1, 7, 31)
@@ -1593,8 +1591,8 @@ class ToulouseAxel(BaseResource):
                 'ANNEEREFERENCE': str(reference_year),
                 'PERIODE': [
                     {
-                        'DATEDEBUT': start_date.strftime(utils.json_date_format),
-                        'DATEDFIN': end_date.strftime(utils.json_date_format),
+                        'DATEDEBUT': start_date.strftime(axel.json_date_format),
+                        'DATEDFIN': end_date.strftime(axel.json_date_format),
                         'SEMAINETYPE': week_pattern,
                     }
                 ],
@@ -1603,7 +1601,7 @@ class ToulouseAxel(BaseResource):
         # build data
         data = {
             'IDDUI': link.dui,
-            'DATEDEMANDE': today.strftime(utils.json_date_format),
+            'DATEDEMANDE': today.strftime(axel.json_date_format),
             'ENFANT': [
                 {
                     'IDPERSONNE': post_data['child_id'],
@@ -1619,7 +1617,7 @@ class ToulouseAxel(BaseResource):
 
         try:
             result = schemas.reservation_annuelle(self, {'PORTAIL': {'DUI': data}})
-        except AxelError as e:
+        except axel.AxelError as e:
             raise APIError(
                 'Axel error: %s' % e,
                 err_code='error',
diff --git a/passerelle/contrib/toulouse_axel/schemas.py b/passerelle/contrib/toulouse_axel/schemas.py
index 19da22aa..941deac4 100644
--- a/passerelle/contrib/toulouse_axel/schemas.py
+++ b/passerelle/contrib/toulouse_axel/schemas.py
@@ -15,156 +15,16 @@
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
 import copy
-import datetime
 import os
-import re
-import xml.etree.ElementTree as ET
-from collections import namedtuple
 
-from django.utils.encoding import force_text
-
-import xmlschema
-
-from passerelle.utils.xml import JSONSchemaFromXMLSchema
-from . import utils
-from .exceptions import AxelError
+from passerelle.contrib.utils import axel
 
 BASE_XSD_PATH = os.path.join(os.path.dirname(__file__), 'xsd')
 
 
-class AxelSchema(JSONSchemaFromXMLSchema):
-    type_map = {
-        '{urn:AllAxelTypes}DATEREQUIREDType': 'date',
-        '{urn:AllAxelTypes}DATEType': 'date_optional',
-        '{urn:AllAxelTypes}OUINONREQUIREDType': 'bool',
-        '{urn:AllAxelTypes}OUINONType': 'bool_optional',
-    }
-
-    @classmethod
-    def schema_date(cls):
-        return {
-            'type': 'string',
-            'pattern': '[0-9]{4}-[0-9]{2}-[0-9]{2}',
-        }
-
-    def encode_date(self, obj):
-        try:
-            return datetime.datetime.strptime(obj, utils.json_date_format).strftime(utils.xml_date_format)
-        except ValueError:
-            return obj
-
-    def encode_date_optional(self, obj):
-        if not obj:
-            return obj
-        return self.encode_date(obj)
-
-    def decode_date(self, data):
-        value = datetime.datetime.strptime(data.text, utils.xml_date_format).strftime(utils.json_date_format)
-        return xmlschema.ElementData(
-            tag=data.tag, text=value, content=data.content, attributes=data.attributes
-        )
-
-    def decode_date_optional(self, data):
-        if not data.text:
-            return data
-        return self.decode_date(data)
-
-    @classmethod
-    def schema_bool(cls):
-        return copy.deepcopy(utils.boolean_type)
-
-    def encode_bool(self, obj):
-        return utils.encode_bool(obj)
-
-    def decode_bool(self, data):
-        value = False
-        if data.text.lower() == 'oui':
-            value = True
-        return xmlschema.ElementData(
-            tag=data.tag, text=value, content=data.content, attributes=data.attributes
-        )
-
-    @classmethod
-    def schema_bool_optional(cls):
-        schema_bool_optional = cls.schema_bool()
-        schema_bool_optional['oneOf'].append({'type': 'string', 'enum': ['']})
-        return schema_bool_optional
-
-    def encode_bool_optional(self, obj):
-        return self.encode_bool(obj)
-
-    def decode_bool_optional(self, data):
-        if not data.text:
-            return data
-        return self.decode_bool(data)
-
-
-def xml_schema_converter(name, root_element):
-    xsd_path = os.path.join(BASE_XSD_PATH, name)
-    if not os.path.exists(xsd_path):
-        return None
-    return AxelSchema(xsd_path, root_element)
-
-
-OperationResult = namedtuple('OperationResult', ['json_response', 'xml_request', 'xml_response'])
-
-
-class Operation(object):
-    def __init__(self, operation, prefix='Dui/', request_root_element='PORTAIL'):
-        self.operation = operation
-        self.request_converter = xml_schema_converter(
-            '%sQ_%s.xsd' % (prefix, operation), request_root_element
-        )
-        self.response_converter = xml_schema_converter('%sR_%s.xsd' % (prefix, operation), 'PORTAILSERVICE')
-        self.name = re.sub(
-            '(.?)([A-Z])', lambda s: s.group(1) + ('-' if s.group(1) else '') + s.group(2).lower(), operation
-        )
-        self.snake_name = self.name.replace('-', '_')
-
-    @property
-    def request_schema(self):
-        schema = self.request_converter.json_schema
-        schema['flatten'] = True
-        schema['merge_extra'] = True
-        return schema
-
-    def __call__(self, resource, request_data=None):
-        client = resource.soap_client()
-
-        serialized_request = ''
-        if self.request_converter:
-            try:
-                serialized_request = self.request_converter.encode(request_data)
-            except xmlschema.XMLSchemaValidationError as e:
-                raise AxelError('invalid request %s' % str(e))
-            utils.indent(serialized_request)
-            serialized_request = force_text(ET.tostring(serialized_request))
-            try:
-                self.request_converter.xml_schema.validate(serialized_request)
-            except xmlschema.XMLSchemaValidationError as e:
-                raise AxelError('invalid request %s' % str(e), xml_request=serialized_request)
-
-        result = client.service.getData(
-            self.operation, serialized_request, ''
-        )  # FIXME: What is the user parameter for ?
-
-        xml_result = ET.fromstring(result.encode('utf-8'))
-        utils.indent(xml_result)
-        pretty_result = force_text(ET.tostring(xml_result))
-        if xml_result.find('RESULTAT/STATUS').text != 'OK':
-            msg = xml_result.find('RESULTAT/COMMENTAIRES').text
-            raise AxelError(msg, xml_request=serialized_request, xml_response=pretty_result)
-
-        try:
-            return OperationResult(
-                json_response=self.response_converter.decode(xml_result),
-                xml_request=serialized_request,
-                xml_response=pretty_result,
-            )
-        except xmlschema.XMLSchemaValidationError as e:
-            raise AxelError(
-                'invalid response %s' % str(e), xml_request=serialized_request, xml_response=pretty_result
-            )
+class Operation(axel.Operation):
+    base_xsd_path = BASE_XSD_PATH
+    default_prefix = 'Dui/'
 
 
 ref_date_gestion_dui = Operation('RefDateGestionDui')
@@ -183,7 +43,7 @@ reservation_annuelle = Operation('ReservationAnnuelle')
 PAYMENT_SCHEMA = {
     'type': 'object',
     'properties': {
-        'transaction_date': copy.deepcopy(utils.datetime_type),
+        'transaction_date': copy.deepcopy(axel.datetime_type),
         'transaction_id': {
             'type': 'string',
         },
@@ -229,7 +89,7 @@ UPDATE_FAMILY_SCHEMA = copy.deepcopy(
 )
 
 for flag in sorted(UPDATE_FAMILY_FLAGS.keys()):
-    flag_type = copy.deepcopy(utils.boolean_type)
+    flag_type = copy.deepcopy(axel.boolean_type)
     if flag not in UPDATE_FAMILY_REQUIRED_FLAGS:
         flag_type['oneOf'].append({'type': 'null'})
         flag_type['oneOf'].append({'type': 'string', 'enum': ['']})
@@ -283,7 +143,7 @@ sanitaire_properties['ALLERGIE'] = {
 }
 sanitaire_required.append('ALLERGIE')
 for key in ['ASTHME', 'MEDICAMENTEUSES', 'ALIMENTAIRES']:
-    flag_type = copy.deepcopy(utils.boolean_type)
+    flag_type = copy.deepcopy(axel.boolean_type)
     flag_type['oneOf'].append({'type': 'null'})
     flag_type['oneOf'].append({'type': 'string', 'enum': ['']})
     sanitaire_properties['ALLERGIE']['properties'][key] = flag_type
@@ -303,8 +163,8 @@ UPDATE_FAMILY_SCHEMA['unflatten'] = True
 BOOKING_SCHEMA = {
     'type': 'object',
     'properties': {
-        'booking_start_date': copy.deepcopy(utils.date_type),
-        'booking_end_date': copy.deepcopy(utils.date_type),
+        'booking_start_date': copy.deepcopy(axel.date_type),
+        'booking_end_date': copy.deepcopy(axel.date_type),
         'booking_list_MAT': {
             'oneOf': [
                 {'type': 'null'},
@@ -429,7 +289,7 @@ ANNUAL_BOOKING_SCHEMA = {
             'maxLength': 8,
         },
         'regime': {'oneOf': [{'type': 'null'}, {'type': 'string', 'enum': ['', 'SV', 'AV']}]},
-        'booking_date': copy.deepcopy(utils.date_type),
+        'booking_date': copy.deepcopy(axel.date_type),
     },
     'required': [
         'booking_list_MAT',
diff --git a/passerelle/contrib/toulouse_axel/utils.py b/passerelle/contrib/toulouse_axel/utils.py
index f8c8fa9d..8791b930 100644
--- a/passerelle/contrib/toulouse_axel/utils.py
+++ b/passerelle/contrib/toulouse_axel/utils.py
@@ -19,39 +19,12 @@ from __future__ import unicode_literals
 
 from collections import OrderedDict
 import datetime
-import unicodedata
-import xml.etree.ElementTree as ET
-
-import pytz
 
 from django.utils.six import string_types
 
 from passerelle.utils.conversion import normalize
 
 
-boolean_type = {
-    'oneOf': [
-        {'type': 'boolean'},
-        {
-            'type': 'string',
-            'pattern': '[Oo][Uu][Ii]|[Nn][Oo][Nn]|[Tt][Rr][Uu][Ee]|[Ff][Aa][Ll][Ss][Ee]|1|0',
-        },
-    ]
-}
-date_type = {
-    'type': 'string',
-    'pattern': '[0-9]{4}-[0-9]{2}-[0-9]{2}',
-}
-datetime_type = {
-    'type': 'string',
-    'pattern': '[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}',
-}
-json_date_format = '%Y-%m-%d'
-json_datetime_format = '%Y-%m-%dT%H:%M:%S'
-xml_date_format = '%d/%m/%Y'
-xml_datetime_format = '%d/%m/%Y %H:%M:%S'
-
-
 situation_familiale_mapping = OrderedDict(
     [
         ('C', 'Célibataire'),
@@ -113,63 +86,6 @@ def get_label(mapping, code):
     return mapping.get(code, code)
 
 
-def indent(tree, space="  ", level=0):
-    # backport from Lib/xml/etree/ElementTree.py python 3.9
-    if isinstance(tree, ET.ElementTree):
-        tree = tree.getroot()
-    if level < 0:
-        raise ValueError("Initial indentation level must be >= 0, got {level}".format(level))
-    if not len(tree):
-        return
-
-    # Reduce the memory consumption by reusing indentation strings.
-    indentations = ["\n" + level * space]
-
-    def _indent_children(elem, level):
-        # Start a new indentation level for the first child.
-        child_level = level + 1
-        try:
-            child_indentation = indentations[child_level]
-        except IndexError:
-            child_indentation = indentations[level] + space
-            indentations.append(child_indentation)
-
-        if not elem.text or not elem.text.strip():
-            elem.text = child_indentation
-
-        for child in elem:
-            if len(child):
-                _indent_children(child, child_level)
-            if not child.tail or not child.tail.strip():
-                child.tail = child_indentation
-
-        # Dedent after the last child by overwriting the previous indentation.
-        if not child.tail.strip():
-            child.tail = indentations[level]
-
-    _indent_children(tree, 0)
-
-
-def encode_bool(obj):
-    if obj is True or str(obj).lower() in ['true', 'oui', '1']:
-        return 'OUI'
-    if obj is False or str(obj).lower() in ['false', 'non', '0']:
-        return 'NON'
-    return obj
-
-
-def parse_datetime(value):
-    try:
-        dt = datetime.datetime.strptime(value, json_datetime_format)
-    except ValueError:
-        return None
-    return pytz.utc.localize(dt)
-
-
-def encode_datetime(dt):
-    return dt.astimezone(pytz.timezone('Europe/Paris')).strftime(xml_datetime_format)
-
-
 def upperize(data):
     if isinstance(data, dict):
         for key, val in data.items():
diff --git a/passerelle/contrib/utils/__init__.py b/passerelle/contrib/utils/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/passerelle/contrib/utils/axel.py b/passerelle/contrib/utils/axel.py
new file mode 100644
index 00000000..daa76ef4
--- /dev/null
+++ b/passerelle/contrib/utils/axel.py
@@ -0,0 +1,264 @@
+# 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/>.
+
+
+import copy
+import datetime
+import os
+import re
+import xml.etree.ElementTree as ET
+from collections import namedtuple
+
+import pytz
+
+from django.utils.encoding import force_text
+
+import xmlschema
+
+from passerelle.utils.xml import JSONSchemaFromXMLSchema
+
+
+boolean_type = {
+    'oneOf': [
+        {'type': 'boolean'},
+        {
+            'type': 'string',
+            'pattern': '[Oo][Uu][Ii]|[Nn][Oo][Nn]|[Tt][Rr][Uu][Ee]|[Ff][Aa][Ll][Ss][Ee]|1|0',
+        },
+    ]
+}
+date_type = {
+    'type': 'string',
+    'pattern': '[0-9]{4}-[0-9]{2}-[0-9]{2}',
+}
+datetime_type = {
+    'type': 'string',
+    'pattern': '[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}',
+}
+json_date_format = '%Y-%m-%d'
+json_datetime_format = '%Y-%m-%dT%H:%M:%S'
+xml_date_format = '%d/%m/%Y'
+xml_datetime_format = '%d/%m/%Y %H:%M:%S'
+
+
+def indent(tree, space="  ", level=0):
+    # backport from Lib/xml/etree/ElementTree.py python 3.9
+    if isinstance(tree, ET.ElementTree):
+        tree = tree.getroot()
+    if level < 0:
+        raise ValueError("Initial indentation level must be >= 0, got {level}".format(level=level))
+    if not len(tree):
+        return
+
+    # Reduce the memory consumption by reusing indentation strings.
+    indentations = ["\n" + level * space]
+
+    def _indent_children(elem, level):
+        # Start a new indentation level for the first child.
+        child_level = level + 1
+        try:
+            child_indentation = indentations[child_level]
+        except IndexError:
+            child_indentation = indentations[level] + space
+            indentations.append(child_indentation)
+
+        if not elem.text or not elem.text.strip():
+            elem.text = child_indentation
+
+        for child in elem:
+            if len(child):
+                _indent_children(child, child_level)
+            if not child.tail or not child.tail.strip():
+                child.tail = child_indentation
+
+        # Dedent after the last child by overwriting the previous indentation.
+        if not child.tail.strip():
+            child.tail = indentations[level]
+
+    _indent_children(tree, 0)
+
+
+def encode_bool(obj):
+    if obj is True or str(obj).lower() in ['true', 'oui', '1']:
+        return 'OUI'
+    if obj is False or str(obj).lower() in ['false', 'non', '0']:
+        return 'NON'
+    return obj
+
+
+def parse_datetime(value):
+    try:
+        dt = datetime.datetime.strptime(value, json_datetime_format)
+    except ValueError:
+        return None
+    return pytz.utc.localize(dt)
+
+
+def encode_datetime(dt):
+    return dt.astimezone(pytz.timezone('Europe/Paris')).strftime(xml_datetime_format)
+
+
+class AxelError(Exception):
+    def __init__(self, message, xml_request=None, xml_response=None, *args):
+        self.message = message
+        self.xml_request = xml_request
+        self.xml_response = xml_response
+        super(AxelError, self).__init__(message, *args)
+
+    def __str__(self):
+        return self.message
+
+
+class AxelSchema(JSONSchemaFromXMLSchema):
+    type_map = {
+        '{urn:AllAxelTypes}DATEREQUIREDType': 'date',
+        '{urn:AllAxelTypes}DATEType': 'date_optional',
+        '{urn:AllAxelTypes}OUINONREQUIREDType': 'bool',
+        '{urn:AllAxelTypes}OUINONType': 'bool_optional',
+    }
+
+    @classmethod
+    def schema_date(cls):
+        return {
+            'type': 'string',
+            'pattern': '[0-9]{4}-[0-9]{2}-[0-9]{2}',
+        }
+
+    def encode_date(self, obj):
+        try:
+            return datetime.datetime.strptime(obj, json_date_format).strftime(xml_date_format)
+        except ValueError:
+            return obj
+
+    def encode_date_optional(self, obj):
+        if not obj:
+            return obj
+        return self.encode_date(obj)
+
+    def decode_date(self, data):
+        value = datetime.datetime.strptime(data.text, xml_date_format).strftime(json_date_format)
+        return xmlschema.ElementData(
+            tag=data.tag, text=value, content=data.content, attributes=data.attributes
+        )
+
+    def decode_date_optional(self, data):
+        if not data.text:
+            return data
+        return self.decode_date(data)
+
+    @classmethod
+    def schema_bool(cls):
+        return copy.deepcopy(boolean_type)
+
+    def encode_bool(self, obj):
+        return encode_bool(obj)
+
+    def decode_bool(self, data):
+        value = False
+        if data.text.lower() == 'oui':
+            value = True
+        return xmlschema.ElementData(
+            tag=data.tag, text=value, content=data.content, attributes=data.attributes
+        )
+
+    @classmethod
+    def schema_bool_optional(cls):
+        schema_bool_optional = cls.schema_bool()
+        schema_bool_optional['oneOf'].append({'type': 'string', 'enum': ['']})
+        return schema_bool_optional
+
+    def encode_bool_optional(self, obj):
+        return self.encode_bool(obj)
+
+    def decode_bool_optional(self, data):
+        if not data.text:
+            return data
+        return self.decode_bool(data)
+
+
+def xml_schema_converter(base_xsd_path, name, root_element):
+    xsd_path = os.path.join(base_xsd_path, name)
+    if not os.path.exists(xsd_path):
+        return None
+    return AxelSchema(xsd_path, root_element)
+
+
+OperationResult = namedtuple('OperationResult', ['json_response', 'xml_request', 'xml_response'])
+
+
+class Operation(object):
+    base_xsd_path = None
+    default_prefix = ''
+
+    def __init__(self, operation, prefix=None, request_root_element='PORTAIL'):
+        if prefix is None:
+            prefix = self.default_prefix
+        self.operation = operation
+        self.request_converter = xml_schema_converter(
+            self.base_xsd_path, '%sQ_%s.xsd' % (prefix, operation), request_root_element
+        )
+        self.response_converter = xml_schema_converter(
+            self.base_xsd_path, '%sR_%s.xsd' % (prefix, operation), 'PORTAILSERVICE'
+        )
+        self.name = re.sub(
+            '(.?)([A-Z])', lambda s: s.group(1) + ('-' if s.group(1) else '') + s.group(2).lower(), operation
+        )
+        self.snake_name = self.name.replace('-', '_')
+
+    @property
+    def request_schema(self):
+        schema = self.request_converter.json_schema
+        schema['flatten'] = True
+        schema['merge_extra'] = True
+        return schema
+
+    def __call__(self, resource, request_data=None):
+        client = resource.soap_client()
+
+        serialized_request = ''
+        if self.request_converter:
+            try:
+                serialized_request = self.request_converter.encode(request_data)
+            except xmlschema.XMLSchemaValidationError as e:
+                raise AxelError('invalid request %s' % str(e))
+            indent(serialized_request)
+            serialized_request = force_text(ET.tostring(serialized_request))
+            try:
+                self.request_converter.xml_schema.validate(serialized_request)
+            except xmlschema.XMLSchemaValidationError as e:
+                raise AxelError('invalid request %s' % str(e), xml_request=serialized_request)
+
+        result = client.service.getData(
+            self.operation, serialized_request, ''
+        )  # FIXME: What is the user parameter for ?
+
+        xml_result = ET.fromstring(result.encode('utf-8'))
+        indent(xml_result)
+        pretty_result = force_text(ET.tostring(xml_result))
+        if xml_result.find('RESULTAT/STATUS').text != 'OK':
+            msg = xml_result.find('RESULTAT/COMMENTAIRES').text
+            raise AxelError(msg, xml_request=serialized_request, xml_response=pretty_result)
+
+        try:
+            return OperationResult(
+                json_response=self.response_converter.decode(xml_result),
+                xml_request=serialized_request,
+                xml_response=pretty_result,
+            )
+        except xmlschema.XMLSchemaValidationError as e:
+            raise AxelError(
+                'invalid response %s' % str(e), xml_request=serialized_request, xml_response=pretty_result
+            )
diff --git a/passerelle/contrib/toulouse_axel/exceptions.py b/tests/test_axel_utils.py
similarity index 57%
rename from passerelle/contrib/toulouse_axel/exceptions.py
rename to tests/test_axel_utils.py
index b1d155ca..b8ee8369 100644
--- a/passerelle/contrib/toulouse_axel/exceptions.py
+++ b/tests/test_axel_utils.py
@@ -1,5 +1,5 @@
 # passerelle - uniform access to multiple data sources and services
-# Copyright (C) 2020  Entr'ouvert
+# 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
@@ -14,13 +14,20 @@
 # 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 passerelle.contrib.utils.axel import (
+    parse_datetime,
+    encode_datetime,
+)
 
-class AxelError(Exception):
-    def __init__(self, message, xml_request=None, xml_response=None, *args):
-        self.message = message
-        self.xml_request = xml_request
-        self.xml_response = xml_response
-        super(AxelError, self).__init__(message, *args)
 
-    def __str__(self):
-        return self.message
+def test_parse_datetime():
+    # wrong format
+    assert parse_datetime('foo') is None
+    assert parse_datetime('2019-12-12') is None
+    assert parse_datetime('2019-12-12T12:01:72') is None
+    # ok
+    assert parse_datetime('2019-12-12T12:01:42').isoformat() == '2019-12-12T12:01:42+00:00'
+
+
+def test_encode_datetime():
+    assert encode_datetime(parse_datetime('2019-12-12T23:40:42')) == '13/12/2019 00:40:42'
diff --git a/tests/test_toulouse_axel.py b/tests/test_toulouse_axel.py
index 317d3d37..871a7b41 100644
--- a/tests/test_toulouse_axel.py
+++ b/tests/test_toulouse_axel.py
@@ -33,7 +33,6 @@ import pytest
 import xmlschema
 import jsonschema
 
-from passerelle.contrib.toulouse_axel.exceptions import AxelError
 from passerelle.contrib.toulouse_axel.models import (
     Link,
     Lock,
@@ -48,6 +47,8 @@ from passerelle.contrib.toulouse_axel.utils import (
     regime_mapping,
     upperize,
 )
+from passerelle.contrib.utils.axel import AxelError
+from passerelle.contrib.utils.axel import OperationResult
 from passerelle.utils.jsonresponse import APIError
 from passerelle.utils.soap import SOAPError
 import utils
@@ -675,7 +676,7 @@ def test_link_endpoint_axel_error(app, resource, link_params):
         % content
     )
     with mock_getdata(content, 'RefVerifDui'):
-        with mock.patch('passerelle.contrib.toulouse_axel.schemas.AxelSchema.decode') as decode:
+        with mock.patch('passerelle.contrib.utils.axel.AxelSchema.decode') as decode:
             decode.side_effect = xmlschema.XMLSchemaValidationError(None, None)
             resp = app.post_json('/toulouse-axel/test/link?NameID=yyy', params=link_params)
     assert resp.json['err_desc'].startswith("Axel error: invalid response")
@@ -1278,7 +1279,7 @@ def test_update_family_info_endpoint(app, resource, update_params, family_data):
     assert 'xml_response' in resp.json['data']
 
     with mock.patch('passerelle.contrib.toulouse_axel.schemas.form_maj_famille_dui') as operation:
-        operation.return_value = schemas.OperationResult(json_response={}, xml_request='', xml_response='')
+        operation.return_value = OperationResult(json_response={}, xml_request='', xml_response='')
         with mock.patch(
             'passerelle.contrib.toulouse_axel.models.ToulouseAxel.get_family_data', return_value=family_data
         ):
@@ -1292,7 +1293,7 @@ def test_update_family_info_endpoint(app, resource, update_params, family_data):
     link.person_id = '35'
     link.save()
     with mock.patch('passerelle.contrib.toulouse_axel.schemas.form_maj_famille_dui') as operation:
-        operation.return_value = schemas.OperationResult(json_response={}, xml_request='', xml_response='')
+        operation.return_value = OperationResult(json_response={}, xml_request='', xml_response='')
         with mock.patch(
             'passerelle.contrib.toulouse_axel.models.ToulouseAxel.get_family_data', return_value=family_data
         ):
@@ -2735,9 +2736,7 @@ def test_clae_children_activities_info_check(app, resource, activities, expected
         }
     }
     with mock.patch('passerelle.contrib.toulouse_axel.schemas.enfants_activites') as operation:
-        operation.return_value = schemas.OperationResult(
-            json_response=result, xml_request='', xml_response=''
-        )
+        operation.return_value = OperationResult(json_response=result, xml_request='', xml_response='')
         resp = app.get('/toulouse-axel/test/clae_children_activities_info?NameID=yyy&booking_date=2020-01-20')
     assert resp.json['err'] == 0
     assert len(resp.json['data']) == expected and 1 or 0
@@ -4000,9 +3999,7 @@ def test_clae_booking_endpoint(app, resource, booking_params, child_activities_d
         assert 'xml_response' in resp.json['data']
 
         with mock.patch('passerelle.contrib.toulouse_axel.schemas.reservation_annuelle') as operation:
-            operation.return_value = schemas.OperationResult(
-                json_response={}, xml_request='', xml_response=''
-            )
+            operation.return_value = OperationResult(json_response={}, xml_request='', xml_response='')
             resp = app.post_json('/toulouse-axel/test/clae_booking?NameID=yyy', params=booking_params)
         payload = operation.call_args_list[0][0][1]['PORTAIL']['DUI']
         assert payload == {
@@ -4069,9 +4066,7 @@ def test_clae_booking_endpoint(app, resource, booking_params, child_activities_d
         new_booking_params['booking_list_SOIR'] = []
         new_booking_params['booking_list_GARD'] = []
         with mock.patch('passerelle.contrib.toulouse_axel.schemas.reservation_annuelle') as operation:
-            operation.return_value = schemas.OperationResult(
-                json_response={}, xml_request='', xml_response=''
-            )
+            operation.return_value = OperationResult(json_response={}, xml_request='', xml_response='')
             resp = app.post_json('/toulouse-axel/test/clae_booking?NameID=yyy', params=new_booking_params)
         payload = operation.call_args_list[0][0][1]['PORTAIL']['DUI']
         assert len(payload['ENFANT']) == 1
@@ -4109,9 +4104,7 @@ def test_clae_booking_endpoint(app, resource, booking_params, child_activities_d
         new_booking_params['booking_list_SOIR'] = None
         new_booking_params['booking_list_GARD'] = None
         with mock.patch('passerelle.contrib.toulouse_axel.schemas.reservation_annuelle') as operation:
-            operation.return_value = schemas.OperationResult(
-                json_response={}, xml_request='', xml_response=''
-            )
+            operation.return_value = OperationResult(json_response={}, xml_request='', xml_response='')
             resp = app.post_json('/toulouse-axel/test/clae_booking?NameID=yyy', params=new_booking_params)
         payload = operation.call_args_list[0][0][1]['PORTAIL']['DUI']
         assert 'ACTIVITE' not in payload['ENFANT'][0]
@@ -4134,9 +4127,7 @@ def test_clae_booking_endpoint(app, resource, booking_params, child_activities_d
         new_booking_params['booking_list_SOIR'] = None
         new_booking_params['booking_list_GARD'] = None
         with mock.patch('passerelle.contrib.toulouse_axel.schemas.reservation_annuelle') as operation:
-            operation.return_value = schemas.OperationResult(
-                json_response={}, xml_request='', xml_response=''
-            )
+            operation.return_value = OperationResult(json_response={}, xml_request='', xml_response='')
             with mock.patch('django.core.cache.cache.delete') as mock_cache_delete:
                 resp = app.post_json('/toulouse-axel/test/clae_booking?NameID=yyy', params=new_booking_params)
         assert mock_cache_delete.call_args_list == [
@@ -4180,25 +4171,19 @@ def test_clae_booking_regime(app, resource, booking_params, child_activities_dat
     ):
         booking_params['regime'] = None
         with mock.patch('passerelle.contrib.toulouse_axel.schemas.reservation_annuelle') as operation:
-            operation.return_value = schemas.OperationResult(
-                json_response={}, xml_request='', xml_response=''
-            )
+            operation.return_value = OperationResult(json_response={}, xml_request='', xml_response='')
             app.post_json('/toulouse-axel/test/clae_booking?NameID=yyy', params=booking_params)
         payload = operation.call_args_list[0][0][1]['PORTAIL']['DUI']
         assert payload['ENFANT'][0]['REGIME'] == 'SV'
         booking_params['regime'] = ''
         with mock.patch('passerelle.contrib.toulouse_axel.schemas.reservation_annuelle') as operation:
-            operation.return_value = schemas.OperationResult(
-                json_response={}, xml_request='', xml_response=''
-            )
+            operation.return_value = OperationResult(json_response={}, xml_request='', xml_response='')
             app.post_json('/toulouse-axel/test/clae_booking?NameID=yyy', params=booking_params)
         payload = operation.call_args_list[0][0][1]['PORTAIL']['DUI']
         assert payload['ENFANT'][0]['REGIME'] == 'SV'
         del booking_params['regime']
         with mock.patch('passerelle.contrib.toulouse_axel.schemas.reservation_annuelle') as operation:
-            operation.return_value = schemas.OperationResult(
-                json_response={}, xml_request='', xml_response=''
-            )
+            operation.return_value = OperationResult(json_response={}, xml_request='', xml_response='')
             app.post_json('/toulouse-axel/test/clae_booking?NameID=yyy', params=booking_params)
         payload = operation.call_args_list[0][0][1]['PORTAIL']['DUI']
         assert payload['ENFANT'][0]['REGIME'] == 'SV'
@@ -4228,9 +4213,7 @@ def test_clae_booking_endpoint_next_year(app, resource, child_activities_data):
     ) as mock_activities:
         mock_activities.return_value = activities
         with mock.patch('passerelle.contrib.toulouse_axel.schemas.reservation_annuelle') as operation:
-            operation.return_value = schemas.OperationResult(
-                json_response={}, xml_request='', xml_response=''
-            )
+            operation.return_value = OperationResult(json_response={}, xml_request='', xml_response='')
             app.post_json('/toulouse-axel/test/clae_booking?NameID=yyy', params=booking_params)
     assert mock_activities.call_args_list == [
         mock.call(child_id='3535', dui='XXX', reference_year=2020),
@@ -4305,9 +4288,7 @@ def test_clae_booking_annual_endpoint(app, resource, annual_booking_params, chil
         assert 'xml_response' in resp.json['data']
 
         with mock.patch('passerelle.contrib.toulouse_axel.schemas.reservation_annuelle') as operation:
-            operation.return_value = schemas.OperationResult(
-                json_response={}, xml_request='', xml_response=''
-            )
+            operation.return_value = OperationResult(json_response={}, xml_request='', xml_response='')
             resp = app.post_json(
                 '/toulouse-axel/test/clae_booking_annual?NameID=yyy', params=annual_booking_params
             )
@@ -4360,9 +4341,7 @@ def test_clae_booking_annual_endpoint(app, resource, annual_booking_params, chil
         new_booking_params['booking_list_SOIR'] = []
         new_booking_params['booking_list_GARD'] = []
         with mock.patch('passerelle.contrib.toulouse_axel.schemas.reservation_annuelle') as operation:
-            operation.return_value = schemas.OperationResult(
-                json_response={}, xml_request='', xml_response=''
-            )
+            operation.return_value = OperationResult(json_response={}, xml_request='', xml_response='')
             resp = app.post_json(
                 '/toulouse-axel/test/clae_booking_annual?NameID=yyy', params=new_booking_params
             )
@@ -4402,9 +4381,7 @@ def test_clae_booking_annual_endpoint(app, resource, annual_booking_params, chil
         new_booking_params['booking_list_SOIR'] = None
         new_booking_params['booking_list_GARD'] = None
         with mock.patch('passerelle.contrib.toulouse_axel.schemas.reservation_annuelle') as operation:
-            operation.return_value = schemas.OperationResult(
-                json_response={}, xml_request='', xml_response=''
-            )
+            operation.return_value = OperationResult(json_response={}, xml_request='', xml_response='')
             resp = app.post_json(
                 '/toulouse-axel/test/clae_booking_annual?NameID=yyy', params=new_booking_params
             )
@@ -4434,9 +4411,7 @@ def test_clae_booking_annual_next_year(app, resource, child_activities_data):
         'passerelle.contrib.toulouse_axel.models.ToulouseAxel.get_child_activities', return_value=activities
     ):
         with mock.patch('passerelle.contrib.toulouse_axel.schemas.reservation_annuelle') as operation:
-            operation.return_value = schemas.OperationResult(
-                json_response={}, xml_request='', xml_response=''
-            )
+            operation.return_value = OperationResult(json_response={}, xml_request='', xml_response='')
             app.post_json('/toulouse-axel/test/clae_booking_annual?NameID=yyy', params=annual_booking_params)
     payload = operation.call_args_list[0][0][1]['PORTAIL']['DUI']
     assert payload['ENFANT'][0]['ACTIVITE'][0]['PERIODE'][0]['DATEDEBUT'] == '2020-08-01'
@@ -4452,25 +4427,19 @@ def test_clae_booking_annual_regime(app, resource, annual_booking_params, child_
     ):
         annual_booking_params['regime'] = None
         with mock.patch('passerelle.contrib.toulouse_axel.schemas.reservation_annuelle') as operation:
-            operation.return_value = schemas.OperationResult(
-                json_response={}, xml_request='', xml_response=''
-            )
+            operation.return_value = OperationResult(json_response={}, xml_request='', xml_response='')
             app.post_json('/toulouse-axel/test/clae_booking_annual?NameID=yyy', params=annual_booking_params)
         payload = operation.call_args_list[0][0][1]['PORTAIL']['DUI']
         assert payload['ENFANT'][0]['REGIME'] == 'SV'
         annual_booking_params['regime'] = ''
         with mock.patch('passerelle.contrib.toulouse_axel.schemas.reservation_annuelle') as operation:
-            operation.return_value = schemas.OperationResult(
-                json_response={}, xml_request='', xml_response=''
-            )
+            operation.return_value = OperationResult(json_response={}, xml_request='', xml_response='')
             app.post_json('/toulouse-axel/test/clae_booking_annual?NameID=yyy', params=annual_booking_params)
         payload = operation.call_args_list[0][0][1]['PORTAIL']['DUI']
         assert payload['ENFANT'][0]['REGIME'] == 'SV'
         del annual_booking_params['regime']
         with mock.patch('passerelle.contrib.toulouse_axel.schemas.reservation_annuelle') as operation:
-            operation.return_value = schemas.OperationResult(
-                json_response={}, xml_request='', xml_response=''
-            )
+            operation.return_value = OperationResult(json_response={}, xml_request='', xml_response='')
             app.post_json('/toulouse-axel/test/clae_booking_annual?NameID=yyy', params=annual_booking_params)
         payload = operation.call_args_list[0][0][1]['PORTAIL']['DUI']
         assert payload['ENFANT'][0]['REGIME'] == 'SV'
diff --git a/tests/test_toulouse_axel_utils.py b/tests/test_toulouse_axel_utils.py
index ffe87d66..6d34fe11 100644
--- a/tests/test_toulouse_axel_utils.py
+++ b/tests/test_toulouse_axel_utils.py
@@ -18,26 +18,11 @@ import datetime
 import pytest
 
 from passerelle.contrib.toulouse_axel.utils import (
-    parse_datetime,
-    encode_datetime,
     get_booking,
     get_reference_year_from_date,
     get_week_dates_from_date,
-    json_date_format,
 )
-
-
-def test_parse_datetime():
-    # wrong format
-    assert parse_datetime('foo') is None
-    assert parse_datetime('2019-12-12') is None
-    assert parse_datetime('2019-12-12T12:01:72') is None
-    # ok
-    assert parse_datetime('2019-12-12T12:01:42').isoformat() == '2019-12-12T12:01:42+00:00'
-
-
-def test_encode_datetime():
-    assert encode_datetime(parse_datetime('2019-12-12T23:40:42')) == '13/12/2019 00:40:42'
+from passerelle.contrib.utils.axel import json_date_format
 
 
 @pytest.mark.parametrize(
-- 
2.30.2

