Une interface de saisie avec Streamlit
On a une belle base SQLite, on sait la requêter, maintenant on va lui offrir une interface digne de ce nom — sans avoir à ouvrir DB Browser à chaque fois. L'idée, c'est une petite application web qui tourne en local, avec des formulaires pour saisir du mobilier, des structures et des anomalies, et un onglet pour consulter ce qu'on a enregistré.
L'outil qu'on va utiliser pour ça, c'est Streamlit. C'est une bibliothèque Python qui permet de construire des interfaces web interactives avec très peu de code. En gros : on écrit du Python, et ça donne une page web. Pas besoin de HTML, pas besoin de JavaScript. C'est un peu magique, et ça marche vraiment bien pour ce genre d'outil de terrain.
Si vous n'avez jamais touché à Python, pas de panique. On va y aller doucement, et le code sera expliqué au fur et à mesure. Si vous avez des questions, envoyez-moi un mail (et un jour, peut-être un forum, soyons fous !).
Installation
On va avoir besoin d'installer trois choses : Python (si ce n'est pas déjà fait), Streamlit, et Pandas (une bibliothèque pour manipuler des tableaux de données). Pour créer le code, moi j'utilise VSCode, c'est l'éditeur de code de Microsoft, je sais qu'il appartient à une grosse boîte capitaliste, mais il open source et j'ai l'habitude de l'utiliser. De façon générale, c'est quand même plus simple d'utiliser une vraie interface de développement quand vous codez, donc choisissez-en simplement une qui vous plaît et on y va ! 
Python
Installez et gérez Python sur votre ordinateur. Il y a BEAUCOUP de ressources en ligne là-dessus, je vous laisse vous y retrouver comme des grand.e.s. 
Streamlit et Pandas
Ouvrez un terminal (PowerShell sur Windows, Terminal sur macOS/Linux) et tapez :
pip install streamlit pandas
Voilà, c'est tout.
(bon, sur Linux, pensez à l'environnement virtuel, sur Windows, c'est ajouter au PATH, tout ça, tout ça...).
Le principe général
Une application Streamlit, c'est un fichier Python (extension .py) qu'on lance depuis le terminal. Streamlit l'exécute et ouvre automatiquement une page dans votre navigateur. À chaque interaction (clic sur un bouton, sélection dans une liste...), le script se relance et la page se met à jour. Vous verrez, de votre côté, vous ne ferez que cliquer, mais dans le terminal, il y aura plein de commandes qui s'afficheront, vous pourrez vous la péter et draguer en bilbiothèque (après, j'ai jamais dit que j'avais une vie sentimentale épanouie
).
Créez un dossier pour votre projet, par exemple base_prospection/, et placez-y votre fichier de base SQLite prospections_foret.db. C'est dans ce dossier que vous allez créer vos fichiers Python.
Premier test avec python : afficher un message à la con avec Streamlit
Avec l'éditeur, créez un fichier coucou.py dans votre dossier et copiez-y ceci :
import streamlit as st
st.title("Prospections forestières")
st.write("Bonjour ! L'interface fonctionne <img align='absmiddle' alt=':tada:' class='emoji' src='/gitbook/gitbook-plugin-advanced-emoji/emojis/tada.png' title=':tada:' />")
Puis lancez-le depuis le terminal (en étant dans votre dossier (et après avoir activé votre environnement virtuel sous Linux)) :
streamlit run coucou.py
Votre navigateur devrait s'ouvrir tout seul sur http://localhost:8501. Si ce n'est pas le cas, copiez-collez cette adresse. Vous devriez voir votre titre et votre message. C'est votre application. :nerd_face:
Pour arrêter l'application, retournez dans le terminal et faites
Ctrl+C.
Se connecter à la base de données
On va maintenant brancher l'application sur notre base SQLite. Python sait parler à SQLite nativement (sans installation supplémentaire), via le module sqlite3. On va aussi utiliser pandas pour transformer les résultats des requêtes en tableaux lisibles.
N'oubliez pas, le GeoPackage, c'est un conteneur SQLite, donc vous pouvez directement pointer un fichier .gpkg si vous utilisez le module
sqlite3.Et si c'est un format classique de base de données SQLite, pointez le fichier .db ou .sqlite,...
Créez prospections.py pour commencer à construire quelque chose d'utile (enfin de plus utile, après tout, ça peut aussi être utile d'afficher des mots à la con, je ne juge (presque) pas
) :
import streamlit as st
import sqlite3
import pandas as pd
# Le chemin vers votre base de données
# Si prospections.py et prospections_foret.db sont dans le même dossier, c'est bon comme ça
DB_PATH = "prospections_foret.gpkg"
#si votre base est un GeoPackage, la ligne sera : DB_PATH = "prospections_foret.gpkg"
def connexion():
"""Ouvre une connexion à la base de données."""
conn = sqlite3.connect(DB_PATH)
conn.enable_load_extension(True)
conn.load_extension("mod_spatialite")
return conn
st.title("Prospections forestières")
# Test : on affiche le contenu de T_zones
conn = connexion()
df_zones = pd.read_sql("SELECT * FROM T_zones", conn)
conn.close()
st.dataframe(df_zones)
Pourquoi SpatiaLite ? Quand vous faites un
INSERTdans une table d'un GeoPackage, des déclencheurs automatiques (« triggers ») s'activent pour valider les géométries — même si vous n'insérez pas de coordonnées. Ces triggers font appel à des fonctions spatiales commeST_IsEmpty(), qui n'existent pas dans SQLite de base. Sans SpatiaLite chargé, vous obtenez l'erreurno such function: ST_IsEmpty. Les deux lignesenable_load_extensionetload_extensionrèglent ça.Sur Linux, SpatiaLite s'installe avec
sudo apt install libsqlite3-mod-spatialite. Sur macOS via Homebrew :brew install libspatialite. Sur Windows, il faut télécharger la DLL sur le site de SpatiaLite et indiquer son chemin complet à la place de"mod_spatialite".
Relancez (ou laissez Streamlit repérer les changements, il détecte les modifications du fichier et vous propose le rafraichissement de la page) : vous devriez voir un tableau avec vos zones. 
Là, on a utilisé une commande SQL simple, SELECT avec * pour dire qu'on prend tout (et pas des colonnes spécifiques), FROM (depuis) la table T_zones. Ça affiche bêtement un tableau, rien de très excitant jusqu'ici, j'en conviens... Mais on y va petit pas par petit pas. :footprints:
Charger les listes de valeurs
Nos tables L_ (les listes de valeurs) vont alimenter les menus déroulants des formulaires. On va créer une petite fonction utilitaire pour ça, qu'on pourra réutiliser pour toutes les listes :
def charger_liste(table, col_id, col_label):
"""
Charge une table de liste de valeurs et renvoie un dictionnaire
{ label_affiché : id_en_base }.
"""
conn = connexion()
df = pd.read_sql(f"SELECT {col_id}, {col_label} FROM {table}", conn)
conn.close()
# On crée un dictionnaire { "Céramique": 1, "Métal": 2, ... }
return dict(zip(df[col_label], df[col_id]))
Cette fonction prend le nom d'une table, le nom de la colonne identifiant et le nom de la colonne libellé. Elle renvoie un dictionnaire qui fait le lien entre ce qu'on affiche à l'écran (le libellé) et ce qu'on enregistre en base (l'identifiant numérique). C'est ce qu'on passera ensuite aux selectbox de Streamlit. J'ai sorti plein de termes techniques pour me la raconter, mais pas seulement. Je l'ai écrit plus haut (enfin je crois
), c'est bien d'avoir suivi une petite formation Python pour comprendre ce que sont les fonctions ou les dictionnaires.
Organiser avec des onglets
On va structurer l'interface avec deux onglets : un pour la saisie, un pour la consultation. Streamlit propose st.tabs() pour ça :
onglet_saisie, onglet_consultation = st.tabs(["📝 Saisie", "🔍 Consultation"])
# On appelle deux objets dont le nom dans le code sont `onglet_saisie` et `onglet_consultation`, on leut attribue la fonction `tabs` suivie de leurs étiquettes en option. C'est seulement ensuite qu'on va vraiment définir le contenu des onglets.
with onglet_saisie:
st.header("Enregistrer un mobilier")
# ... le formulaire ira ici
with onglet_consultation:
st.header("Consulter les données")
# ... les tableaux iront ici
Les deux blocs with définissent ce qui s'affiche dans chaque onglet. Simple (basique (ouais, tout ça)).
Le formulaire de saisie du mobilier
On va construire le formulaire de saisie pour T_mobilier. On utilise st.form() pour regrouper tous les champs et n'envoyer les données en base qu'au clic sur le bouton "Enregistrer" (sinon Streamlit re-exécute tout le script à chaque interaction, ce qui serait un peu pénible
).
Ah, et on se souvient de ce qu'on a mis juste avant, on remplit les onglets (les with onglet_machin_truc)...
with onglet_saisie:
st.header("Enregistrer un mobilier")
# On charge les listes de valeurs
natures_mob = charger_liste("L_natures_mob", "id", "nature_mob")
zones = charger_liste("T_zones", "fid", "nom") # Les tables créées avec la création de couche GeoPackage dans QGIS ont une clé primaire créée automatiquement et nommée `fid
auteurices = charger_liste("L_auteurices", "id", "auteurice")
contextes = charger_liste("L_contextes", "id", "contexte")
# là on appel des valeurs comme en base de données classique, la table, la valeur stockée (donc en général la clé primaire) et la valeur qui sera affichée
with st.form("form_mobilier", clear_on_submit=True):
identifiant = st.text_input("Identifiant (ex: MOB-042)")
zone = st.selectbox("Zone", options=list(zones.keys()))
nature = st.selectbox("Nature du mobilier", options=list(natures_mob.keys()))
contexte = st.selectbox("Contexte de découverte", options=list(contextes.keys()))
auteurice = st.selectbox("Auteur·ice", options=list(auteurices.keys()))
commentaires = st.text_area("Commentaires")
# Ici, on crée les petites zones de saisie, on met leur étiquette puis la fonction spécifique (`texte_input` pour écrire simplement ou `selectbox` pour un menu déroulant avec sélection d'un terme), entre parenthèses on peux mettre une petite indication (le truc grisé) qui s'affiche avant qu'on commence à remplir une zone et ensuite, on charge une des listes définies au-dessus.
enregistrer = st.form_submit_button("Enregistrer")
if enregistrer:
if not identifiant:
st.error("L'identifiant ne peut pas être vide !")
else:
conn = connexion()
conn.execute("""
INSERT INTO T_mobilier
(identifiant, zone, nature, contexte_sol, auteurice, commentaires,
date_creation, date_modification)
VALUES (?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))
""", (
identifiant,
zones[zone],
natures_mob[nature],
contextes[contexte],
auteurices[auteurice],
commentaires
))
conn.commit()
conn.close()
st.success(f"Mobilier **{identifiant}** enregistré ! <img align='absmiddle' alt=':white_check_mark:' class='emoji' src='/gitbook/gitbook-plugin-advanced-emoji/emojis/white_check_mark.png' title=':white_check_mark:' />")
Quelques explications sur les fonctions de streamlit :
st.text_input(): champ texte librest.selectbox(): liste déroulante. On lui passe les clés du dictionnaire (les libellés) ; quand on récupère la valeur sélectionnée, on faitzones[zone]pour obtenir l'identifiant numérique correspondantst.text_area(): zone de texte multilignest.form_submit_button(): le bouton d'envoi du formulaireclear_on_submit=True: vide les champs après envoi, pratique pour enchaîner les saisies- Les
?dans la requête SQL sont des paramètres : on ne concatène jamais des valeurs directement dans une requête (risque d'injection SQL), on les passe séparément via le tuple en deuxième argument. C'est la bonne pratique, et SQLite s'occupe de tout.
Le bloc
if enregistrer:est à l'extérieur duwith st.form(...). C'est important : c'est là que se passe l'action après le clic sur le bouton.
L'onglet de consultation
Pour consulter les données, on va afficher un tableau avec les jointures pour rendre les identifiants lisibles — exactement comme les requêtes SQL qu'on a vues dans le chapitre précédent.
with onglet_consultation:
st.header("Mobilier enregistré")
conn = connexion()
df_mobilier = pd.read_sql("""
SELECT
m.identifiant,
z.nom AS zone,
n.nature_mob AS nature,
c.contexte AS contexte,
a.auteurice AS auteurice,
m.date_creation,
m.commentaires
FROM T_mobilier m
LEFT JOIN T_zones z ON m.zone = z.id
LEFT JOIN L_natures_mob n ON m.nature = n.id
LEFT JOIN L_contextes c ON m.contexte_sol = c.id
LEFT JOIN L_auteurices a ON m.auteurice = a.id
ORDER BY m.date_creation DESC
""", conn)
conn.close()
# Les `left join`, c'est classique en SQL, pour afficher le terme correspondant à la clé primaire enregistrée dans la table que nous sommes en train de consulter.
# Et si la table est encore vide, on le prévoit et on renvoit un message pour éviter les erreurs et confusions.
if df_mobilier.empty:
st.info("Aucun mobilier enregistré pour l'instant.")
else:
st.dataframe(df_mobilier, use_container_width=True)
st.dataframe() affiche un tableau interactif : on peut trier par colonne, redimensionner, et même rechercher dedans. use_container_width=True lui dit d'occuper toute la largeur disponible.
Ajouter les structures et les anomalies
On suit le même principe pour T_structures et T_anomalies. On peut imbriquer des onglets dans des onglets — ou utiliser st.radio() pour choisir le type à saisir. On va recréer l'onglet de saisie (donc on remplace l'enregistrement de mobilier), en mettant en sous-onglet les structures, les anomalies, mais aussi le mobilier :
with onglet_saisie:
sous_onglet_mob, sous_onglet_str, sous_onglet_ano = st.tabs([
"Mobilier", "Structure", "Anomalie"
])
with sous_onglet_mob:
# ... formulaire mobilier (voir ci-dessus) --> C'est ici qu'on va remettre les infos liées au mobilier qu'on avait mis dans le premier `onglet_saisie`
with sous_onglet_str:
natures_str = charger_liste("L_natures_str", "id", "nom_nature_str")
etats = charger_liste("L_etats", "id", "nom_etat")
zones = charger_liste("T_zones", "fid", "nom")
auteurices = charger_liste("L_auteurices", "id", "auteurice")
with st.form("form_structure", clear_on_submit=True):
identifiant = st.text_input("Identifiant (ex: STR-007)")
zone = st.selectbox("Zone", options=list(zones.keys()))
nature = st.selectbox("Nature de la structure", options=list(natures_str.keys()))
etat = st.selectbox("État de conservation", options=list(etats.keys()))
auteurice = st.selectbox("Auteur·ice", options=list(auteurices.keys()))
commentaires = st.text_area("Commentaires")
enregistrer = st.form_submit_button("Enregistrer")
if enregistrer:
if not identifiant:
st.error("L'identifiant ne peut pas être vide !")
else:
conn = connexion()
conn.execute("""
INSERT INTO T_structures
(nom_structure, zone, nature, etat, auteurice, commentaires,
date_creation, date_modification)
VALUES (?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))
""", (
identifiant,
zones[zone],
natures_str[nature],
etats[etat],
auteurices[auteurice],
commentaires
))
conn.commit()
conn.close()
st.success(f"Structure **{identifiant}** enregistrée !")
with sous_onglet_ano:
zones = charger_liste("T_zones", "fid", "nom")
auteurices = charger_liste("L_auteurices", "id", "auteurice")
with st.form("form_anomalie", clear_on_submit=True):
zone = st.selectbox("Zone", options=list(zones.keys()))
auteurice = st.selectbox("Auteur·ice", options=list(auteurices.keys()))
description = st.text_area("Description")
enregistrer = st.form_submit_button("Enregistrer")
if enregistrer:
conn = connexion()
conn.execute("""
INSERT INTO T_anomalies
(zone, auteurice, description, date_creation, date_modification)
VALUES (?, ?, ?, datetime('now'), datetime('now'))
""", (zones[zone], auteurices[auteurice], description))
conn.commit()
conn.close()
st.success("Anomalie enregistrée !")
Le fichier complet
Voici le fichier prospections_foret.py complet, prêt à l'emploi. Copiez-le dans votre dossier à côté de prospections_foret.db.
import streamlit as st
import sqlite3
import pandas as pd
DB_PATH = "prospections_forets.gpkg"
def connexion():
conn = sqlite3.connect(DB_PATH)
conn.enable_load_extension(True)
conn.load_extension("mod_spatialite")
return conn
def charger_liste(table, col_id, col_label):
conn = connexion()
df = pd.read_sql(f"SELECT {col_id}, {col_label} FROM {table}", conn)
conn.close()
return dict(zip(df[col_label], df[col_id]))
# ─── Interface ───────────────────────────────────────────────────────────────
st.title("Prospections forestières")
onglet_saisie, onglet_consultation = st.tabs(["📝 Saisie", "🔍 Consultation"])
# ─── Saisie ──────────────────────────────────────────────────────────────────
with onglet_saisie:
sous_onglet_mob, sous_onglet_str, sous_onglet_ano = st.tabs([
"Mobilier", "Structure", "Anomalie"
])
# -- Mobilier --
with sous_onglet_mob:
st.header("Nouveau mobilier")
natures_mob = charger_liste("L_natures_mob", "id", "nom_nature_mob")
zones = charger_liste("T_zones", "fid", "nom")
auteurices = charger_liste("L_auteurices", "id", "auteurice")
contextes = charger_liste("L_contextes", "id", "nom_contexte")
with st.form("form_mobilier", clear_on_submit=True):
identifiant = st.text_input("Identifiant (ex: MOB-042)")
zone = st.selectbox("Zone", options=list(zones.keys()))
nature = st.selectbox("Nature du mobilier", options=list(natures_mob.keys()))
contexte = st.selectbox("Contexte de découverte", options=list(contextes.keys()))
auteurice = st.selectbox("Auteur·ice", options=list(auteurices.keys()))
commentaires = st.text_area("Commentaires")
enregistrer = st.form_submit_button("Enregistrer")
if enregistrer:
if not identifiant:
st.error("L'identifiant ne peut pas être vide !")
else:
conn = connexion()
conn.execute("""
INSERT INTO T_mobilier
(identifiant, zone, nature, contexte_sol, auteurice, commentaires,
date_creation, date_modification)
VALUES (?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))
""", (identifiant, zones[zone], natures_mob[nature],
contextes[contexte], auteurices[auteurice], commentaires))
conn.commit()
conn.close()
st.success(f"Mobilier **{identifiant}** enregistré !")
# -- Structures --
with sous_onglet_str:
st.header("Nouvelle structure")
natures_str = charger_liste("L_natures_str", "id", "nom_nature_str")
etats = charger_liste("L_etats", "id", "nom_etat")
zones = charger_liste("T_zones", "fid", "nom")
auteurices = charger_liste("L_auteurices", "id", "auteurice")
with st.form("form_structure", clear_on_submit=True):
identifiant = st.text_input("Identifiant (ex: STR-007)")
zone = st.selectbox("Zone", options=list(zones.keys()))
nature = st.selectbox("Nature de la structure", options=list(natures_str.keys()))
etat = st.selectbox("État de conservation", options=list(etats.keys()))
auteurice = st.selectbox("Auteur·ice", options=list(auteurices.keys()))
commentaires = st.text_area("Commentaires")
enregistrer = st.form_submit_button("Enregistrer")
if enregistrer:
if not identifiant:
st.error("L'identifiant ne peut pas être vide !")
else:
conn = connexion()
conn.execute("""
INSERT INTO T_structures
(nom_structure, zone, nature, etat, auteurice, commentaires,
date_creation, date_modification)
VALUES (?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))
""", (identifiant, zones[zone], natures_str[nature],
etats[etat], auteurices[auteurice], commentaires))
conn.commit()
conn.close()
st.success(f"Structure **{identifiant}** enregistrée !")
# -- Anomalies --
with sous_onglet_ano:
st.header("Nouvelle anomalie")
zones = charger_liste("T_zones", "fid", "nom")
auteurices = charger_liste("L_auteurices", "id", "auteurice")
with st.form("form_anomalie", clear_on_submit=True):
zone = st.selectbox("Zone", options=list(zones.keys()))
auteurice = st.selectbox("Auteur·ice", options=list(auteurices.keys()))
description = st.text_area("Description")
enregistrer = st.form_submit_button("Enregistrer")
if enregistrer:
conn = connexion()
conn.execute("""
INSERT INTO T_anomalies
(zone, auteurice, description, date_creation, date_modification)
VALUES (?, ?, ?, datetime('now'), datetime('now'))
""", (zones[zone], auteurices[auteurice], description))
conn.commit()
conn.close()
st.success("Anomalie enregistrée !")
# ─── Consultation ─────────────────────────────────────────────────────────────
with onglet_consultation:
vue_mob, vue_str, vue_ano = st.tabs(["Mobilier", "Structures", "Anomalies"])
with vue_mob:
st.header("Mobilier enregistré")
conn = connexion()
df = pd.read_sql("""
SELECT m.nom_mobilier, z.nom AS zone, n.nom_nature_mob AS nature,
c.nom_contexte, a.auteurice, m.date_creation, m.commentaires
FROM T_mobilier m
LEFT JOIN T_zones z ON m.zone = z.fid
LEFT JOIN L_natures_mob n ON m.nature = n.id
LEFT JOIN L_contextes c ON m.contexte_sol = c.id
LEFT JOIN L_auteurices a ON m.auteurice = a.id
ORDER BY m.date_creation DESC
""", conn)
conn.close()
if df.empty:
st.info("Aucun mobilier enregistré pour l'instant.")
else:
st.dataframe(df, use_container_width=True)
with vue_str:
st.header("Structures enregistrées")
conn = connexion()
df = pd.read_sql("""
SELECT s.nom_structure, z.nom AS zone, n.nom_nature_str AS nature,
e.nom_etat, a.auteurice, s.date_creation, s.commentaires
FROM T_structures s
LEFT JOIN T_zones z ON s.zone = z.fid
LEFT JOIN L_natures_str n ON s.nature = n.id
LEFT JOIN L_etats e ON s.etat = e.id
LEFT JOIN L_auteurices a ON s.auteurice = a.id
ORDER BY s.date_creation DESC
""", conn)
conn.close()
if df.empty:
st.info("Aucune structure enregistrée pour l'instant.")
else:
st.dataframe(df, use_container_width=True)
with vue_ano:
st.header("Anomalies enregistrées")
conn = connexion()
df = pd.read_sql("""
SELECT t.fid, z.nom AS zone, a.auteurice,
t.date_creation, t.description
FROM T_anomalies t
LEFT JOIN T_zones z ON t.zone = z.fid
LEFT JOIN L_auteurices a ON t.auteurice = a.id
ORDER BY t.date_creation DESC
""", conn)
conn.close()
if df.empty:
st.info("Aucune anomalie enregistrée pour l'instant.")
else:
st.dataframe(df, use_container_width=True)
Lancer l'application
streamlit run prospections_foret.py
Et voilà.
Votre navigateur s'ouvre, vous avez vos formulaires avec les menus déroulants qui lisent les tables L_, et vos tableaux de consultation avec les jointures déjà faites. Les données enregistrées ici atterrissent directement dans prospections_foret.gpkg — vous pouvez vérifier dans DB Browser ou QGIS si ça vous rassure.
Je sais que ça fait beaucoup de boulot, mais ça claque et c'est déjà pas mal (et comme ça, vous pouvez montrer ça à votre direction pour faire genre vous travaillez d'arrache pied sur votre thèse ou mémoire, même si on sait très bien que parfois, bon...
). 
L'application tourne entièrement en local. Rien ne sort de votre machine :robot:. Si vous voulez la rendre accessible à toute l'équipe sur un réseau local (genre, tout le monde sur le même WiFi de dans la maison de fouilles), c'est possible : lancez avec
streamlit run prospection.py --server.address 0.0.0.0et communiquez votre adresse IP locale aux autres. Mais attention : dans ce cas, la base de données doit être sur une machine accessible par tous, et les écritures simultanées peuvent poser des problèmes. Je ferai (ou j'ai fait, ça dépend vraiment de quand vous lisez ce support, si j'ai pensé à faire toutes les corrections...) un support sur héberger une base de données en local avec serveur de stockage (genre NAS) sur Raspberry Pi dans un réseau local et là, l'interface Streamlit, c'est pas mal adapté, même si j'aime bien aussi (enfin en vrai je préfère) SQLpage.
Ressources
- Documentation Streamlit : https://docs.streamlit.io — très bien faite, avec plein d'exemples interactifs
- Galerie d'applications : https://streamlit.io/gallery — pour voir ce que d'autres ont construit, et s'inspirer
Si vous n'avez jamais touché à Python, pas de panique. On va y aller doucement, et le code sera expliqué au fur et à mesure. Si vous avez des questions, envoyez-moi un mail (et un jour, peut-être un forum, soyons fous !).
Pourquoi SpatiaLite ? Quand vous faites un
) un support sur héberger une base de données en local avec serveur de stockage (genre NAS) sur Raspberry Pi dans un réseau local et là, l'interface Streamlit, c'est pas mal adapté, même si j'aime bien aussi (enfin en vrai je préfère) SQLpage.