Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Importer itinéraires dans Geotrek via Tourinsoft #2534

Open
AudreyRemy opened this issue Feb 5, 2021 · 7 comments
Open

Importer itinéraires dans Geotrek via Tourinsoft #2534

AudreyRemy opened this issue Feb 5, 2021 · 7 comments

Comments

@AudreyRemy
Copy link

Bonjour,

Actuellement de nombreux PLR des Pyrénées-Atlantiques sont saisis dans Tourinsoft par les OT ou les Communautés de Communes (+ de 900 itinéraires).
Nous souhaiterions utiliser une passerelle permettant de récupérer ces itinéraires (tracé, pas à pas, photo, ...) et les importer dans Geotrek.
Avez-vous déjà une procédure permettant cette passerelle ?
Si non, seriez-vous intéressé par cette passerelle ?
Merci d'avance pour vos retours.
Bonne fin de journée.

@camillemonchicourt
Copy link
Member

Oui pouvoir importer des itinéraires directement depuis une source externe, est un sujet souvent évoqué, ça intéresse beaucoup de monde.

Ca n'a jamais été réellement creusé à ma connaissance, car c'est assez complexe du fait de la segmentation dynamique au cœur de Geotrek-admin, où les itinéraires n'existent pas en tant que tel, mais uniquement par les tronçons qu"ils utilisent.

Néanmoins des pistes de reflexion sur le sujet ont déjà été initiés, voir par exemple #2139
Donc le sujet serait intéressant à investiguer si il est important pour vous.

@AudreyRemy
Copy link
Author

Bonjour,
Nous avons fait un point avec Makina Corpus pour travailler sur ces passerelles.
La saisie des tronçons se fera manuellement.
Il serait envisageable de créer une passerelle qui importera toutes les données des itinéraires de Tourinsoft (plateforme actuelle utilisée par les collectivités dans le département) dans Geotrek.
Dans un second temps, Geotrek deviendra la plateforme de saisie des itinéraires et une autre passerelle permettra d'envoyer les données de Geotrek vers Tourinsoft qui lui même alimente différents sites internet via des flux.
Nous en sommes à la création du cahier des charges pour bien cibler les besoins.
Bon après-midi

@AudreyRemy
Copy link
Author

AudreyRemy commented Feb 18, 2022

Bonjour,

Nous avons créé avec Makina Corpus un fichier parsers permettant d'importer des données de Tourinsoft vers Geotrek (restaurants, hébergements, loisirs). Les éléments sont à affiner en fonction des données à récupérer et de la catégorisation qu'on veut leur donner dans Geotrek mais ce fichier peut servir de base. Les champs ne sont pas les mêmes pour tous dans Tourinsoft, le fichier est donc à adapter avec les données de chacun.
Les champs dans Geotrek sont mentionnés dans le fichier tourism.pdf : https://github.com/GeotrekCE/Geotrek-admin/tree/master/docs/data-model

Attention l'importation va mettre à jour les données, les créer si nécessaire mais également supprimer les éléments qui ne seraient pas dans la base de Tourinsoft (données qui auraient été saisies à la main dans Geotrek par exemple).

