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

Import du fichier catalogue du MC #456

Closed
johanricher opened this issue Sep 27, 2022 · 22 comments
Closed

Import du fichier catalogue du MC #456

johanricher opened this issue Sep 27, 2022 · 22 comments

Comments

@johanricher
Copy link
Member

A la suite de

Le fichier à importer (conforme au schéma du MC) est catalogue_mc_20220921.csv dans le dossier /Parties prenantes/MC sur Nextcloud.

@johanricher johanricher added this to the catalogue.data.gouv.fr milestone Sep 27, 2022
@johanricher johanricher added this to Backlog in Outil de catalogage de données via automation Sep 27, 2022
@johanricher johanricher moved this from Backlog to Prêt à développer in Outil de catalogage de données Sep 27, 2022
@florimondmanca florimondmanca moved this from Prêt à développer to Tâches à faire in Outil de catalogage de données Oct 5, 2022
@florimondmanca florimondmanca self-assigned this Oct 5, 2022
@florimondmanca florimondmanca moved this from Tâches à faire to Tâches en cours in Outil de catalogage de données Oct 5, 2022
@florimondmanca
Copy link
Collaborator

florimondmanca commented Oct 5, 2022

@johanricher J'ai préparé l'import en local.

Je mets ci-dessous comment j'ai fait et une série de screenshots du résultat. Si ça te semble OK, une fois que DataPass sera branché sur prod (pour qu'on puisse vérifier), je pourrai lancer l'import sur prod. :-)

Procédure

Je l'ai testé en local comme suit.

  1. Vider la DB : dropdb catalogage && createdb catalogage && make migrate
  2. Créer l'orga avec le repo de config :
    1. Dans le .env, mettre APP_CONFIG_REPO_API_KEY=abcd1234
    2. Dans le .env du repo de config, mettre CATALOGAGE_API_URL=http://localhost:3579 et CATALOGAGE_API_KEY=abcd1234
    3. Lancer make serve
    4. Dans le repo de config, lancer make upload
  3. Créer un fichier d'initdata à partir du catalogue au format CSV
    python -m debug.mc_import.main debug/mc_import/catalogue_mc_20220921.csv debug/mc_import/initdata.yml
  4. Lancer un initdata avec ce fichier en entrée
    python -m tools.initdata debug/mc_import/initdata.yml
  5. Lancer make serve, se connecter avec DataPass, parcourir les jeux de données

Le script debug/mc_import/main.py est une réécriture du script que j'avais écrit lors du premier import, le voici pour la postérité (et apercevoir le genre d'opérations à faire pour #343) :

Cliquer pour afficher le script
import argparse
import asyncio
import csv
import datetime as dt
import io
from pathlib import Path
from typing import Dict, List, Optional, TextIO

import yaml

from server.application.catalogs.queries import GetCatalogBySiret
from server.application.catalogs.views import ExtraFieldView
from server.config.di import bootstrap, resolve
from server.domain.common.types import id_factory
from server.domain.datasets.entities import DataFormat, UpdateFrequency
from server.domain.organizations.types import Siret
from server.seedwork.application.messages import MessageBus
from tools.initdata import InitData


def _map_geographical_coverage(value: Optional[str]) -> str:
    if not value or value == "Information manquante":
        return "(Information manquante)"  # Field is required

    return value


def _map_formats(value: Optional[str], notes_f: TextIO) -> List[str]:
    if not value or value == "Information manquante":
        notes_f.write("Format : (Information manquante)\n")
        return [DataFormat.OTHER.value]  # At least one value expected.

    if value == "oracle et shp":
        return [DataFormat.DATABASE.value, DataFormat.FILE_GIS.value]

    if value.startswith("la base est sur Oracle"):
        return [DataFormat.DATABASE.value]

    unknown_formats = []

    def _map_format(value: str) -> DataFormat:
        dataformat = {
            "ods": DataFormat.FILE_TABULAR,
            "xls": DataFormat.FILE_TABULAR,
            "xlsx": DataFormat.FILE_TABULAR,
            "csv": DataFormat.FILE_TABULAR,
            "base de données": DataFormat.DATABASE,
            "sql": DataFormat.DATABASE,
            "mbd (access)": DataFormat.DATABASE,
            "odb": DataFormat.DATABASE,
            "dbf": DataFormat.DATABASE,
            "shapefile": DataFormat.FILE_GIS,
            "shape": DataFormat.FILE_GIS,
            "shp": DataFormat.FILE_GIS,
            "shp ou map info": DataFormat.FILE_GIS,
            "geojson": DataFormat.FILE_GIS,
            "kml": DataFormat.FILE_GIS,
            "access": DataFormat.DATABASE,
        }.get(value.lower().strip(), value)

        try:
            return DataFormat(dataformat)
        except ValueError:
            unknown_formats.append(value)
            return DataFormat.OTHER

    result = [_map_format(val.strip()).value for val in value.split(",")]

    if unknown_formats:
        notes_f.write(
            f"Format (valeurs non-reconnues) : {', '.join(unknown_formats)}\n"
        )

    return result


def _map_contact_emails(text: Optional[str]) -> List[str]:
    if text is None:
        return []

    return [text.strip().replace(">", "")]


def _map_update_frequency(text: Optional[str], notes_f: TextIO) -> Optional[str]:
    if text is None or text in ("NSP", "Information manquante"):
        return None

    value = {
        "aucune": UpdateFrequency.NEVER,
        "en continu": UpdateFrequency.REALTIME,
        "mensuelle": UpdateFrequency.MONTHLY,
        "annuelle": UpdateFrequency.YEARLY,
    }.get(text.lower(), text)

    try:
        update_frequency = UpdateFrequency(value)
    except ValueError:
        notes_f.write(f"Fréquence de mise à jour (valeur originale) : {value}\n")
        return None

    return update_frequency.value


def _map_last_updated_at(datestring: str = None) -> Optional[dt.datetime]:
    if datestring is None:
        return None

    return dt.datetime.strptime(datestring, "%Y-%M-%d")


def _map_tag_ids(value: str, tags_by_name: Dict[str, dict]) -> List[str]:
    tag_ids = []

    # "périmètre délimité des abords (PDA), urbanisme; géolocalisation"
    # -> {"périmètre délimité des abords (PDA)", "urbanisme", "géolocalisation"}
    cleaned_names = set(name.strip() for name in value.replace(";", ",").split(","))

    for name in cleaned_names:
        try:
            tag = tags_by_name[name]
        except KeyError:
            tag = {"id": str(id_factory()), "params": {"name": name}}
            tags_by_name[name] = tag

        tag_ids.append(tag["id"])

    return tag_ids


def _map_extra_field_values(
    row: dict, extra_fields: List[ExtraFieldView]
) -> List[dict]:
    return [
        {"extra_field_id": str(extra_field.id), "value": value}
        for extra_field in extra_fields
        if (value := row[extra_field.name])
    ]


async def main(csv_path: Path, out_path: Path) -> None:
    bus = resolve(MessageBus)

    siret = Siret("11004601800013")

    catalog = await bus.execute(GetCatalogBySiret(siret=siret))
    expected_extra_fields = {field.name for field in catalog.extra_fields}

    with csv_path.open(
        # CSV file has BOM (Byte Order Mark) that would otherwise be read in
        # the first column name, see:
        # https://stackoverflow.com/questions/40310042/python-read-csv-bom-embedded-into-the-first-key
        # encoding="utf-8-sig",
    ) as f:
        reader = csv.DictReader(f, delimiter=",")
        fieldnames = list(reader.fieldnames or [])
        rows = list(reader)

    common_fields = {
        "titre",
        "description",
        "service",
        "couv_geo",
        "format",
        "si",
        "contact_service",
        "contact_personne",
        "freq_maj",
        "date_maj",
        "url",
        "licence",
        "mots_cles",
    }

    actual_extra_fields = (
        set(fieldnames)
        - common_fields
        - {
            "nom_orga",
            "siret_orga",
            "id_alt_orga",
            "date_pub",
            "producteur_type",  # See: https://github.com/etalab/catalogage-donnees-config/pull/22  # noqa
        }
    )

    assert actual_extra_fields == expected_extra_fields

    tags_by_name: dict = {}
    datasets = []

    for row in rows:
        assert row["siret_orga"] == str(siret)
        assert row["nom_orga"] == "Ministère de la Culture"
        assert not row["id_alt_orga"]

        import_notes_f = io.StringIO()

        params: dict = {}

        params["organization_siret"] = str(siret)
        params["title"] = row["titre"]
        params["description"] = row["description"]
        params["service"] = row["service"] or None
        params["geographical_coverage"] = _map_geographical_coverage(
            row["couv_geo"] or None
        )
        params["formats"] = _map_formats(row["format"] or None, import_notes_f)
        params["technical_source"] = row["si"] or None
        params["producer_email"] = row["contact_service"] or None
        params["contact_emails"] = _map_contact_emails(row["contact_personne"] or None)
        params["update_frequency"] = _map_update_frequency(
            row["freq_maj"] or None, import_notes_f
        )
        params["last_updated_at"] = _map_last_updated_at(row["date_maj"] or None)
        params["url"] = row["url"] or None
        params["license"] = row["licence"] or None
        params["tag_ids"] = _map_tag_ids(row["mots_cles"], tags_by_name)

        params["extra_field_values"] = _map_extra_field_values(
            row, catalog.extra_fields
        )

        if output := import_notes_f.getvalue():
            notes = "\n".join(["[[ Notes d'import automatique ]]", "", output])
            params["description"] = "\n".join([params["description"], "", notes])

        pk = id_factory()
        datasets.append({"id": str(pk), "params": params})

    spec = InitData(
        organizations=[],
        catalogs=[],
        users=[],
        tags=list(tags_by_name.values()),
        datasets=datasets,
    ).dict()

    out_path.write_text(yaml.safe_dump(spec))