La procédure à suivre est la suivante :

  • Glisser le fichier parsers.py (le fichier doit s'appeler comme ça absolument) dans le dossier /tmp - Déplacer le fichier dans le dossier conf : sudo mv /home/votreLogin/parsers.py /opt/geotrek-admin/var/conf
  • Importer les données : sudo geotrek import –v 2 <nom_du_parser>
  • Exemple pour l’import des restaurants dont le nom de la classe est TourinsoftParser64Restaurants : sudo geotrek import –v 2 TourinsoftParser64Restaurants

Voilà le code du fichier parsers que nous avons utilisé :

# coding=utf-8
from __future__ import unicode_literals

from django.conf import settings
from django.contrib.gis.geos import Point

from geotrek.common.models import RecordSource
from geotrek.common.parsers import ValueImportError
from geotrek.tourism.models import TouristicContent, TouristicContentCategory, TouristicContentType1, TouristicContentType2
from geotrek.tourism.parsers import TouristicContentTourInSoftParserV3


class TourinsoftParser64(TouristicContentTourInSoftParserV3):
    """
    On hérite d'une classe Geotrek qui fourni un ensemble de méthodes pour parser les flux de données Tourinsoft.

    Le classe TourinsoftParser64 servira ensuite pour chaque classe qu'on créera pour fournir des méthodes communes (par exemple les méthodes qui formatte les données d'adresse, de contact, etc.).
    """
    # override next_row method from TourInSoftParser to match particular behavior
    def next_row(self):
        skip = 0
        while True:
            params = {
                'format': 'json',
                'inlinecount': 'allpages',
                'top': 1000,
                'skip': skip,
            }
            response = self.request_or_retry(self.url, params=params)
            self.root = response.json()
            self.nb = self.get_nb()
            for row in self.items:
                yield {self.normalize_field_name(src): val for src, val in row.items()}
            skip += 1000
            if skip >= self.nb:
                return

    # override get_nb method from TourInSoftParser to match particular behavior with new Tourinsoft API
    def get_nb(self):
        return len(list(self.root['value']))

    non_fields = {
        'attachments': 'PHOTOSs',
    }

    def filter_name(self, src, val):
        if not val:
            raise ValueImportError("No Name")
        return val

    def filter_geom(self, src, val):
        lng, lat = val
        if lng and lat:
            geom = Point(float(lng), float(lat), srid=4326)
            geom.transform(settings.SRID)
        else:
            raise ValueImportError('Touristic content needs a geometry')
        return geom

    def filter_contact(self, src, val):
        communication, adresses = val
        infos = ""
        if communication:
            for comm in communication:
                if comm['TypedaccesTelecom'] and comm['TypedaccesTelecom']['ThesCode'] == 'C1':
                    infos += u"<strong>Téléphone :</strong><br>"
                    infos += u"%s<br>" % comm['CoordonneesTelecom']
                if comm['TypedaccesTelecom'] and comm['TypedaccesTelecom']['ThesCode'] == 'C6':
                    infos += u"<strong>Téléphone mobile :</strong><br>"
                    infos += u"%s<br>" % comm['CoordonneesTelecom']
                if comm['TypedaccesTelecom'] and comm['TypedaccesTelecom']['ThesCode'] == 'FB':
                    infos += u"<strong>Page facebook :</strong><br>"
                    infos += u"%s<br>" % comm['CoordonneesTelecom']
        if adresses:
            for ad in adresses:
                infos += u"<br><strong>Adresse :</strong><br>"
                if ad['Adresse1']:
                    infos += u"%s<br>" % ad['Adresse1']
                if ad['Adresse1suite']:
                    infos += u"%s<br>" % ad['Adresse1suite']
                if ad['Adresse2']:
                    infos += u"%s<br>" % ad['Adresse2']
                if ad['Adresse3']:
                    infos += u"%s<br>" % ad['Adresse3']
                if ad['CodePostal']:
                    infos += u"%s<br>" % ad['CodePostal']
                if ad['Commune']:
                    infos += u"%s" % ad['Commune']
        if infos[-4:] == u'<br>':
            infos = infos[-4:]
        return infos.replace('\n', '<br>')

    def filter_email(self, src, values):
        # values = toutes les informations présentent dans le champ tourinsoft "MOYENSCOMs"
        if values:
            for value in values:
                # pour chaque entrée du tableau, on vérifie si le type d'information présent est bien C4 (C4 = email)
                if value['TypedaccesTelecom'] and value['TypedaccesTelecom']['ThesCode'] == 'C4':
                    # si c'est le cas alors c'est la valeur qu'on retournera pour remplir le champ Geotrk "email"
                    val = value['CoordonneesTelecom']
                    val = val.replace(' ', '')
                    return val
        return ''

    def filter_website(self, src, values):
        if values:
            for value in values:
                if value['TypedaccesTelecom'] and value['TypedaccesTelecom']['ThesCode'] == 'C5':
                    val = value['CoordonneesTelecom']
                    val = val.replace(' ', '')
                    if not val.startswith('http'):
                        return 'http://{}'.format(val)
                    return val
        return ''

    def filter_attachments(self, src, val):
        result = []
        if val:
            for photo in val:
                if photo and photo['Photo'] and photo['Photo']['Url']:
                    url = photo['Photo']['Url']
                    legend = photo['Photo']['Titre']
                    credits = photo['Photo']['Credit']
                    result.append((url, legend, credits))
        return result

    def filter_source(self, src, val):
        sirtaqui = RecordSource.objects.get(name="SIRTAQUI 64")
        result = [sirtaqui]
        return result

    def filter_description(self, src, val):
        descriptions = val
        infos = ''
        if descriptions:
            for desc in descriptions:
                if desc['Descriptioncommerciale']:
                    infos += '%s <br><br>' % desc['Descriptioncommerciale']
        if infos[-4:] == '<br>':
            infos = infos[:-4]
        if not infos or infos == u'<br>':
            return None
        return infos.replace('\n', '<br>')


class TourinsoftParser64Restaurants(TourinsoftParser64):
    """
    A partir de maintenant une classe = un flux tourinsoft

    Dans celle-ci on s'occupe du flux tourinsoft pour les Restaurants
    """
    label = "Restaurants"
    category = "Restaurants"
    model = TouristicContent  # On indique que l'objet Geotrk qu'on souhaite créer et un object de type "Contenu touristique"
    url = "https://api-v3.tourinsoft.com/api/syndications/PRECISER VOTRE URL"
    field_options = {  # Pour pouvoir créer un Contenu touristique, on indique ici qu'on souhaite à minima que notre objet possède une géométrie et une sous-catégorie (en plus du nom obligatoire par contrainte SQL donc non spécifié ici)
        'geom': {'required': True},
        'type1': {'required': True},
    }
    fields = {
        'eid': 'SyndicObjectID',
        'name': 'SyndicObjectName',
        'geom': ('GmapLongitude', 'GmapLatitude'),  # Ici on associe deux champs Tourinsoft à un champ Geotrek. Geotrek va donc chercher une méthode "filter_geom" pour savoir que faire avec les deux valeurs provnenant de tourinsoft. La méthode filter_geom est définie L57
        'practical_info': (
            'OUVERTURETEXTEs',
            'TARIFSTEXTEs',
            'CAPACITEs',
        ),
        'contact': (
            'MOYENSCOMs',
            'ADRESSEs',
        ),
        'description': 'DESCRIPTIFSs',
        'email': 'MOYENSCOMs',  # Ici on pourrait ne pas faire de méthode dédiée, mais comme il s'agit d'un champ Tourinsoft qui est un tableau on doit créer une méthode pour pouvoir parcourir le tableau et trouver l'information qui nous intéresse. Cf L96
        'website': 'MOYENSCOMs',
    }

    m2m_fields = {
        'type1': 'TYPE2s',  # Ici on associe le champ Tourinsoft "TYPE2s" au champ Many2Many Geotrek "type1". Ce champ correspond à une liste de plusieurs objets de type "Sous-Catégorie de Contenu touristique de niveau 1". Voir L176
        'source': 'TYPE2s',
    }

    def start(self):
        """
        Méthode d'initialisation de note parser. On va s'occuper ici de créer une catégorie de contenu touristique nommée "Restaurant". Si la cétégorie existe déjà on la récupère afin de pouvoir l'associer aux objets que notr Parser va créer.
        """
        super().start()
        category, created = TouristicContentCategory.objects.get_or_create(label=self.label)
        self.category = category

    def filter_type1(self, src, val):
        """
        Dans cette méthode on s'occupe de parcourir les valeurs contenues dans TYPE2s.
        Pour chaque valeur on va soit créer s'il n'existe pas encore, soit récupérer un objet de type TouristicContentType1 correspondant à la valeur rencontrée dans le flux Tourinsoft.
        Ensuite on va renvoyer un tableau contenu l'ensemble des objet rencontrés. Ainsi, le contenu touristique créé dans Geotrek sera associé à plusieurs sous-catégories issues de Tourinsoft.
        """
        instances = []
        if not val:
            return instances
        for entry in val:
            if entry['Categorie']:
                for cat in entry['Categorie']:
                    instance_type, created = TouristicContentType1.objects.get_or_create(
                        category_id=self.category.id,
                        label=cat['ThesLibelle']
                    )
                    instances.append(instance_type)
        return instances

    def filter_practical_info(self, src, val):
        ouverture_textes, tarifs, capacites = val
        infos = ''
        if ouverture_textes:
            infos += '<strong>Informations sur l\'ouverture de l\'établissement : </strong><br>'
            infos += '%s<br>' % ouverture_textes[0]['Informationsouverture']
        if tarifs:
            infos += '<strong>Informations sur les tarifs : </strong><br>'
            infos += '%s<br>' % tarifs[0]['Descriptiftarifs']
        if tarifs:
            for tarif in tarifs:
                if tarif['Descriptiftarifs']:
                    infos += '<br><strong>Informations sur les tarifs2 : </strong> %s<br>' % tarif['Descriptiftarifs']
        if capacites:
            for capacite in capacites:
                if capacite['Nombremaximumdecouverts']:
                    infos += '<strong>Nombre de couverts  : </strong> %s<br>' % capacite['Nombremaximumdecouverts']
        if infos[-4:] == '<br>':
            infos = infos[:-4]
        if not infos or infos == u'<br>':
            return None
        return infos.replace('\n', '<br>')


class TourinsoftParser64Hebergements(TourinsoftParser64):
    """
    On s'occupe du flux tourinsoft pour les Hebergements
    """
    label = "Hébergement"
    category = "Hébergement"
    model = TouristicContent  # On indique que l'objet Geotrk qu'on souhaite créer et un object de type "Contenu touristique"
    url = "https://api-v3.tourinsoft.com/api/syndications/PRECISER VOTRE URL"
    field_options = {  # Pour pouvoir créer un Contenu touristique, on indique ici qu'on souhaite à minima que notre objet possède une géométrie et une sous-catégorie (en plus du nom obligatoire par contrainte SQL donc non spécifié ici)
        'geom': {'required': True},
        'type1': {'required': True},
    }
    fields = {
        'eid': 'SyndicObjectID',
        'name': 'SyndicObjectName',
        'geom': ('GmapLongitude', 'GmapLatitude'),  # Ici on associe deux champs Tourinsoft à un champ Geotrek. Geotrek va donc chercher une méthode "filter_geom" pour savoir que faire avec les deux valeurs provnenant de tourinsoft. La méthode filter_geom est définie L57
        'practical_info': (
            'CLASs',
            'NBREPERS',
            'NBRECHAMBRES',
            'NBREEMPLACEMENTS',
        ),
        'contact': (
            'MOYENSCOMs',
            'ADRESSEs',
        ),
        'description': 'DESCRIPTIFSs',
        'email': 'MOYENSCOMs',  # Ici on pourrait ne pas faire de méthode dédiée, mais comme il s'agit d'un champ Tourinsoft qui est un tableau on doit créer une méthode pour pouvoir parcourir le tableau et trouver l'information qui nous intéresse. Cf L96
        'website': 'MOYENSCOMs',
    }

    m2m_fields = {
        'type1': (
            'ObjectTypeName',
            'TYPEHEBs'
        ),
        'type2': 'LABELSs',
        'source': 'LABELs',
    }

    def start(self):
        """
        Méthode d'initialisation de note parser. On va s'occuper ici de créer une catégorie de contenu touristique nommée "Hebergements". Si la cétégorie existe déjà on la récupère afin de pouvoir l'associer aux objets que notr Parser va créer.
        """
        super().start()
        category, created = TouristicContentCategory.objects.get_or_create(label=self.category)
        self.category = category

    def filter_practical_info(self, src, val):
        classement, capacite_pers, capacite_chambres, capacite_emplacements = val
        infos = ''
        if classement:
            infos += '<strong>Classements : </strong>'
            for x in classement:
                infos += '%s / ' % x['Classement']['ThesLibelle']
            infos = infos[:-3]
        if capacite_pers:
            infos += '<br><strong>Capacité d\'accueil : </strong> %s<br>' % capacite_pers
        if capacite_chambres:
            infos += '<strong>Nombre de chambres  : </strong> %s<br>' % capacite_chambres
        if capacite_emplacements:
            infos += '<strong>Nombre d\'emplacements : </strong> %s<br>' % capacite_emplacements
        if infos[-4:] == '<br>':
            infos = infos[:-4]
        if not infos or infos == u'<br>':
            return None
        return infos.replace('\n', '<br>')

    def filter_type2(self, src, val):
        """
        Dans cette méthode on s'occupe de parcourir les valeurs contenues dans TYPE2s.
        Pour chaque valeur on va soit créer s'il n'existe pas encore, soit récupérer un objet de type TouristicContentType1 correspondant à la valeur rencontrée dans le flux Tourinsoft.
        Ensuite on va renvoyer un tableau contenu l'ensemble des objet rencontrés. Ainsi, le contenu touristique créé dans Geotrek sera associé à plusieurs sous-catégories issues de Tourinsoft.
        """
        instances = []
        if not val:
            return instances
        labels = val
        for element in labels:
            instance_label, created = TouristicContentType2.objects.get_or_create(
                category_id=self.category.id,
                label=element['ThesLibelle']
            )
            instances.append(instance_label)
        return instances

    def filter_type1(self, src, val):
        """
        Try to match every object with one of the following category :
        Chambre d'hôtes / Hébergement atypique / Gîte et hébergement collectif / Hôtel / Camping / Aire de pique-nique (currently no element matching) / Restaurant / Aire de camping-car
        """
        instances = []
        if not val:
            return instances
        object_type, type_hebergement = val
        if object_type == "Campings":
            instance_type, created = TouristicContentType1.objects.get_or_create(
                category_id=self.category.id,
                label='Camping'
            )
            instances.append(instance_type)

        elif object_type == "Hôtels":
            instance_type, created = TouristicContentType1.objects.get_or_create(
                category_id=self.category.id,
                label='Hôtel'
            )
            instances.append(instance_type)

        elif object_type == "Hébergements collectifs":
            instance_type, created = TouristicContentType1.objects.get_or_create(
                category_id=self.category.id,
                label='Hébergement collectif'
            )
            instances.append(instance_type)
        elif object_type == "Aires de camping-cars":
            instance_type, created = TouristicContentType1.objects.get_or_create(
                category_id=self.category.id,
                label='Aire de camping-car'
            )
            instances.append(instance_type)
        elif object_type == "Villages Vacances":
            instance_type, created = TouristicContentType1.objects.get_or_create(
                category_id=self.category.id,
                label='Village de Vacances'
            )
            instances.append(instance_type)
        elif object_type == "Résidences":
            instance_type, created = TouristicContentType1.objects.get_or_create(
                category_id=self.category.id,
                label='Résidence de Tourisme'
            )
            instances.append(instance_type)
        elif object_type == "Hébergements locatifs (meublés et chambres d'hôtes)":
            if type_hebergement:
                if type_hebergement[0]['Typedequipement']['ThesLibelle'] == "Chambres d'hôtes":
                    instance_type, created = TouristicContentType1.objects.get_or_create(
                        category_id=self.category.id,
                        label="Chambre d'hôte"
                    )
                    instances.append(instance_type)
                elif type_hebergement[0]['Typedequipement']['ThesLibelle'] == "Meublés":
                    instance_type, created = TouristicContentType1.objects.get_or_create(
                        category_id=self.category.id,
                        label='Locations et Gîtes'
                    )
                    instances.append(instance_type)
                else:
                    raise ValueImportError("An unknown category for 'Type d'hébergement locatif' has been found !")
        else:
            raise ValueImportError('No category found')
        return instances


class TourinsoftParser64LOISIRS1(TourinsoftParser64):
    """
    A partir de maintenant une classe = un flux tourinsoft

    Dans celle-ci on s'occupe du flux tourinsoft pour les LOI PCU PNA
    """
    label = "Sites recommandés"
    category = "Sites recommandés"
    model = TouristicContent  # On indique que l'objet Geotrk qu'on souhaite créer et un object de type "Contenu touristique"
    url = "https://api-v3.tourinsoft.com/api/syndications/PRECISER VOTRE URL"
    field_options = {  # Pour pouvoir créer un Contenu touristique, on indique ici qu'on souhaite à minima que notre objet possède une géométrie et une sous-catégorie (en plus du nom obligatoire par contrainte SQL donc non spécifié ici)
        'geom': {'required': True},
        'type1': {'required': True},
    }
    fields = {
        'eid': 'SyndicObjectID',
        'name': 'SyndicObjectName',
        'geom': ('GmapLongitude', 'GmapLatitude'),  # Ici on associe deux champs Tourinsoft à un champ Geotrek. Geotrek va donc chercher une méthode "filter_geom" pour savoir que faire avec les deux valeurs provnenant de tourinsoft. La méthode filter_geom est définie L57
        'practical_info': (
            'OUVERTURETEXTEs',
            'TARIFSTEXTEs',
        ),
        'contact': (
            'MOYENSCOMs',
            'ADRESSEs',
        ),
        'description': 'DESCRIPTIFSs',
        'email': 'MOYENSCOMs',  # Ici on pourrait ne pas faire de méthode dédiée, mais comme il s'agit d'un champ Tourinsoft qui est un tableau on doit créer une méthode pour pouvoir parcourir le tableau et trouver l'information qui nous intéresse. Cf L96
        'website': 'MOYENSCOMs',
    }

    m2m_fields = {
        'type1': 'TYPE',  # Ici on associe le champ Tourinsoft "TYPE2s" au champ Many2Many Geotrek "type1". Ce champ correspond à une liste de plusieurs objets de type "Sous-Catégorie de Contenu touristique de niveau 1". Voir L176
    }

    def start(self):
        """
        Méthode d'initialisation de note parser. On va s'occuper ici de créer une catégorie de contenu touristique nommée "Restaurant". Si la cétégorie existe déjà on la récupère afin de pouvoir l'associer aux objets que notr Parser va créer.
        """
        super().start()
        category, created = TouristicContentCategory.objects.get_or_create(label=self.label)
        self.category = category

    def filter_type1(self, src, val):
        """
        Dans cette méthode on s'occupe de parcourir les valeurs contenues dans TYPE2s.
        Pour chaque valeur on va soit créer s'il n'existe pas encore, soit récupérer un objet de type TouristicContentType1 correspondant à la valeur rencontrée dans le flux Tourinsoft.
        Ensuite on va renvoyer un tableau contenu l'ensemble des objet rencontrés. Ainsi, le contenu touristique créé dans Geotrek sera associé à plusieurs sous-catégories issues de Tourinsoft.
        """
        instances = []
        if not val:
            return instances
        for entry in val.split('#'):
            instance_type, created = TouristicContentType1.objects.get_or_create(
                category_id=self.category.id,
                label=entry
            )
            instances.append(instance_type)
        return instances

    def filter_practical_info(self, src, val):
        ouverture_textes, tarifs = val
        infos = ''
        if ouverture_textes:
            infos += '<strong>Informations sur l\'ouverture de l\'établissement : </strong><br>'
            infos += '%s<br>' % ouverture_textes[0]['Informationsouverture']
        if tarifs:
            infos += '<strong>Informations sur les tarifs : </strong><br>'
            infos += '%s<br>' % tarifs[0]['Descriptiftarifs']
        #if tarifs:
        #    for tarif in tarifs:
        #        if tarif['Descriptiftarifs']:
        #            infos += '<br><strong>Informations sur les tarifs2 : </strong> %s<br>' % tarif['Descriptiftarifs']
        if infos[-4:] == '<br>':
            infos = infos[:-4]
        if not infos or infos == u'<br>':
            return None
        return infos.replace('\n', '<br>')

    def filter_description(self, src, val):
        descriptions = val
        infos = ''
        if descriptions:
            for desc in descriptions:
                if desc['Descriptioncommerciale']:
                    infos += '%s <br><br>' % desc['Descriptioncommerciale']
        if infos[-4:] == '<br>':
            infos = infos[:-4]
        if not infos or infos == u'<br>':
            return None
        return infos.replace('\n', '<br>')


class TourinsoftParser64PCU(TourinsoftParser64):
    """
    A partir de maintenant une classe = un flux tourinsoft

    Dans celle-ci on s'occupe du flux tourinsoft pour les PCU
    """
    label = "Patrimoine Culturel"
    category = "Patrimoine Culturel"
    model = TouristicContent  # On indique que l'objet Geotrk qu'on souhaite créer et un object de type "Contenu touristique"
    url = "https://api-v3.tourinsoft.com/api/syndications/cdt64.tourinsoft.com/3cfd6199-8c69-41b7-b935-3c2f4dba4b15"
    field_options = {
        # Pour pouvoir créer un Contenu touristique, on indique ici qu'on souhaite à minima que notre objet possède une géométrie et une sous-catégorie (en plus du nom obligatoire par contrainte SQL donc non spécifié ici)
        'geom': {'required': True},
        'type1': {'required': True},
    }
    fields = {
        'eid': 'SyndicObjectID',
        'name': 'SyndicObjectName',
        'geom': ('GmapLongitude', 'GmapLatitude'),
        # Ici on associe deux champs Tourinsoft à un champ Geotrek. Geotrek va donc chercher une méthode "filter_geom" pour savoir que faire avec les deux valeurs provnenant de tourinsoft. La méthode filter_geom est définie L57
        'practical_info': (
            'OUVERTURETEXTE',
            'TARIFSTEXTE',
        ),
        'contact': (
            'MOYENSCOMs',
            'ADRESSEs',
        ),
        'description': 'DESCRIPTIFSs',
        'email': 'MOYENSCOMs',
        # Ici on pourrait ne pas faire de méthode dédiée, mais comme il s'agit d'un champ Tourinsoft qui est un tableau on doit créer une méthode pour pouvoir parcourir le tableau et trouver l'information qui nous intéresse. Cf L96
        'website': 'MOYENSCOMs',
    }

    m2m_fields = {
        'type1': 'TYPEs',
        # Ici on associe le champ Tourinsoft "TYPE2s" au champ Many2Many Geotrek "type1". Ce champ correspond à une liste de plusieurs objets de type "Sous-Catégorie de Contenu touristique de niveau 1". Voir L176
    }

    def start(self):
        """
        Méthode d'initialisation de note parser. On va s'occuper ici de créer une catégorie de contenu touristique nommée "Restaurant". Si la cétégorie existe déjà on la récupère afin de pouvoir l'associer aux objets que notr Parser va créer.
        """
        super().start()
        category, created = TouristicContentCategory.objects.get_or_create(label=self.label)
        self.category = category

    def filter_type1(self, src, val):
        """
        Dans cette méthode on s'occupe de parcourir les valeurs contenues dans TYPE.
        Pour chaque valeur on va soit créer s'il n'existe pas encore, soit récupérer un objet de type TouristicContentType1 correspondant à la valeur rencontrée dans le flux Tourinsoft.
        Ensuite on va renvoyer un tableau contenu l'ensemble des objet rencontrés. Ainsi, le contenu touristique créé dans Geotrek sera associé à plusieurs sous-catégories issues de Tourinsoft.
        """
        # instances = []
        # if not val:
        #    return instances
        # for entry in val.split('#'):
        #    instance_type, created = TouristicContentType1.objects.get_or_create(
        #       category_id=self.category.id,
        #        label=entry
        #    )
        #    instances.append(instance_type)
        # return instances

        # instances = []
        # if not val:
        #    return instances
        # entry = val
        # for entry in val:
        #            instance_type, created = TouristicContentType1.objects.get_or_create(
        #                category_id=self.category.id,
        #                label=entry['ThesLibelle']
        #            )
        #            instances.append(instance_type)
        # return instances

        # instances = []
        # if not val:
        #    return instances
        # labels = val
        # for element in labels:
        #    instance_label, created = TouristicContentType1.objects.get_or_create(
        #        category_id=self.category.id,
        #        label=element['ThesLibelle']
        #    )
        #    instances.append(instance_label)
        # return instances

        instances = []
        if not val:
            return instances
        for entry in val:
            if entry['Typedequipementprincipal']:
                for cat in entry['Typedequipementprincipal']:
                    instance_type, created = TouristicContentType1.objects.get_or_create(
                        category_id=self.category.id,
                        label=entry['Typedequipementprincipal']['ThesLibelle']
                    )
                    instances.append(instance_type)
        return instances

    def filter_practical_info(self, src, val):
        ouverture_textes, tarifs = val
        infos = ''
        if ouverture_textes:
            infos += '<strong>Informations sur l\'ouverture de l\'établissement : </strong><br>'
            infos += '%s<br>' % ouverture_textes
        if tarifs:
            infos += '<strong>Informations sur les tarifs : </strong><br>'
            infos += '%s<br>' % tarifs
        # if tarifs:
        #    for tarif in tarifs:
        #        if tarif['Descriptiftarifs']:
        #            infos += '<br><strong>Informations sur les tarifs2 : </strong> %s<br>' % tarif['Descriptiftarifs']
        if infos[-4:] == '<br>':
            infos = infos[:-4]
        if not infos or infos == u'<br>':
            return None
        return infos.replace('\n', '<br>')

    def filter_description(self, src, val):
        descriptions = val
        infos = ''
        if descriptions:
            for desc in descriptions:
                if desc['Descriptioncommerciale']:
                    infos += '%s <br><br>' % desc['Descriptioncommerciale']
        if infos[-4:] == '<br>':
            infos = infos[:-4]
        if not infos or infos == u'<br>':
            return None
        return infos.replace('\n', '<br>')

@camillemonchicourt
Copy link
Member

Merci pour le retour et le partage.
Il y avait un autre exemple de parser Tourinsoft qui avait été partagé ici : https://groups.google.com/g/geotrek-fr/c/iR8toTknUpM/m/_88y0peACgAJ

Il y a déjà une partie du parser Tourinsoft qui est partagée dans le code https://github.com/GeotrekCE/Geotrek-admin/blob/master/geotrek/common/parsers.py#L678 et https://github.com/GeotrekCE/Geotrek-admin/blob/master/geotrek/tourism/parsers.py#L585

Mais suite à votre travail, il y en a peut-être d'autres à intégrer dans les parsers par défaut, pour moins avoir de partie spécifique dans votre parser, même si il y a toujours des parties qui doivent être spécifiques au contexte ?

Et comme la mise à place d'import depuis des flux Tourinsoft revient souvent, votre mise en place serait certainement l'occasion de mettre quelques lignes sur l'import Tourinsoft dans la documentation d'import (https://github.com/GeotrekCE/Geotrek-admin/blob/master/docs/import.rst) car actuellement on mentionne uniquement Apidae, Esprit Parc et Biodiv'Sports.

@camillemonchicourt
Copy link
Member

camillemonchicourt commented Apr 28, 2022

En complément, @AudreyRemy nous partage un parser permettant d'importer des randonnées et leurs POI depuis Tourinsoft vers Geotrek.

Code

class TrekTourInSoftParser(TourInSoftParser):
    eid = 'eid'
    model = Trek
    delete = True
    themes = None
    portal = None
    url = 'https://wcf.tourinsoft.com/Syndication/cdt64/xxxxxxxxxxxxxxxxxxxxxxxxxx/Objects'

    constant_fields = {
        'review': True,
        'deleted': False,
    }

    fields = {
        'eid': 'SyndicObjectID',
        'name': ('SyndicObjectName', 'NUMEROETAPE'),
        'description_teaser': 'TITRE2',
        'ambiance': 'DESCRIPTIF',
        'departure': 'COMMUNEDEPART',
        'arrival': 'COMMUNEARRIVEE',
        'description': ('ETAPES', 'COMMUNEDEPART', 'RAISONSOCIALE', 'SyndicObjectID'),
        'points_reference': 'ETAPES',
        'geom': ('GmapLongitude', 'GmapLatitude'),
        'difficulty': 'DIFFICULTE',
        'advice': 'ALERTEINFO',
        'practice': 'TYPE',
        'duration': 'DUREE',
        'structure': 'structure',
        'route': 'TYPOLOGIE'
    }

    non_fields = {
        'pois': 'POINTDINTERET',
        'attachments': 'PHOTO',
    }

    field_options = {
        'geom': {'required': True},
        'source': {'create': True},

    }

    m2m_fields = {
        'themes': 'THEMATIQUE', # Not used
        'networks': 'BALISAGECOULEUR',
        'information_desks': 'structure',
        'web_links': ('COMMUNEDEPART', 'RAISONSOCIALE', 'SyndicObjectID'),
        'source': 'AUTEUR'
    }

    natural_keys = {
        'category': 'label',
        'type1': 'label',
        'type2': 'label',
        'source': 'name',
        'portal': 'name',
    }
    match_poi_type = {"Panorama": "Point de vue",
                      "Patrimoine bâti": "Patrimoine bâti",
                      "Site naturel": "Site naturel",
                      "Site culturel": "Site culturel",
                      "Dégustation": "Dégustation",
                      "Faune / Flore": "Faune / Flore"}

    match_practice = {"Equestre": "Equestre",
                      "VAE": "Vélo"}

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.constant_fields = self.constant_fields.copy()
        self.m2m_constant_fields = self.m2m_constant_fields.copy()

    def next_row(self):
        skip = 0
        while True:
            params = {
                '$format': 'json',
                '$inlinecount': 'allpages',
                '$top': 1000,
                '$skip': skip,
            }
            response = self.request_or_retry(self.url, params=params)
            self.root = response.json()
            self.nb = self.get_nb()
            for row in self.items:
                try:
                    # On ajoute l'info sur les structures dans la reponse avant de traiter l'objet
                    # On peut ainsi récuperer les informations pour les lieux d'informations et les structures Geotrek
                    response = self.request_or_retry(row.get('Structure').get('__deferred').get('uri'),
                                                     params={'$format': 'json'})
                    row['structure'] = response.json()['d']
                except requests.exceptions.ConnectionError:
                    # Si on arrive pas a se connceter a l'url de structure on continue sans ajouter la structure
                    pass
                yield {self.normalize_field_name(src): val for src, val in row.items()}
            skip += 1000
            if skip >= self.nb:
                return

    def start(self):
        kwargs = self.get_to_delete_kwargs()
        # On cherche tous les itinéraires qui n'ont que une aggregation et qui viennent de tourinsoft (eid_startwith)
        # queryset.filter(**kwargs) filtre également les itinéraires qui ne sont plus en cours de publication reviewed=True
        queryset = self.model.objects.annotate(count_aggreg=Count('topo_object__aggregations'))
        kwargs['eid__startswith'] = "ITI"
        self.to_delete = set(queryset.filter(**kwargs).exclude(count_aggreg__gt=1).values_list('pk', flat=True))
        # Ici c'est la gestion des types de fichiers, il ne faut pas changer cette partie.
        if settings.PAPERCLIP_ENABLE_LINK is False and self.download_attachments is False:
            raise Exception('You need to enable PAPERCLIP_ENABLE_LINK to use this function')
        try:
            self.filetype = FileType.objects.get(type=self.filetype_name, structure=None)
        except FileType.DoesNotExist:
            try:
                self.filetype = FileType.objects.get(type=self.filetype_name, structure=self.structure)
            except FileType.DoesNotExist:
                raise GlobalImportError(_("FileType '{name}' does not exists in "
                                          "Geotrek-Admin. Please add it").format(name=self.filetype_name))
        # L'auteur des images sera un utilisateur "import"
        self.creator, created = get_user_model().objects.get_or_create(username='import', defaults={'is_active': False})

    def make_string_to_float(self, value):
        # Cela permet de remplacer les données de mauvaise qualité en flottant
        return float(value.replace('°', '').replace(',', '.').replace(' ', '').replace('O', ''))

    def filter_route(self, src, val):
        # En fonction de la valeur dans tourinsoft on remplace la valeur val en nouvelle valeur new_val (Aller-retour ...)
        # Certaines condition ne sont pas utiles mais elle existe si on veut changer le nom des types de routes
        if not val:
            return
        val = val.split('#')[0]
        if val == "Itinéraire linéaire":
            new_val = "Traversée"
        elif val == "Boucle":
            new_val = "Boucle"
        elif val == "Aller-retour":
            new_val = "Aller-retour"
        else:
            return
        return Route.objects.get(route=new_val)

    def filter_name(self, src, val):
        name, num_etape = val
        if num_etape and len(num_etape.split('|')) > 1:
            final_name = f"{name} étape n°{num_etape.split('|')[1]}"
        else:
            final_name = name
        return final_name

    def filter_departure(self, src, val):
        # On met en majuscule le départ
        return val.capitalize()

    def filter_arrival(self, src, val):
        # On met en majuscule l'arrivée
        return val.capitalize()

    def filter_practice(self, src, val):
        # Depuis une liste de pratique on récupere la première pratique et on essaye de trouver sa correspondance.
        # Toutes les correspondances ne sont pas faites (voir match_practice en haut du parser)
        practice_name = val.split('#')[0]  # TODO We can have Pédestre#Vélo#VTT what should we do
        practice_name_g = self.match_practice.get(practice_name) if self.match_practice.get(practice_name) else practice_name
        try:
            practice = Practice.objects.get(name=practice_name_g)
        except Practice.DoesNotExist:
            # Si la pratique n'est pas trouvé sur geotrek, on affiche une erreur en fin d'import de parser. Il arrive également que il n'y ait pas de pratique.
            raise RowImportError(f"Missing practice : {practice_name_g}")
        return practice

    def filter_difficulty(self, src, val):
        if not val:
            return
        val = val.split("#")[0]
        # On remplace moyenne par intémediaire
        if val == "Moyenne":
            val = "Intermédiaire"
        return DifficultyLevel.objects.get(difficulty=val)

    def filter_description(self, src, val):
        steps, commune, social, id_trek = val
        if not steps:
            description = ""
        else:
            # On ajoute les étapes
            b_steps = steps.split(self.separator)
            final_list_steps = []
            for step in b_steps:
                steps_information = step.split(self.separator2)
                if len(steps_information) > 2:
                    final_list_steps.append(f"""<li>{steps_information[2]}</li>""")

            description = "</br>".join(final_list_steps)
            description = f'{description}</br></br>'
        # On ajoute l'url resa tourisme a partir de la commune l'id du trek dans tourinsoft et la raison social
        url = f'https://resa.tourisme64.com/itineraire/{commune.replace(" ", "-")}/{id_trek}-{social.replace(" ", "-")}'
        final_description = f'''<ol>{description}</ol><p><strong>Informations touristiques complémentaires : </strong></p></br><p><a href="{url}">{url}</a></p>'''
        return final_description

    def filter_structure(self, src, val):
        if not val or not val.get('Name'):
            return Structure.objects.get_or_create(name=settings.DEFAULT_STRUCTURE_NAME)[0]
        name_structure = val.get('Name')
        # Create or get the structure in structure json
        structure, created = Structure.objects.get_or_create(name=name_structure)
        return structure

    def filter_web_links(self, src, val):
        # On ajoute sur tous les liens web "reussir ma rando"
        web_link_1 = WebLink.objects.get(name="Reussirmarando")
        return [web_link_1]

    def filter_information_desks(self, src, val):
        # On creer un nouveau lieux d'information a partir des données stockés dans structure
        name = val.get('Name')
        email = val.get('Email')
        url = val.get('Url')
        address = val.get('Address1')
        address_2 = val.get('Address2')
        address_3 = val.get('Address3')
        postcode = val.get('Postcode')
        city = val.get('City')
        phone = val.get('Phone')
        type_id = InformationDeskType.objects.get(label="Office du tourisme")
        information_desk, created = InformationDesk.objects.get_or_create(name=name,
                                                                          defaults={'type': type_id,
                                                                                    'phone': phone,
                                                                                    'email': email,
                                                                                    'website': url,
                                                                                    'municipality': f'{city} {postcode}',
                                                                                    'street': f'{address} {address_2} {address_3}'
                                                                                    })
        return [information_desk]

    def save_pois(self, src, val):
        # On genere des pois à partir des données dans POINTDINTERET
        # Ici on utilise l'obj courant pour gerer les fichiers attachés (self.obj)
        # Durant l'import on passe par Trek_1 puis POI_1_A, POI_1_B Trek_2 puis POI_2_1 etc.
        # On stock l'information de l'itinéraire car des actions sont faites sur lui également après l'enregistrement des pois
        main_obj = self.obj
        if not val:
            return
        pois_list = val.split(self.separator)
        for poi_text in pois_list:
            list_val_poi = poi_text.split(self.separator2)
            if not list_val_poi or len(list_val_poi) < 8:
                # Si entre les separateurs | nous avons moins de 8 elements, on passe au point d'interet suivant (Il faut changer le separateur si il y a le separateur dans la description !)
                continue
            type_poi = list_val_poi[0]
            # On recupere le type de poi correspondant dans match_poi_type
            type_poi_g = self.match_poi_type.get(type_poi) if self.match_poi_type.get(
                type_poi) else type_poi
            try:
                poi_type_final = POIType.objects.get(label=type_poi_g)
            except POIType.DoesNotExist:
                self.add_warning(f"This type of poi doesn't exist {type_poi}")
                continue
            name_poi = list_val_poi[1]
            description_poi = list_val_poi[2]
            lng = list_val_poi[3]
            lat = list_val_poi[4]
            url_img_poi = list_val_poi[5]
            legend_img_poi = list_val_poi[6]
            credit_img_po = list_val_poi[7]
            # Gestion des geometries
            if not lng or not lat or not type_poi_g:
                continue
            try:
                serialized = '{"lng": %s, "lat": %s}' % (self.make_string_to_float(lng), self.make_string_to_float(lat))
            except ValueError:
                continue
            try:
                topology = Topology.deserialize(serialized)
            except GDALException:
                self.add_warning(f'poi {name_poi} has bad lat lng format {lat}, {lng} (should be 4326)')
                continue
            # Move deserialization aggregations to the POI
            # We get the poi by it's name if it exist already do not change review/published

            #
            poi = POI.objects.filter(name=name_poi, type=poi_type_final).first()
            if poi:
                poi.description = description_poi
                poi.save()
            else:
                poi = POI.objects.create(name=name_poi,
                                         type=poi_type_final,
                                         description=description_poi,
                                         review=True)
            # Genere la geometrie avec la segmentation dynamique :
            poi.mutate(topology)
            # On met le poi en obj courant pour la gestion des fichiers attachés
            self.obj = poi
            try:
                self.save_attachments(src, [url_img_poi, f'{legend_img_poi} {credit_img_po}', ''])

            except (requests.exceptions.ConnectionError, ValueImportError):
                continue
        # On remet l'itineraire courant comme obj courant
        self.obj = main_obj

    def filter_attachments(self, src, val):
        # Gere a la fois les fichiers attachés depuis les itinéraires et les pois si c'est une liste cela vient des pois sinon depuis l'itineraires
        # Dans le cas des itineraires nous avons aucune information par rapport a la legende etc
        if not val:
            return []
        if isinstance(val, list):
            url_end, legend, author = val
        else:
            values = val.split('#')
            list_urls = []
            for value in values:
                list_urls.append([f'http://cdt64.media.tourinsoft.eu/upload/{value}', "", ""])
            return list_urls
        if url_end:
            url = f'http://cdt64.media.tourinsoft.eu/upload/{url_end}'
            return [[url, legend, author]]
        else:
            return []

    def filter_points_reference(self, src, val):
        # Gestion des points de passages
        if not val:
            return
        steps = val.split(self.separator)
        steps = [step.split(self.separator2) for step in steps]
        geometries = []
        for step in steps:
            if not step:
                continue
            if len(step) < 7:
                continue
            lng = step[5].replace('°', '').replace(',', '.').replace(' ', '').replace('O', '')
            lat = step[6].replace('°', '').replace(',', '.').replace(' ', '').replace('O', '')
            if lng and lat:
                try:
                    geom = Point(float(lng), float(lat), srid=4326)
                    try:

                        geom_srid = geom.transform(settings.SRID, clone=True)
                    except GDALException:
                        # Geometry with int value but transformation in srid of project is not possible
                        continue
                    geometries.append(geom_srid)
                except ValueError:
                    # lat lng are not numbers
                    pass
        return MultiPoint(geometries, srid=settings.SRID)

    def filter_geom(self, src, val):
        # Ici on essaye de transformer des lats/long qui peuvent etre ecrit : 43,5 en 43.5
        lng, lat = val
        if not lng or not lat:
            raise ValueImportError("Empty geometry")
        lng = lng.replace('°', '').replace(',', '.').replace(' ', '').replace('O', '')
        lat = lat.replace('°', '').replace(',', '.').replace(' ', '').replace('N', '')
        geom = Point(float(lng), float(lat), srid=4326)  # WGS84
        geom.transform(settings.SRID)
        paths = Path.objects.annotate(distance=Distance('geom', geom))
        path = paths.order_by('distance').first()
        # pour creer une geometrie il faut l'ecrire dans la forme de l'outil comme ci desous (segmentation dynamique)
        # [{distance au troncon, information sur es troncons : le premier (0) entre 0 et 1 sur le troncon "path"]
        serialized = [{"offset": 0, "positions": {"0": [0, 1]}, "paths":[path.pk]}]
        self.topology = None
        try:
            self.topology = Topology.deserialize(serialized)
        except GDALException:
            self.add_warning(f'Wrong geom')
        return path.geom

    def filter_duration(self, src, val):
        # Ici on essaye de transformer la valeur version humaine en entier : 1h30 => 1.5 / 1 jour => 8
        all_duration = val
        duration = all_duration.split(self.separator2)[0]
        duration = duration.replace(' ', '')
        duration_number = None
        # Type multiple de durée,
        for action in ["%Hh%M", "%Hh", '%H', '%Mmn', 'à%Hh', '%Mmin', '%H:00']:
            try:
                date_duration = datetime.strptime(duration, action)
                duration_number = round(date_duration.hour + date_duration.minute / 60, 3)
                break
            except ValueError:
                continue
        for action in ["%djour", "%djours"]:
            try:
                date_duration = datetime.strptime(duration, action)
                duration_number = date_duration.day * 8
            except ValueError:
                continue
        if not duration_number:
            if duration == '1/2Jour':
                return 4
            if duration == '1/2jour':
                return 4
        return duration_number

    def filter_networks(self, src, val):
        # Nous savons uniquement que Blanc et rouge c'est le GR jaune c'est un PR etc...
        # Autres valeurs retrouvés : Blanc et rouge (GR®), Jaune et rouge (GRP®), Bleu, Jaune, Noir, Orange, Rouge, Vert, Violet
        network = val
        if network == 'Blanc et rouge (GR®)':
            return [TrekNetwork.objects.get(network='GR')]
        elif network == 'Jaune':
            return [TrekNetwork.objects.get(network='PR')]
        elif network == 'Orange':
            return [TrekNetwork.objects.get(network='Piste équestre')]
        return []

    def parse_obj(self, row, operation):
        try:
            update_fields = self.parse_fields(row, self.fields)
            update_fields += self.parse_fields(row, self.constant_fields)
            # Ici on enleve les champs qui ne seront pas repris lors de la mise a jour d'un objet (review, geom, pois et id)
            if 'review' in update_fields:
                update_fields.remove('review')  # don't want to change review value after creation
            if 'geom' in update_fields:
                update_fields.remove('geom')  # don't want to change geom value after creation
            if 'pois' in update_fields:
                update_fields.remove('pois')  # don't want to change pois value after creation
            if 'id' in update_fields:
                update_fields.remove('id')  # Can't update primary key
        except RowImportError as warnings:
            self.add_warning(str(warnings))
            return
        if operation == "created":
            self.obj.save()
            # Permet de creer la geometrie associé aux informations stocké dans la topologie
            # [{distance au troncon, information sur es troncons : le premier (0) entre 0 et 1 sur le troncon "path"]
            self.obj.mutate(self.topology)

        else:
            self.obj.save(update_fields=update_fields)
        update_fields += self.parse_fields(row, self.m2m_fields)
        update_fields += self.parse_fields(row, self.m2m_constant_fields)
        update_fields += self.parse_fields(row, self.non_fields, non_field=True)
        if operation == "created":
            self.nb_created += 1
        elif update_fields:
            self.nb_updated += 1
        else:
            self.nb_unmodified += 1

    def download_attachment(self, url):
        try:
            super().download_attachment(url)
        except requests.ConnectionError:
            return None

    def report(self, output_format='txt'):
        report_txt = super().report(output_format)
        if self.nb_created > 0:
            try:
                # On envoit un mail si il y a un nouveau itinéraire
                mail_admins(
                    "[Itinéraire] Un nouvel itinéraire provenant du flux Tourinsoft vient d'être créé",
                    f"Lors de l'import du flux d'itinéraires provenant de Tourinsoft, un itinéraire a été créer, il est possible de le retrouver avec le champ review dans les filtres et son identifiant est {self.obj.pk}"
                )
            except Exception:
                print("Error while trying to send an email, please configure your mail server")
        return report_txt

Comme pour créer le tracé d'un itinéraire dans Geotrek, il faut s'appuyer sur une série de tronçons, on ne peut pas importer le tracé depuis Tourinsoft tel quel pour en faire le tracé de la rando dans Geotrek.

Le processus est donc celui-ci :

  • On interroge le flux des randos de Tourinsoft
  • On mappe tous les champs et les valeurs qui ont des correspondances entre Tourinsoft et Geotrek
  • On récupère le maximum d’informations que l'on peut sur les itinéraires et leurs POI (structure, lieux d'info, pratique, photos, parcours, difficulté, points de référence, liens web, durée, réseau...)
  • Si un nouvel itinéraire est trouvé dans le flux, on va le créer avec une géométrie incorrecte/incomplète (en la basant sur le tronçon le plus proche du XY renvoyé par Tourinsoft), on laisse l'itinéraire en statut "en cours de publication" et on envoie un email à l'administrateur lui indiquant qu'un nouvel itinéraire a été importé depuis Tourinsoft et qu'il faut le reprendre pour tracer sa géométrie correctement sur les tronçons existants
  • Les POI qui sont des points sont eux bien localisés avec leur géométrie correcte
  • Lors des prochaines interrogations du flux, les champs seront éventuellement mis à jour sauf le statut et la géométrie car celle-ci aura été reprise au niveau de Geotrek et ne doit donc pas être écrasée lors des mises à jour

En évolution, je me dis qu'on pourrait voir pour importer la géométrie venant de Tourinsoft dans la géométrie de l'itinéraire, sans lui créer de topologie. Ou plutôt en lui créant une topologie de base comme ça semble déjà être le cas, mais stocker dans la "geom" de l'itinéraire sa géométrie venant de Tourinsoft.
Et lors de l'édition de celui-ci permettre d'afficher la géométrie enregistrée de l'itinéraire pour en faciliter le traçage sur la base des tronçons.

D'autant plus que c'est proche d'une fonctionnalité envisagée pour remédier au sujet des modifications de topologies non voulues (#2515) où il a été envisagé :

Lors d’une modification, afficher la géométrie originale pour que l’utilisateur soit averti d’un éventuel changement à la sauvegarde (prévention modification non souhaitée)

Ainsi quand on est sur un itinéraire, on pourrait imaginer une fonctionnalité permettant d'afficher la "géométrie préalable" de l'objet en affichant son champs "geom" sur la carte, en plus de sa topologie sur les tronçons.

@babastienne
Copy link
Member

De part la structure variable des flux Tourinsofts, il n'est pas possible de créer un parser unique pour importer les itinéraires directement dans Geotrek comme c'est le cas pour Apidae. En effet, un travail d'analyse du flux (de sa structure), et de code est nécéssaire pour faire correspondre les champs Geotrek et les champs Tourinsoft.

Ce qui a été effectué pour le département 64 fonctionne bien mais est un cas spécifique car importe des données attributaires mais pas les géométries du fait de la segmentation dynamique.

Un autre exemple est disponible ci-après, utilisé pour un territoire sans segmentation dynamique, ce qui signifie que le script importe également les géométries des itinéraires.

On notera dans l'exemple qu'un travail de code est nécéssaire pour s'adapter au flux Tourinsoft. Ensuite la classe principale est surchargée pour chaque pratique afin d'adapter les contenus (deux exemples de surcharge en fin de snippet).

Code

from datetime import datetime, timedelta
import textwrap
from geotrek.common.models import Attachment, FileType
from geotrek.trekking.parsers import ApidaeTrekParser
from geotrek.common.parsers import TourInSoftParser, RowImportError
from geotrek.trekking.models import DifficultyLevel, Practice, Trek, Route


class TrekParser(TourInSoftParser):
    """Import Treks from Tourinsoft SIT"""
    version_tourinsoft = 3
    url = "URL DU FLUX"
    label = "Itinéraires TourinSoft"
    label_fr = ""
    label_en = "TourinSoft Treks"
    model = Trek
    create = True
    eid = 'eid'
    provider = "Tourinsoft"
    fields = {
        "eid": "SyndicObjectID",
        "name": "NomOffre",
        "practice": "TypePrincipals.*.ThesLibelle",
        "route": "TypeParcourss.*.Typedetrace.ThesLibelle",
        "difficulty": ("TypeParcourss.*.Difficulteduparcours.ThesLibelle", "NiveauDifficultePedestre.ThesLibelle", "NiveauDifficulteVelo.ThesLibelle", "NiveauDifficulteVTT.ThesLibelle", "NiveauDifficulteEquestre.ThesLibelle"),
        "duration": ("TypeParcourss.*.Dureeduparcours", "DureePied", "DureeVelo", "DureeVTT", "DureeEquestre"),
        "description": ("Description", "PointForts.*.Pointsforts"),
        "geom": "Traces.0.TraceGPS.Url",
        "departure": ("AdresseDepart", "CPDepart", "CommuneDepart"),
        "arrival": ("CPArrivee", "CommuneArrivee"),
        "access": ("Access.*.Pointdacces.ThesLibelle", "Access.*.Nomdelacces")
    }
    natural_keys = {
        'difficulty': 'difficulty',
        'route': 'route',
        'themes': 'label',
        'practice': 'name',
        'accessibilities': 'name',
        'networks': 'network'
    }

    constant_fields = {
        'published': True
    }

    m2m_fields = {
    }

    non_fields = {
        "attachments": ("Fichierss.*.Fichier", "Photos.*.Photos")
    }

    def __init__(self, progress_cb=None, user=None, encoding='utf8'):
        super().__init__(progress_cb, user, encoding)

    def normalize_field_name(self, name):
        return name  # bypass uppercase

    def get_nb(self):
        return int(len(self.root['value']))

    def filter_geom(self, src, val):
        geom_file = self.request_or_retry(url=val).content
        geom = ApidaeTrekParser._get_geom_from_gpx(geom_file)
        return geom

    def filter_route(self, src, val):
        route = None
        if val and val[0]:
            first_val = val[0]
            route, _ = Route.objects.get_or_create(route=first_val.capitalize())
        return route

    def filter_difficulty(self, src, val):
        if val:
            level, _ = DifficultyLevel.objects.get_or_create(difficulty=val.capitalize())
            return level
        return None

    def filter_duration(self, src, val):
        if val:
            try:
                val_time = datetime.strptime(val, '%H:%M:%S')
            except ValueError:
                val_time = datetime.strptime(val, '%H:%M')
            val_delta = timedelta(hours=val_time.hour, minutes=val_time.minute, seconds=val_time.second)
            val_decimal = round(float(val_delta.seconds / 3600), 2)
            return val_decimal
        return None

    def filter_departure(self, src, val):
        return ' '.join(val)

    def filter_arrival(self, src, val):
        return ' '.join(val)

    def filter_description(self, src, val):
        desc, pois = val
        if pois:
            return f"{desc}\n\nPoints d'intérêts: {', '.join(pois)}"
        return desc

    def filter_access(self, src, val):
        access_types, access_names = val
        value = ""
        if access_types and access_names:
            for type, name in zip(access_types, access_names):
                value = f"{value}{type} {name}\n"
        return value

    def filter_attachments(self, src, val):
        values = []
        pdfs, images = val
        for pdf in pdfs:
            if pdf:
                values.append([pdf['Url'], pdf['Titre'], pdf['Credit'], "Topoguide"])
        for img in images:
            if img:
                values.append([img['Url'], img['Titre'], img['Credit'], ''])
        return values

    def generate_attachment(self, **kwargs):
        # No title in data - we use this kwargs to state if we need to use topoguide or regular file
        filetype = FileType.objects.get(type="Topoguide") if kwargs.get('title') == "Topoguide" else self.filetype
        attachment = Attachment()
        attachment.content_object = self.obj
        attachment.filetype = filetype
        attachment.creator = self.creator
        attachment.author = kwargs.get('author')
        attachment.legend = textwrap.shorten(kwargs.get('legend'), width=127)
        attachment.title = ""
        return attachment


class ARCPedestrianTrekParser(ARCTrekParser):
    label = "Itinéraires TourinSoft pédestres"
    label_fr = "Itinéraires TourinSoft pédestres"

    def filter_practice(self, src, val):
        if "Pédestre" in val:
            return Practice.objects.get(name_fr="Pédestre")
        raise RowImportError("Ignored Trek because it is not related to practice 'Pédestre'")

    def filter_difficulty(self, src, val):
        typeparcours_val, specific_val, _, _, _ = val
        if typeparcours_val and typeparcours_val[0]:
            actual_val = typeparcours_val[0]
        else:
            actual_val = specific_val
        return super().filter_difficulty(src, actual_val)

    def filter_duration(self, src, val):
        typeparcours_val, specific_val, _, _, _ = val
        if typeparcours_val and typeparcours_val[0]:
            actual_val = typeparcours_val[0]
        else:
            actual_val = specific_val
        return super().filter_duration(src, actual_val)


class VeloTrekParser(ARCTrekParser):
    label = "Itinéraires TourinSoft Vélo"
    label_fr = "Itinéraires TourinSoft Vélo"

    def filter_practice(self, src, val):
        if "Cyclotourisme" in val:
            return Practice.objects.get(name="Vélo")
        raise RowImportError("Ignored Trek because it is not related to practice 'Vélo'")

    def filter_difficulty(self, src, val):
        typeparcours_val, _, specific_val, _, _ = val
        if typeparcours_val and typeparcours_val[0]:
            actual_val = typeparcours_val[0]
        else:
            actual_val = specific_val
        return super().filter_difficulty(src, actual_val)

    def filter_duration(self, src, val):
        typeparcours_val, _, specific_val, _, _ = val
        if typeparcours_val and typeparcours_val[0]:
            actual_val = typeparcours_val[0]
        else:
            actual_val = specific_val
        return super().filter_duration(src, actual_val)

Côté code Geotrek, il n'est pas prévu en l'état d'aller plus loin dans l'intégration de cette fonctionnalité au code source de Geotrek, car les flux Tourinsoft sont trop variables. Je laisse ouvert ce ticket et le marque comme "documentation". Il faudra ajouter à la documentation de Geotrek un référence vers ce ticket puis lorsque ce sera fait le clôturer.

@GeotrekCE GeotrekCE locked as resolved and limited conversation to collaborators Jan 4, 2024
@babastienne babastienne reopened this Jan 4, 2024
@camillemonchicourt
Copy link
Member

OK merci pour les précisions.
OK pour ne pas en ajouter plus dans le code de Geotrek car trop spécifique à chaque flux. Mais mettre quelques éléments dans la doc d'import mentionnant Tourinsoft et renvoyant vers ces exemples.

Concernant la piste de pouvoir quand même importer les géométries depuis Tourinsoft (ou autre) même en mode segmentation, sujet plus global discuté par ailleurs, notamment sur #2515 (comment)

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Projects
None yet
Development

No branches or pull requests

3 participants