if __name__ == "__main__":
    bootstrap()

    parser = argparse.ArgumentParser()
    parser.add_argument("csv_path", type=Path)
    parser.add_argument("out_path", type=Path)
    args = parser.parse_args()

    asyncio.run(
        main(
            csv_path=args.csv_path,
            out_path=args.out_path,
        )
    )

Aperçu du résultat

Au total j'ai 715 jeux de données et 105 tags.

Page d'accueil

Screenshot 2022-10-05 at 17-56-47 Catalogue (105 kB)

Page de détail

Les champs complémentaires s'affichent correctement. J'ai ajouté des "notes d'import" dans la description pour éviter de perdre de l'information quand l'état actuel de l'outil nous y obligerait (par ex, un "format" qui n'est pas parmi l'enum actuel -- On n'a pas encore de ticket pour le transformer sur le mode de couv_geo / licence ?).

Screenshot 2022-10-05 at 17-57-02 Catalogue (156 kB)

Formulaire

Idem, les champs complémentaires définis dans le schéma du MC s'affichent bien. (Mon screenshot a un peu buggé.)

Screenshot 2022-10-05 at 17-57-19 Catalogue (164 kB)

Recherche

Les filtres se comportent bien.

Pour le champ couv géo, certains jeux de données n'avaient pas de valeur, or ce champ est requis, j'ai donc mis "(Information manquante)".

Screenshot 2022-10-05 at 17-58-01 Rechercher un jeu de données

@johanricher
Copy link
Member Author

Merci @florimondmanca ces notes seront très utile pour documenter l'import #343

des valeurs à importer ne sont pas dans les listes (enum) implémentées dans l'outil

Arf, j'avais oublié ça. 😓 Quelle plaie ces enums. C'est seulement le champ "fréquence de mise à jour" qui est concerné par ce type de problème à l'import ou il y en a d'autres ?

A condition que le nombre de ces blocages reste exceptionnel, pour l'instant je ne vois pas de meilleure solution que ta proposition (notes ajoutées dans la description). Je vais créer des tickets pour passer les différents champs enum sous la même implé que couv geo et licence. On verra quand on pourra s'en occuper en fonction du reste et de la priorisation.

J'attends un avis de @DaFrenchFrog en tout cas en ce qui me concerne tu peux lancer l'import en prod

@johanricher
Copy link
Member Author

Pour info il y avait quelques coquilles que j'ai corrigées dans le fichier (qui du coup n'était plus valide, je sais pas ce qui s'est passé à un moment dans les dernières semaines). Ca ne règle pas le problème d'enum que tu pointes mais ça peut pas faire de mal à l'import.
Fichier mis à jour en écrasant l'ancien (catalogue_mc_20220921.csv), toujours dans le même dossier sur nextcloud.

@florimondmanca
Copy link
Collaborator

C'est seulement le champ "fréquence de mise à jour" qui est concerné par ce type de problème à l'import ou il y en a d'autres ?

Ça concerne freq_maj et formats

@DaFrenchFrog
Copy link
Collaborator

Les formats sont libres non ? On devait passer ce champ en texte simple avec suggestion des termes existant je crois (je crois aussi que je devais faire un ticket à ce propos mais il est peut-être passé à la trappe 😥 ).

Pour le champ couv géo, certains jeux de données n'avaient pas de valeur, or ce champ est requis, j'ai donc mis "(Information manquante)".

Cette solution me semble bien et cela permettra de retrouver facilement les fdd pour les corriger.

@florimondmanca
Copy link
Collaborator

@DaFrenchFrog Peut-être. En tout cas pour l'instant les maquettes correspondent à ce qu'on a implémenté. Mais je crois bien qu'on en avait parlé de temps en temps, mais que malheureusement on ne s'est jamais mis ça dans le backlog...

@DaFrenchFrog
Copy link
Collaborator

@DaFrenchFrog Peut-être. En tout cas pour l'instant les maquettes correspondent à ce qu'on a implémenté. Mais je crois bien qu'on en avait parlé de temps en temps, mais que malheureusement on ne s'est jamais mis ça dans le backlog...

J'ai créé un début de ticket ici #477

@johanricher
Copy link
Member Author

johanricher commented Oct 6, 2022

@DaFrenchFrog Super est-ce que tu peux faire une issue pour chacun des champs concernés en détaillant l'implé avec les différents changements éventuels (contribution fiche, lecture fiche et recherche) ? Sinon je m'en occuperais plus tard.

@johanricher
Copy link
Member Author

@florimondmanca Du coup on est d'accord, tu peux faire l'import (avec le fichier mis à jour si possible) cette fois en prod.

@DaFrenchFrog
Copy link
Collaborator

@johanricher Je pense que pour freq_maj on est bon c'est juste l'import qui pose problème et la solution proposée me semble bien.
Je vais détailler formats.

@johanricher
Copy link
Member Author

freq_maj n'est pas bon non plus puisque le MC a besoin de valeurs supplémentaires ("quinquennal" par exemple). Ils en voudront peut-être d'autres, sans parler d'autres organisations. Et on ne va certainement pas commencer en ajouter dans la liste enum (sinon c'est infini).

Une implé avec des "garde fous" serait acceptable par les parties prenantes, comme pour les champs qui ont déjà ce systeme.

@johanricher
Copy link
Member Author

johanricher commented Oct 6, 2022

Concernant la fréquence, si on considère que la question de garder une liste de valeurs contrôlée (enum donc) vaut la peine d'être approfondie, c'est probablement avec des arguments en faveur de l'interopérabilité et donc de l'ordre (cathédrale vs bazar). Auquel cas il faudrait à mon sens s'appuyer sur un standard international. En l'occurence DCAT fait la recommandation suivante :

The value should be taken from a controlled vocabulary such as Dublin Core Collection Description Frequency Vocabulary.

Mais "quinquennal" n'y est pas. 😆

@DaFrenchFrog
Copy link
Collaborator

Dans ce cas je pense que c'est un sujet dont il faut lui parler. Si nous sommes dans un démarche d'accompagnement je pense qu'il faut indiquer qu'à terme nous allons nous diriger vers le modèle enum pour qu'ils puissent préparer leur transition.

@johanricher
Copy link
Member Author

Concernant le champ fréquence, l'enum est déjà ce qui est implémenté : https://github.com/etalab/catalogage-donnees/blob/master/client/src/constants.ts#L30

Le problème c'est que cette liste est déjà considérée comme incomplète et le sera toujours malheureusement, même avec une liste plus complète comme celle de Dublin Core.

Moi je suis favorable à passer tous les champs en string sans contrainte d'enum mais avec une implé plus fonctionnelle qu'aujourd'hui (autocomplétion, tri par popularité, multi select quand c'est applicable, etc.).

@DaFrenchFrog
Copy link
Collaborator

DaFrenchFrog commented Oct 6, 2022

L'enum permet un meilleur contrôle lors de la recherche et d'éviter des redondance... Pourquoi ne pas suivre les recommandation DCAT ? Au moins on ne prend pas de responsabilité sur les choix qui sont fait et on est plus dans les standards ?

@johanricher
Copy link
Member Author

Pourquoi ne pas suivre les recommandation DCAT ?

Ok mais le "quinquennal" du MC on le met où ? et le "quadriennal" de l'organisation X ? et le "deux fois par semaine" de l'organisation Y ?

@DaFrenchFrog
Copy link
Collaborator

Ce sont les contraintes du standard ? 🤷‍♂️

@florimondmanca
Copy link
Collaborator

florimondmanca commented Oct 10, 2022

J'ai créé #481 pour discuter du champs "Fréquence de mise à jour".

Suite à la réu de jeudi il y a aussi désormais #477 pour le champs "Formats de données" (créé par Romain).

Je vais faire l'import en l'état sur prod aujourd'hui.

@florimondmanca
Copy link
Collaborator

@johanricher Le catalogue corrigé a été importé sur prod. https://catalogue.data.gouv.fr/

Tu peux vérifier avec le compte admin

Si OK, ce ticket pourra être fermé. :)

@florimondmanca florimondmanca moved this from Tâches en cours to Tâches en revue in Outil de catalogage de données Oct 10, 2022
@johanricher
Copy link
Member Author

C'est bon ! 🎉

Outil de catalogage de données automation moved this from Tâches en revue to Terminé Oct 10, 2022
@DaFrenchFrog
Copy link
Collaborator

@johanricher je peux envoyer un mail à RA pour vérifier qu'elle peut se connecter et consulter le catalogue ?

@johanricher
Copy link
Member Author

Oui tous les feux sont au vert pour faire la validation avec elle. Tous les critères d'acceptation doivent absolument être vérifiés avec elle sur ces 2 tickets :

Tant que la vérification n'est pas faite, on ne fermera pas les tickets (donc c'est bloquant pour la livraison).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Development

No branches or pull requests

3 participants