Préparation à l'internationalisation

This commit is contained in:
Fabrication du Numérique 2025-05-13 16:31:53 +02:00
parent 96682783b6
commit 059e94b0f3
22 changed files with 1031 additions and 306 deletions

View File

@ -1,4 +1,5 @@
import streamlit as st
from utils.translations import _
from .sankey import afficher_sankey
@ -30,11 +31,11 @@ def preparer_graphe(G):
def selectionner_niveaux():
"""Interface pour sélectionner les niveaux de départ et d'arrivée."""
st.markdown("## Sélection des nœuds de départ et d'arrivée")
valeur_defaut = "-- Sélectionner un niveau --"
st.markdown(f"## {str(_('pages.analyse.selection_nodes', 'Sélection des nœuds de départ et d\'arrivée'))}")
valeur_defaut = str(_("pages.analyse.select_level", "-- Sélectionner un niveau --"))
niveau_choix = [valeur_defaut] + list(niveau_labels.values())
niveau_depart = st.selectbox("Niveau de départ", niveau_choix, key="analyse_niveau_depart")
niveau_depart = st.selectbox(str(_("pages.analyse.start_level", "Niveau de départ")), niveau_choix, key="analyse_niveau_depart")
if niveau_depart == valeur_defaut:
return None, None
@ -42,7 +43,7 @@ def selectionner_niveaux():
niveaux_arrivee_possibles = [v for k, v in niveau_labels.items() if k > niveau_depart_int]
niveaux_arrivee_choix = [valeur_defaut] + niveaux_arrivee_possibles
analyse_niveau_arrivee = st.selectbox("Niveau d'arrivée", niveaux_arrivee_choix, key="analyse_niveau_arrivee")
analyse_niveau_arrivee = st.selectbox(str(_("pages.analyse.end_level", "Niveau d'arrivée")), niveaux_arrivee_choix, key="analyse_niveau_arrivee")
if analyse_niveau_arrivee == valeur_defaut:
return niveau_depart_int, None
@ -54,7 +55,7 @@ def selectionner_minerais(G, niveau_depart, niveau_arrivee):
"""Interface pour sélectionner les minerais si nécessaire."""
minerais_selection = None
if niveau_depart < 2 < niveau_arrivee:
st.markdown("### Sélectionner un ou plusieurs minerais")
st.markdown(f"### {str(_('pages.analyse.select_minerals', 'Sélectionner un ou plusieurs minerais'))}")
# Tous les nœuds de niveau 2 (minerai)
minerais_nodes = sorted([
n for n, d in G.nodes(data=True)
@ -62,7 +63,7 @@ def selectionner_minerais(G, niveau_depart, niveau_arrivee):
])
minerais_selection = st.multiselect(
"Filtrer par minerais (optionnel)",
str(_("pages.analyse.filter_by_minerals", "Filtrer par minerais (optionnel)")),
minerais_nodes,
key="analyse_minerais"
)
@ -73,15 +74,15 @@ def selectionner_minerais(G, niveau_depart, niveau_arrivee):
def selectionner_noeuds(G, niveaux_temp, niveau_depart, niveau_arrivee):
"""Interface pour sélectionner les nœuds spécifiques de départ et d'arrivée."""
st.markdown("---")
st.markdown("## Sélection fine des items")
st.markdown(f"## {str(_('pages.analyse.fine_selection', 'Sélection fine des items'))}")
depart_nodes = [n for n in G.nodes() if niveaux_temp.get(n) == niveau_depart]
arrivee_nodes = [n for n in G.nodes() if niveaux_temp.get(n) == niveau_arrivee]
noeuds_depart = st.multiselect("Filtrer par noeuds de départ (optionnel)",
noeuds_depart = st.multiselect(str(_("pages.analyse.filter_start_nodes", "Filtrer par noeuds de départ (optionnel)")),
sorted(depart_nodes),
key="analyse_noeuds_depart")
noeuds_arrivee = st.multiselect("Filtrer par noeuds d'arrivée (optionnel)",
noeuds_arrivee = st.multiselect(str(_("pages.analyse.filter_end_nodes", "Filtrer par noeuds d'arrivée (optionnel)")),
sorted(arrivee_nodes),
key="analyse_noeuds_arrivee")
@ -94,26 +95,26 @@ def selectionner_noeuds(G, niveaux_temp, niveau_depart, niveau_arrivee):
def configurer_filtres_vulnerabilite():
"""Interface pour configurer les filtres de vulnérabilité."""
st.markdown("---")
st.markdown("## Sélection des filtres pour identifier les vulnérabilités")
st.markdown(f"## {str(_('pages.analyse.vulnerability_filters', 'Sélection des filtres pour identifier les vulnérabilités'))}")
filtrer_ics = st.checkbox("Filtrer les chemins contenant au moins minerai critique pour un composant (ICS > 66 %)",
filtrer_ics = st.checkbox(str(_("pages.analyse.filter_ics", "Filtrer les chemins contenant au moins minerai critique pour un composant (ICS > 66 %)")),
key="analyse_filtrer_ics")
filtrer_ivc = st.checkbox("Filtrer les chemins contenant au moins un minerai critique par rapport à la concurrence sectorielle (IVC > 30)",
filtrer_ivc = st.checkbox(str(_("pages.analyse.filter_ivc", "Filtrer les chemins contenant au moins un minerai critique par rapport à la concurrence sectorielle (IVC > 30)")),
key="analyse_filtrer_ivc")
filtrer_ihh = st.checkbox("Filtrer les chemins contenant au moins une opération critique par rapport à la concentration géographique ou industrielle (IHH pays ou acteurs > 25)",
filtrer_ihh = st.checkbox(str(_("pages.analyse.filter_ihh", "Filtrer les chemins contenant au moins une opération critique par rapport à la concentration géographique ou industrielle (IHH pays ou acteurs > 25)")),
key="analyse_filtrer_ihh")
ihh_type = "Pays"
if filtrer_ihh:
ihh_type = st.radio("Appliquer le filtre IHH sur :",
["Pays", "Acteurs"],
ihh_type = st.radio(str(_("pages.analyse.apply_ihh_filter", "Appliquer le filtre IHH sur :")),
[str(_("pages.analyse.countries", "Pays")), str(_("pages.analyse.actors", "Acteurs"))],
horizontal=True,
key="analyse_ihh_type")
filtrer_isg = st.checkbox("Filtrer les chemins contenant un pays instable (ISG ≥ 60)",
filtrer_isg = st.checkbox(str(_("pages.analyse.filter_isg", "Filtrer les chemins contenant un pays instable (ISG ≥ 60)")),
key="analyse_filtrer_isg")
logique_filtrage = st.radio("Logique de filtrage",
["OU", "ET"],
logique_filtrage = st.radio(str(_("pages.analyse.filter_logic", "Logique de filtrage")),
[str(_("pages.analyse.or", "OU")), str(_("pages.analyse.and", "ET"))],
horizontal=True,
key="analyse_logique_filtrage")
@ -121,16 +122,16 @@ def configurer_filtres_vulnerabilite():
def interface_analyse(G_temp):
st.markdown("# Analyse du graphe")
with st.expander("Comment utiliser cet onglet ?", expanded=False):
st.markdown("""
1. Sélectionnez le niveau de départ (produit final, composant ou minerai)
2. Choisissez le niveau d'arrivée souhaité
3. Affinez votre sélection en spécifiant soit un ou des minerais à cibler spécifiquement ou des items précis à chaque niveau (optionnel)
4. Définissez les critères d'analyse en sélectionnant les indices de vulnérabilité pertinents
5. Choisissez le mode de combinaison des indices (ET/OU) selon votre besoin d'analyse
6. Explorez le graphique généré en utilisant les contrôles de zoom et de déplacement ; vous pouvez basculer en mode plein écran pour le graphe
""")
st.markdown(f"# {str(_('pages.analyse.title', 'Analyse du graphe'))}")
with st.expander(str(_("pages.analyse.help", "Comment utiliser cet onglet ?")), expanded=False):
st.markdown("\n".join(_("pages.analyse.help_content", [
"1. Sélectionnez le niveau de départ (produit final, composant ou minerai)",
"2. Choisissez le niveau d'arrivée souhaité",
"3. Affinez votre sélection en spécifiant soit un ou des minerais à cibler spécifiquement ou des items précis à chaque niveau (optionnel)",
"4. Définissez les critères d'analyse en sélectionnant les indices de vulnérabilité pertinents",
"5. Choisissez le mode de combinaison des indices (ET/OU) selon votre besoin d'analyse",
"6. Explorez le graphique généré en utilisant les contrôles de zoom et de déplacement ; vous pouvez basculer en mode plein écran pour le graphe"
])))
st.markdown("---")
try:
@ -154,7 +155,7 @@ def interface_analyse(G_temp):
# Lancement de l'analyse
st.markdown("---")
if st.button("Lancer l'analyse", type="primary", key="analyse_lancer"):
if st.button(str(_("pages.analyse.run_analysis", "Lancer l'analyse")), type="primary", key="analyse_lancer"):
afficher_sankey(
G_temp,
niveau_depart=niveau_depart,
@ -171,4 +172,4 @@ def interface_analyse(G_temp):
)
except Exception as e:
st.error(f"Erreur de prévisualisation du graphe : {e}")
st.error(f"{str(_('errors.graph_preview_error', 'Erreur de prévisualisation du graphe :'))} {e}")

View File

@ -5,6 +5,7 @@ import plotly.graph_objects as go
import networkx as nx
import logging
import tempfile
from utils.translations import _
from utils.graph_utils import (
extraire_chemins_depuis,
@ -188,11 +189,11 @@ def edge_info(G, u, v):
"""Génère l'info-bulle pour un lien"""
data = G.get_edge_data(u, v)
if not data:
return f"Relation : {u}{v}"
return f"{str(_('pages.analyse.sankey.relation', 'Relation'))} : {u}{v}"
if isinstance(data, dict) and all(isinstance(k, int) for k in data):
data = data[0]
base = [f"{k}: {v}" for k, v in data.items()]
return f"Relation : {u}{v}<br>" + "<br>".join(base)
return f"{str(_('pages.analyse.sankey.relation', 'Relation'))} : {u}{v}<br>" + "<br>".join(base)
def preparer_donnees_sankey(G, liens_chemins, niveaux, chemins):
"""Prépare les données pour le graphique Sankey"""
@ -272,7 +273,7 @@ def creer_graphique_sankey(G, niveaux, df_liens, sorted_nodes, customdata, link_
))
fig.update_layout(
title_text="Hiérarchie filtrée par niveaux et noeuds",
title_text=str(_("pages.analyse.sankey.filtered_hierarchy", "Hiérarchie filtrée par niveaux et noeuds")),
paper_bgcolor="white",
plot_bgcolor="white"
)
@ -302,7 +303,7 @@ def exporter_graphe_filtre(G, liens_chemins):
with open(dot_path, encoding="utf-8") as f:
st.download_button(
label="Télécharger le fichier DOT filtré",
label=str(_("pages.analyse.sankey.download_dot", "Télécharger le fichier DOT filtré")),
data=f.read(),
file_name="graphe_filtré.dot",
mime="text/plain"
@ -324,7 +325,7 @@ def afficher_sankey(
chemins = extraire_chemins_selon_criteres(G, niveaux, niveau_depart, noeuds_depart, noeuds_arrivee, minerais)
if not chemins:
st.warning("Aucun chemin trouvé pour les critères spécifiés.")
st.warning(str(_("pages.analyse.sankey.no_paths", "Aucun chemin trouvé pour les critères spécifiés.")))
return
# Étape 3 : Filtrage des chemins selon les critères de vulnérabilité
@ -334,7 +335,7 @@ def afficher_sankey(
)
if not liens_chemins:
st.warning("Aucun chemin ne correspond aux critères.")
st.warning(str(_("pages.analyse.sankey.no_matching_paths", "Aucun chemin ne correspond aux critères.")))
return
# Étape 4 : Préparation des données pour le graphique Sankey

View File

@ -3,6 +3,7 @@ import streamlit as st
import requests
import os
import pathlib
from utils.translations import _
from .utils.tickets.display import afficher_tickets_par_fiche
from .utils.tickets.creation import formulaire_creation_ticket_dynamique
@ -17,21 +18,23 @@ from .utils.fiche_utils import load_seuils, doit_regenerer_fiche
from .generer import generer_fiche
def interface_fiches():
st.markdown("# Découverte des fiches")
with st.expander("Comment utiliser cet onglet ?", expanded=False):
st.markdown("""
1. Parcourez la liste des fiches disponibles par catégorie
2. Sélectionnez une fiche pour afficher son contenu complet
3. Consultez les données détaillées, graphiques et analyses supplémentaires
4. Utilisez ces informations pour approfondir votre compréhension des vulnérabilités identifiées
Les catégories sont les suivantes :
* Assemblage : opération d'assemblage des produits finaux à partir des composants
* Connexe : opérations diverses nécessaires pour fabriquer le numérique, mais n'entrant pas directement dans sa composition
* Criticités : indices utilisés pour identifier et évaluer les vulnérabilités
* Fabrication : opération de fabrication des composants à partir de minerais
* Minerai : description et opérations d'extraction et de traitement des minerais
""")
st.markdown(f"# {str(_('pages.fiches.title', 'Découverte des fiches'))}")
with st.expander(str(_("pages.fiches.help", "Comment utiliser cet onglet ?")), expanded=False):
st.markdown("\n".join([
" " + line for line in _("pages.fiches.help_content", [
"1. Parcourez la liste des fiches disponibles par catégorie",
"2. Sélectionnez une fiche pour afficher son contenu complet",
"3. Consultez les données détaillées, graphiques et analyses supplémentaires",
"4. Utilisez ces informations pour approfondir votre compréhension des vulnérabilités identifiées",
"",
"Les catégories sont les suivantes :",
"* Assemblage : opération d'assemblage des produits finaux à partir des composants",
"* Connexe : opérations diverses nécessaires pour fabriquer le numérique, mais n'entrant pas directement dans sa composition",
"* Criticités : indices utilisés pour identifier et évaluer les vulnérabilités",
"* Fabrication : opération de fabrication des composants à partir de minerais",
"* Minerai : description et opérations d'extraction et de traitement des minerais"
])
]))
st.markdown("---")
if "fiches_arbo" not in st.session_state:
@ -39,18 +42,24 @@ def interface_fiches():
arbo = st.session_state.get("fiches_arbo", {})
if not arbo:
st.warning("Aucune fiche disponible pour le moment.")
st.warning(str(_("pages.fiches.no_files", "Aucune fiche disponible pour le moment.")))
return
dossiers = sorted(arbo.keys(), key=lambda x: x.lower())
dossier_choisi = st.selectbox("Choisissez une catégorie de fiches", ["-- Sélectionner un dossier --"] + dossiers)
dossier_choisi = st.selectbox(
str(_("pages.fiches.choose_category", "Choisissez une catégorie de fiches")),
[str(_("pages.fiches.select_folder", "-- Sélectionner un dossier --"))] + dossiers
)
if dossier_choisi and dossier_choisi != "-- Sélectionner un dossier --":
if dossier_choisi and dossier_choisi != str(_("pages.fiches.select_folder", "-- Sélectionner un dossier --")):
fiches = arbo.get(dossier_choisi, [])
noms_fiches = [f['nom'] for f in fiches]
fiche_choisie = st.selectbox("Choisissez une fiche", ["-- Sélectionner une fiche --"] + noms_fiches)
fiche_choisie = st.selectbox(
str(_("pages.fiches.choose_file", "Choisissez une fiche")),
[str(_("pages.fiches.select_file", "-- Sélectionner une fiche --"))] + noms_fiches
)
if fiche_choisie and fiche_choisie != "-- Sélectionner une fiche --":
if fiche_choisie and fiche_choisie != str(_("pages.fiches.select_file", "-- Sélectionner une fiche --")):
fiche_info = next((f for f in fiches if f["nom"] == fiche_choisie), None)
if fiche_info:
try:
@ -92,19 +101,19 @@ def interface_fiches():
if os.path.exists(pdf_path):
with open(pdf_path, "rb") as pdf_file:
st.download_button(
label="Télécharger cette fiche en PDF",
label=str(_("pages.fiches.download_pdf", "Télécharger cette fiche en PDF")),
data=pdf_file,
file_name=pdf_name,
mime="application/pdf",
help="Télécharger la version PDF de cette fiche",
help=str(_("pages.fiches.download_pdf", "Télécharger cette fiche en PDF")),
key="telecharger_fiche_pdf"
)
else:
st.warning("Le fichier PDF de cette fiche n'est pas disponible.")
st.warning(str(_("pages.fiches.pdf_unavailable", "Le fichier PDF de cette fiche n'est pas disponible.")))
st.markdown("## Gestion des tickets pour cette fiche")
st.markdown(f"## {str(_('pages.fiches.ticket_management', 'Gestion des tickets pour cette fiche'))}")
afficher_tickets_par_fiche(rechercher_tickets_gitea(fiche_choisie))
formulaire_creation_ticket_dynamique(fiche_choisie)
except Exception as e:
st.error(f"Erreur lors du chargement de la fiche : {e}")
st.error(f"{str(_('pages.fiches.loading_error', 'Erreur lors du chargement de la fiche :'))} {e}")

View File

@ -5,6 +5,7 @@ import json
import requests
import os
import streamlit as st
from utils.translations import _
from config import GITEA_URL, GITEA_TOKEN, ORGANISATION, DEPOT_FICHES, ENV
@ -16,7 +17,7 @@ def gitea_request(method, url, **kwargs):
response.raise_for_status()
return response
except requests.RequestException as e:
st.error(f"Erreur Gitea ({method.upper()}): {e}")
st.error(f"{str(_('errors.gitea_error', 'Erreur Gitea'))} ({method.upper()}): {e}")
return None
@ -38,9 +39,9 @@ def charger_fiches_et_labels():
"item": item.strip()
}
except FileNotFoundError:
st.error(f"Le fichier {chemin_csv} est introuvable.")
st.error(f"{str(_('errors.file_not_found', 'Le fichier'))} {chemin_csv} {str(_('errors.is_missing', 'est introuvable.'))}")
except Exception as e:
st.error(f"Erreur lors du chargement des fiches : {str(e)}")
st.error(f"{str(_('errors.file_loading', 'Erreur lors du chargement des fiches :'))} {str(e)}")
return dictionnaire_fiches
@ -56,7 +57,7 @@ def rechercher_tickets_gitea(fiche_selectionnee):
try:
issues = reponse.json()
except Exception as e:
st.error(f"Erreur de décodage JSON : {e}")
st.error(f"{str(_('errors.json_decode', 'Erreur de décodage JSON :'))} {e}")
return []
correspondances = charger_fiches_et_labels()
@ -86,7 +87,7 @@ def get_labels_existants():
try:
return {label['name']: label['id'] for label in reponse.json()}
except Exception as e:
st.error(f"Erreur de parsing des labels : {e}")
st.error(f"{str(_('errors.label_parsing', 'Erreur de parsing des labels :'))} {e}")
return {}
@ -113,6 +114,6 @@ def creer_ticket_gitea(titre, corps, labels):
issue_url = reponse.json().get("html_url", "")
if issue_url:
st.success(f"Ticket créé ! [Voir le ticket]({issue_url})")
st.success(f"{str(_('pages.fiches.tickets.created_success', 'Ticket créé !'))} [Voir le ticket]({issue_url})")
else:
st.success("Ticket créé avec succès.")
st.success(str(_('pages.fiches.tickets.created', 'Ticket créé avec succès.')))

View File

@ -3,6 +3,7 @@
import re
import base64
import streamlit as st
from utils.translations import _
from .core import charger_fiches_et_labels, construire_corps_ticket_markdown, creer_ticket_gitea, get_labels_existants, nettoyer_labels
from config import ENV
import requests
@ -38,7 +39,7 @@ def generer_labels(fiche_selectionnee):
if len(cible["operations"]) == 1:
labels.append(cible["operations"][0])
elif len(cible["operations"]) > 1:
selected_ops = st.multiselect("Labels opération à associer",
selected_ops = st.multiselect(str(_("pages.fiches.tickets.contribution_type", "Labels opération à associer")),
cible["operations"],
default=cible["operations"])
@ -52,14 +53,14 @@ def creer_champs_formulaire(sections, fiche_selectionnee):
for section, aide in sections.items():
if "Type de contribution" in section:
options = sorted(set(re.findall(r"- \[.\] (.+)", aide)))
if "Autre" not in options:
options.append("Autre")
choix = st.radio("Type de contribution", options)
reponses[section] = st.text_input("Précisez", "") if choix == "Autre" else choix
if str(_("pages.fiches.tickets.other", "Autre")) not in options:
options.append(str(_("pages.fiches.tickets.other", "Autre")))
choix = st.radio(str(_("pages.fiches.tickets.contribution_type", "Type de contribution")), options)
reponses[section] = st.text_input(str(_("pages.fiches.tickets.specify", "Précisez")), "") if choix == str(_("pages.fiches.tickets.other", "Autre")) else choix
elif "Fiche concernée" in section:
url_fiche = f"https://fabnum-git.peccini.fr/FabNum/Fiches/src/branch/{ENV}/Documents/{fiche_selectionnee.replace(' ', '%20')}"
reponses[section] = url_fiche
st.text_input("Fiche concernée", value=url_fiche, disabled=True)
st.text_input(str(_("pages.fiches.tickets.concerned_card", "Fiche concernée")), value=url_fiche, disabled=True)
elif "Sujet de la proposition" in section:
reponses[section] = st.text_input(section, help=aide)
else:
@ -71,9 +72,9 @@ def creer_champs_formulaire(sections, fiche_selectionnee):
def afficher_controles_formulaire():
"""Affiche les boutons de contrôle du formulaire."""
col1, col2 = st.columns(2)
if col1.button("Prévisualiser le ticket"):
if col1.button(str(_("pages.fiches.tickets.preview", "Prévisualiser le ticket"))):
st.session_state.previsualiser = True
if col2.button("Annuler"):
if col2.button(str(_("pages.fiches.tickets.cancel", "Annuler"))):
st.session_state.previsualiser = False
st.rerun()
@ -83,7 +84,7 @@ def gerer_previsualisation_et_soumission(reponses, labels, selected_ops, cible):
if not st.session_state.get("previsualiser", False):
return
st.subheader("Prévisualisation du ticket")
st.subheader(str(_("pages.fiches.tickets.preview_title", "Prévisualisation du ticket")))
for section, texte in reponses.items():
st.markdown(f"#### {section}")
st.code(texte, language="markdown")
@ -91,9 +92,9 @@ def gerer_previsualisation_et_soumission(reponses, labels, selected_ops, cible):
titre_ticket = reponses.get("Sujet de la proposition", "").strip() or "Ticket FabNum"
final_labels = nettoyer_labels(labels + selected_ops + ([cible["item"]] if cible else []))
st.markdown(f"**Résumé :**\n- **Titre** : `{titre_ticket}`\n- **Labels** : `{', '.join(final_labels)}`")
st.markdown(f"**{str(_('pages.fiches.tickets.summary', 'Résumé'))} :**\n- **{str(_('pages.fiches.tickets.title', 'Titre'))}** : `{titre_ticket}`\n- **{str(_('pages.fiches.tickets.labels', 'Labels'))}** : `{', '.join(final_labels)}`")
if st.button("Confirmer la création du ticket"):
if st.button(str(_("pages.fiches.tickets.confirm", "Confirmer la création du ticket"))):
labels_existants = get_labels_existants()
labels_ids = [labels_existants[l] for l in final_labels if l in labels_existants]
if "Backlog" in labels_existants:
@ -103,16 +104,16 @@ def gerer_previsualisation_et_soumission(reponses, labels, selected_ops, cible):
creer_ticket_gitea(titre_ticket, corps, labels_ids)
st.session_state.previsualiser = False
st.success("Ticket créé et formulaire vidé.")
st.success(str(_("pages.fiches.tickets.created", "Ticket créé et formulaire vidé.")))
def formulaire_creation_ticket_dynamique(fiche_selectionnee):
"""Fonction principale pour le formulaire de création de ticket."""
with st.expander("Créer un nouveau ticket lié à cette fiche", expanded=False):
with st.expander(str(_("pages.fiches.tickets.create_new", "Créer un nouveau ticket lié à cette fiche")), expanded=False):
# Chargement et vérification du modèle
contenu_modele = charger_modele_ticket()
if not contenu_modele:
st.error("Impossible de charger le modèle de ticket.")
st.error(str(_("pages.fiches.tickets.model_load_error", "Impossible de charger le modèle de ticket.")))
return
# Traitement du modèle et génération du formulaire
@ -135,5 +136,5 @@ def charger_modele_ticket():
r.raise_for_status()
return base64.b64decode(r.json().get("content", "")).decode("utf-8")
except Exception as e:
st.error(f"Erreur chargement modèle : {e}")
st.error(f"{str(_('pages.fiches.tickets.model_error', 'Erreur chargement modèle :'))} {e}")
return ""

View File

@ -5,23 +5,28 @@ import html
import re
from collections import defaultdict
from dateutil import parser
from utils.translations import _
from .core import rechercher_tickets_gitea
def extraire_statut_par_label(ticket):
labels = [label.get('name', '') for label in ticket.get('labels', [])]
for statut in ["Backlog", "En attente de traitement", "En cours", "Terminés", "Non retenus"]:
for statut in ["Backlog",
str(_("pages.fiches.tickets.status.awaiting", "En attente de traitement")),
str(_("pages.fiches.tickets.status.in_progress", "En cours")),
str(_("pages.fiches.tickets.status.completed", "Terminés")),
str(_("pages.fiches.tickets.status.rejected", "Non retenus"))]:
if statut in labels:
return statut
return "Autres"
return str(_("pages.fiches.tickets.status.others", "Autres"))
def afficher_tickets_par_fiche(tickets):
if not tickets:
st.info("Aucun ticket lié à cette fiche.")
st.info(str(_("pages.fiches.tickets.no_linked_tickets", "Aucun ticket lié à cette fiche.")))
return
st.markdown("**Tickets associés à cette fiche**")
st.markdown(str(_("pages.fiches.tickets.associated_tickets", "**Tickets associés à cette fiche**")))
tickets_groupes = defaultdict(list)
for ticket in tickets:
statut = extraire_statut_par_label(ticket)
@ -29,9 +34,15 @@ def afficher_tickets_par_fiche(tickets):
nb_backlogs = len(tickets_groupes["Backlog"])
if nb_backlogs:
st.info(f"{nb_backlogs} ticket(s) en attente de modération ne sont pas affichés.")
st.info(f"{nb_backlogs} {str(_('pages.fiches.tickets.moderation_notice', 'ticket(s) en attente de modération ne sont pas affichés.'))}")
ordre_statuts = ["En attente de traitement", "En cours", "Terminés", "Non retenus", "Autres"]
ordre_statuts = [
str(_("pages.fiches.tickets.status.awaiting", "En attente de traitement")),
str(_("pages.fiches.tickets.status.in_progress", "En cours")),
str(_("pages.fiches.tickets.status.completed", "Terminés")),
str(_("pages.fiches.tickets.status.rejected", "Non retenus")),
str(_("pages.fiches.tickets.status.others", "Autres"))
]
for statut in ordre_statuts:
if tickets_groupes[statut]:
with st.expander(f"{statut} ({len(tickets_groupes[statut])})", expanded=(statut == "En cours")):
@ -50,14 +61,14 @@ def recuperer_commentaires_ticket(issue_index):
response.raise_for_status()
return response.json()
except Exception as e:
st.error(f"Erreur lors de la récupération des commentaires : {e}")
st.error(f"{str(_('pages.fiches.tickets.comment_error', 'Erreur lors de la récupération des commentaires :'))} {e}")
return []
def afficher_carte_ticket(ticket):
titre = ticket.get("title", "Sans titre")
titre = ticket.get("title", str(_("pages.fiches.tickets.no_title", "Sans titre")))
url = ticket.get("html_url", "")
user = ticket.get("user", {}).get("login", "inconnu")
user = ticket.get("user", {}).get("login", str(_("pages.fiches.tickets.unknown", "inconnu")))
created = ticket.get("created_at", "")
updated = ticket.get("updated_at", "")
body = ticket.get("body", "")
@ -75,12 +86,12 @@ def afficher_carte_ticket(ticket):
return "?"
date_created_str = format_date(created)
maj_info = f"(MAJ {format_date(updated)})" if updated and updated != created else ""
maj_info = f"({str(_('pages.fiches.tickets.updated', 'MAJ'))} {format_date(updated)})" if updated and updated != created else ""
commentaires = recuperer_commentaires_ticket(ticket.get("number"))
commentaires_html = ""
for commentaire in commentaires:
auteur = html.escape(commentaire.get('user', {}).get('login', 'inconnu'))
auteur = html.escape(commentaire.get('user', {}).get('login', str(_("pages.fiches.tickets.unknown", "inconnu"))))
contenu = html.escape(commentaire.get('body', ''))
date = format_date(commentaire.get('created_at', ''))
commentaires_html += f"""
@ -94,12 +105,12 @@ def afficher_carte_ticket(ticket):
st.markdown(f"""
<div class=\"conteneur_ticket\">
<h4><a href='{url}' target='_blank'>{titre}</a></h4>
<p>Ouvert par <strong>{html.escape(user)}</strong> le {date_created_str} {maj_info}</p>
<p>Sujet : <strong>{html.escape(sujet)}</strong></p>
<p>Labels : {''.join(labels) if labels else 'aucun'}</p>
<p>{str(_("pages.fiches.tickets.opened_by", "Ouvert par"))} <strong>{html.escape(user)}</strong> {str(_("pages.fiches.tickets.on_date", "le"))} {date_created_str} {maj_info}</p>
<p>{str(_("pages.fiches.tickets.subject_label", "Sujet"))} : <strong>{html.escape(sujet)}</strong></p>
<p>Labels : {''.join(labels) if labels else str(_("pages.fiches.tickets.no_labels", "aucun"))}</p>
</div>
""", unsafe_allow_html=True)
st.markdown(body, unsafe_allow_html=False)
st.markdown("---")
st.markdown("**Commentaire(s) :**")
st.markdown(commentaires_html or "Aucun commentaire.", unsafe_allow_html=True)
st.markdown(str(_("pages.fiches.tickets.comments", "**Commentaire(s) :**")))
st.markdown(commentaires_html or str(_("pages.fiches.tickets.no_comments", "Aucun commentaire.")), unsafe_allow_html=True)

View File

@ -1,22 +1,23 @@
import streamlit as st
from utils.translations import _
def ajouter_produit(G):
st.markdown("## Ajouter un nouveau produit final")
new_prod = st.text_input("Nom du nouveau produit (unique)", key="new_prod")
st.markdown(f"## {str(_('pages.personnalisation.add_new_product', 'Ajouter un nouveau produit final'))}")
new_prod = st.text_input(str(_("pages.personnalisation.new_product_name", "Nom du nouveau produit (unique)")), key="new_prod")
if new_prod:
ops_dispo = sorted([
n for n, d in G.nodes(data=True)
if d.get("niveau") == "10"
and any(G.has_edge(p, n) and G.nodes[p].get("niveau") == "0" for p in G.predecessors(n))
])
sel_new_op = st.selectbox("Opération d'assemblage (optionnelle)", ["-- Aucune --"] + ops_dispo, index=0)
sel_new_op = st.selectbox(str(_("pages.personnalisation.assembly_operation", "Opération d'assemblage (optionnelle)")), [str(_("pages.personnalisation.none", "-- Aucune --"))] + ops_dispo, index=0)
niveau1 = sorted([n for n, d in G.nodes(data=True) if d.get("niveau") == "1"])
sel_comps = st.multiselect("Composants à lier", options=niveau1)
if st.button("Créer le produit"):
sel_comps = st.multiselect(str(_("pages.personnalisation.components_to_link", "Composants à lier")), options=niveau1)
if st.button(str(_("pages.personnalisation.create_product", "Créer le produit"))):
G.add_node(new_prod, niveau="0", personnalisation="oui", label=new_prod)
if sel_new_op != "-- Aucune --":
if sel_new_op != str(_("pages.personnalisation.none", "-- Aucune --")):
G.add_edge(new_prod, sel_new_op)
for comp in sel_comps:
G.add_edge(new_prod, comp)
st.success(f"{new_prod} ajouté.")
st.success(f"{new_prod} {str(_('pages.personnalisation.added', 'ajouté'))}")
return G

View File

@ -1,21 +1,24 @@
# interface.py app/personnalisation
import streamlit as st
from utils.translations import _
from .ajout import ajouter_produit
from .modification import modifier_produit
from .import_export import importer_exporter_graph
def interface_personnalisation(G):
st.markdown("# Personnalisation des produits finaux")
with st.expander("Comment utiliser cet onglet ?", expanded=False):
st.markdown("""
1. Cliquez sur « Ajouter un produit final » pour créer un nouveau produit
2. Donnez un nom à votre produit
3. Sélectionnez une opération d'assemblage appropriée (si pertinent)
4. Choisissez les composants qui constituent votre produit dans la liste proposée
5. Sauvegardez votre configuration pour une réutilisation future
6. Vous pourrez par la suite modifier ou supprimer vos produits personnalisés
""")
st.markdown(f"# {str(_('pages.personnalisation.title', 'Personnalisation des produits finaux'))}")
with st.expander(str(_("pages.personnalisation.help", "Comment utiliser cet onglet ?")), expanded=False):
st.markdown("\n".join([
" " + line for line in _("pages.personnalisation.help_content", [
"1. Cliquez sur « Ajouter un produit final » pour créer un nouveau produit",
"2. Donnez un nom à votre produit",
"3. Sélectionnez une opération d'assemblage appropriée (si pertinent)",
"4. Choisissez les composants qui constituent votre produit dans la liste proposée",
"5. Sauvegardez votre configuration pour une réutilisation future",
"6. Vous pourrez par la suite modifier ou supprimer vos produits personnalisés"
])
]))
st.markdown("---")
G = ajouter_produit(G)

View File

@ -1,4 +1,5 @@
import streamlit as st
from utils.translations import _
def get_produits_personnalises(G):
"""Récupère la liste des produits personnalisés du niveau 0."""
@ -7,7 +8,7 @@ def get_produits_personnalises(G):
def supprimer_produit(G, prod):
"""Supprime un produit du graphe."""
G.remove_node(prod)
st.success(f"{prod} supprimé.")
st.success(f"{prod} {str(_('pages.personnalisation.deleted', 'supprimé'))}")
st.session_state.pop("prod_sel", None)
return G
@ -33,10 +34,11 @@ def get_composants_lies(G, prod):
def mettre_a_jour_operations(G, prod, curr_ops, sel_op):
"""Met à jour les opérations liées au produit."""
none_option = str(_("pages.personnalisation.none", "-- Aucune --"))
for op in curr_ops:
if sel_op == "-- Aucune --" or op != sel_op:
if sel_op == none_option or op != sel_op:
G.remove_edge(prod, op)
if sel_op != "-- Aucune --" and (not curr_ops or sel_op not in curr_ops):
if sel_op != none_option and (not curr_ops or sel_op not in curr_ops):
G.add_edge(prod, sel_op)
return G
@ -49,11 +51,11 @@ def mettre_a_jour_composants(G, prod, linked, nouveaux):
return G
def modifier_produit(G):
st.markdown("## Modifier un produit final ajouté")
st.markdown(f"## {str(_('pages.personnalisation.modify_product', 'Modifier un produit final ajouté'))}")
# Sélection du produit à modifier
produits0 = get_produits_personnalises(G)
sel_display = st.multiselect("Produits à modifier", options=produits0)
sel_display = st.multiselect(str(_("pages.personnalisation.products_to_modify", "Produits à modifier")), options=produits0)
if not sel_display:
return G
@ -62,24 +64,24 @@ def modifier_produit(G):
prod = sel_display[0]
# Suppression du produit si demandé
if st.button(f"Supprimer {prod}"):
if st.button(f"{str(_('pages.personnalisation.delete', 'Supprimer'))} {prod}"):
return supprimer_produit(G, prod)
# Gestion des opérations d'assemblage
ops_dispo = get_operations_disponibles(G)
curr_ops = get_operations_actuelles(G, prod)
default_idx = ops_dispo.index(curr_ops[0]) + 1 if curr_ops and curr_ops[0] in ops_dispo else 0
sel_op = st.selectbox("Opération d'assemblage liée", ["-- Aucune --"] + ops_dispo, index=default_idx)
sel_op = st.selectbox(str(_("pages.personnalisation.linked_assembly_operation", "Opération d'assemblage liée")), [str(_("pages.personnalisation.none", "-- Aucune --"))] + ops_dispo, index=default_idx)
# Gestion des composants
niveau1 = get_composants_niveau1(G)
linked = get_composants_lies(G, prod)
nouveaux = st.multiselect(f"Composants liés à {prod}", options=niveau1, default=linked)
nouveaux = st.multiselect(f"{str(_('pages.personnalisation.components_linked_to', 'Composants liés à'))} {prod}", options=niveau1, default=linked)
# Mise à jour des liens si demandé
if st.button(f"Mettre à jour {prod}"):
if st.button(f"{str(_('pages.personnalisation.update', 'Mettre à jour'))} {prod}"):
G = mettre_a_jour_operations(G, prod, curr_ops, sel_op)
G = mettre_a_jour_composants(G, prod, linked, nouveaux)
st.success(f"{prod} mis à jour.")
st.success(f"{prod} {str(_('pages.personnalisation.updated', 'mis à jour'))}")
return G

View File

@ -3,14 +3,31 @@ import altair as alt
import numpy as np
from collections import Counter
import pandas as pd
from utils.translations import _
def afficher_graphique_altair(df):
ordre_personnalise = ['Assemblage', 'Fabrication', 'Traitement', 'Extraction']
categories = [cat for cat in ordre_personnalise if cat in df['categorie'].unique()]
for cat in categories:
st.markdown(f"### {cat}")
df_cat = df[df['categorie'] == cat].copy()
# Définir les catégories originales (en français) et leur ordre
categories_fr = ["Assemblage", "Fabrication", "Traitement", "Extraction"]
# Créer un dictionnaire de mappage entre les catégories originales et leurs traductions
mappage_categories = {
"Assemblage": str(_("pages.visualisations.categories.assembly", "Assemblage")),
"Fabrication": str(_("pages.visualisations.categories.manufacturing", "Fabrication")),
"Traitement": str(_("pages.visualisations.categories.processing", "Traitement")),
"Extraction": str(_("pages.visualisations.categories.extraction", "Extraction"))
}
# Filtrer les catégories qui existent dans les données
categories_fr_filtrees = [cat for cat in categories_fr if cat in df['categorie'].unique()]
# Parcourir les catégories dans l'ordre défini
for cat_fr in categories_fr_filtrees:
# Obtenir le nom traduit de la catégorie pour l'affichage
cat_traduit = mappage_categories[cat_fr]
st.markdown(f"### {cat_traduit}")
# Mais filtrer sur le nom original dans les données
df_cat = df[df['categorie'] == cat_fr].copy()
coord_pairs = list(zip(df_cat['ihh_pays'].round(1), df_cat['ihh_acteurs'].round(1)))
counts = Counter(coord_pairs)
@ -36,8 +53,8 @@ def afficher_graphique_altair(df):
df_cat['ihh_acteurs_text'] = df_cat['ihh_acteurs'] + 0.5
base = alt.Chart(df_cat).encode(
x=alt.X('ihh_pays:Q', title='IHH Pays (%)'),
y=alt.Y('ihh_acteurs:Q', title='IHH Acteurs (%)'),
x=alt.X('ihh_pays:Q', title=str(_("pages.visualisations.axis_titles.ihh_countries", "IHH Pays (%)"))),
y=alt.Y('ihh_acteurs:Q', title=str(_("pages.visualisations.axis_titles.ihh_actors", "IHH Acteurs (%)"))),
size=alt.Size('criticite_cat:Q', scale=alt.Scale(domain=[1, 2, 3], range=[50, 500, 1000]), legend=None),
color=alt.Color('criticite_cat:N', scale=alt.Scale(domain=[1, 2, 3], range=['darkgreen', 'orange', 'darkred']))
)
@ -66,7 +83,7 @@ def afficher_graphique_altair(df):
chart = (points + lines + labels + hline_15 + hline_25 + hline_100 + vline_15 + vline_25 + vline_100).properties(
width=500,
height=400,
title=f"Concentration et criticité {cat}"
title=str(_("pages.visualisations.chart_titles.concentration_criticality", "Concentration et criticité {0}")).format(cat_traduit)
).interactive()
st.altair_chart(chart, use_container_width=True)
@ -74,7 +91,7 @@ def afficher_graphique_altair(df):
def creer_graphes(donnees):
if not donnees:
st.warning("Aucune donnée à afficher.")
st.warning(str(_("pages.visualisations.no_data", "Aucune donnée à afficher.")))
return
try:
@ -105,8 +122,8 @@ def creer_graphes(donnees):
df['ihh_reserves_text'] = df['ihh_reserves'] + 0.5
base = alt.Chart(df).encode(
x=alt.X('ihh_extraction:Q', title='IHH Extraction (%)'),
y=alt.Y('ihh_reserves:Q', title='IHH Réserves (%)'),
x=alt.X('ihh_extraction:Q', title=str(_("pages.visualisations.axis_titles.ihh_extraction", "IHH Extraction (%)"))),
y=alt.Y('ihh_reserves:Q', title=str(_("pages.visualisations.axis_titles.ihh_reserves", "IHH Réserves (%)"))),
size=alt.Size('ivc_cat:Q', scale=alt.Scale(domain=[1, 2, 3], range=[50, 500, 1000]), legend=None),
color=alt.Color('ivc_cat:N', scale=alt.Scale(domain=[1, 2, 3], range=['darkgreen', 'orange', 'darkred'])),
tooltip=['nom:N', 'ivc:Q', 'ihh_extraction:Q', 'ihh_reserves:Q']
@ -136,13 +153,13 @@ def creer_graphes(donnees):
chart = (points + lines + labels + hline_15 + hline_25 + hline_100 + vline_15 + vline_25 + vline_100).properties(
width=600,
height=500,
title="Concentration des ressources critiques vs vulnérabilité IVC"
title=str(_("pages.visualisations.chart_titles.concentration_resources", "Concentration des ressources critiques vs vulnérabilité IVC"))
).interactive()
st.altair_chart(chart, use_container_width=True)
except Exception as e:
st.error(f"Erreur lors de la création du graphique : {e}")
st.error(f"{str(_('errors.graph_creation_error', 'Erreur lors de la création du graphique :'))} {e}")
def lancer_visualisation_ihh_criticite(graph):
@ -156,11 +173,11 @@ def lancer_visualisation_ihh_criticite(graph):
df = recuperer_donnees(graph, noeuds)
if df.empty:
st.warning("Aucune donnée à visualiser.")
st.warning(str(_("pages.visualisations.no_data", "Aucune donnée à visualiser.")))
else:
afficher_graphique_altair(df)
except Exception as e:
st.error(f"Erreur dans la visualisation IHH vs Criticité : {e}")
st.error(f"{str(_('errors.ihh_criticality_error', 'Erreur dans la visualisation IHH vs Criticité :'))} {e}")
def lancer_visualisation_ihh_ivc(graph):
@ -175,4 +192,4 @@ def lancer_visualisation_ihh_ivc(graph):
data = recuperer_donnees_2(graph, noeuds_niveau_2)
creer_graphes(data)
except Exception as e:
st.error(f"Erreur dans la visualisation IHH vs IVC : {e}")
st.error(f"{str(_('errors.ihh_ivc_error', 'Erreur dans la visualisation IHH vs IVC :'))} {e}")

View File

@ -1,4 +1,5 @@
import streamlit as st
from utils.translations import _
from .graphes import (
lancer_visualisation_ihh_criticite,
@ -7,39 +8,39 @@ from .graphes import (
def interface_visualisations(G_temp, G_temp_ivc):
st.markdown("# Analyse du graphe")
with st.expander("Comment utiliser cet onglet ?", expanded=False):
st.markdown("""
1. Explorez les graphiques présentant l'Indice de Herfindahl-Hirschmann (IHH)
2. Analysez sa relation avec la criticité moyenne des minerais ou leur Indice de Vulnérabilité Concurrentielle (IVC)
3. Zoomer dans les graphes pour mieux découvrir les informations
Il est important de se rappeler que l'IHH a deux seuils :
* en-dessous de 15, la concentration est considérée comme étant faible
* au-dessus de 25, elle est considérée comme étant forte
Ainsi plus le positionnement d'un point est en haut à droite des graphiques, plus les risques sont élevés.
Les graphiques présentent 2 droites horizontales et vetrticales pour matérialiser ces seuils.
""")
st.markdown(f"# {str(_('pages.visualisations.title', 'Analyse du graphe'))}")
with st.expander(str(_("pages.visualisations.help", "Comment utiliser cet onglet ?")), expanded=False):
st.markdown("\n".join(_("pages.visualisations.help_content", [
"1. Explorez les graphiques présentant l'Indice de Herfindahl-Hirschmann (IHH)",
"2. Analysez sa relation avec la criticité moyenne des minerais ou leur Indice de Vulnérabilité Concurrentielle (IVC)",
"3. Zoomer dans les graphes pour mieux découvrir les informations",
"",
"Il est important de se rappeler que l'IHH a deux seuils :",
"* en-dessous de 15, la concentration est considérée comme étant faible",
"* au-dessus de 25, elle est considérée comme étant forte",
"",
"Ainsi plus le positionnement d'un point est en haut à droite des graphiques, plus les risques sont élevés.",
"Les graphiques présentent 2 droites horizontales et vetrticales pour matérialiser ces seuils."
])))
st.markdown("---")
st.markdown("""## Indice de Herfindahl-Hirschmann - IHH vs Criticité
st.markdown(f"""## {str(_("pages.visualisations.ihh_criticality", "Indice de Herfindahl-Hirschmann - IHH vs Criticité"))}
La taille des points donne l'indication de la criticité de substituabilité du minerai.
{str(_("pages.visualisations.ihh_criticality_desc", "La taille des points donne l'indication de la criticité de substituabilité du minerai."))}
""")
if st.button("Lancer", key="btn_ihh_criticite"):
if st.button(str(_("buttons.run", "Lancer")), key="btn_ihh_criticite"):
try:
lancer_visualisation_ihh_criticite(G_temp)
except Exception as e:
st.error(f"Erreur dans la visualisation IHH vs Criticité : {e}")
st.error(f"{str(_('errors.ihh_criticality_error', 'Erreur dans la visualisation IHH vs Criticité :'))} {e}")
st.markdown("""## Indice de Herfindahl-Hirschmann - IHH vs IVC
st.markdown(f"""## {str(_("pages.visualisations.ihh_ivc", "Indice de Herfindahl-Hirschmann - IHH vs IVC"))}
La taille des points donne l'indication de la criticité concurrentielle du minerai.
{str(_("pages.visualisations.ihh_ivc_desc", "La taille des points donne l'indication de la criticité concurrentielle du minerai."))}
""")
if st.button("Lancer", key="btn_ihh_ivc"):
if st.button(str(_("buttons.run", "Lancer")), key="btn_ihh_ivc"):
try:
lancer_visualisation_ihh_ivc(G_temp_ivc)
except Exception as e:
st.error(f"Erreur dans la visualisation IHH vs IVC : {e}")
st.error(f"{str(_('errors.ihh_ivc_error', 'Erreur dans la visualisation IHH vs IVC :'))} {e}")

276
assets/locales/en.json Normal file
View File

@ -0,0 +1,276 @@
{
"app": {
"title": "Fabnum Chain Analysis",
"description": "Ecosystem exploration and vulnerability identification.",
"dev_mode": "You are in the development environment."
},
"header": {
"title": "FabNum - Digital Manufacturing Chain",
"subtitle": "Ecosystem exploration and vulnerability identification."
},
"footer": {
"copyright": "Fabnum © 2025",
"contact": "Contact",
"license": "License",
"license_text": "CC BY-NC-ND",
"eco_note": "🌱 CO₂ calculations via",
"eco_provider": "The Green Web Foundation",
"powered_by": "🚀 Powered by",
"powered_by_name": "Streamlit"
},
"sidebar": {
"menu": "Main Menu",
"navigation": "Main Navigation",
"theme": "Theme",
"theme_light": "Light",
"theme_dark": "Dark",
"theme_instructions_only": "Theme changes can only be made from the Instructions tab.",
"impact": "Environmental Impact",
"loading": "Loading..."
},
"auth": {
"title": "Authentication",
"username": "Username_token",
"token": "Gitea Personal Access Token",
"login": "Login",
"logout": "Logout",
"logged_as": "Logged in as",
"error": "❌ Access denied.",
"gitea_error": "❌ Unable to verify user with Gitea.",
"success": "Successfully logged out."
},
"navigation": {
"instructions": "Instructions",
"personnalisation": "Customization",
"analyse": "Analysis",
"visualisations": "Visualizations",
"fiches": "Cards"
},
"pages": {
"instructions": {
"title": "Instructions"
},
"personnalisation": {
"title": "Final Product Customization",
"help": "How to use this tab?",
"help_content": [
"1. Click on \"Add a final product\" to create a new product",
"2. Give your product a name",
"3. Select an appropriate assembly operation (if relevant)",
"4. Choose the components that make up your product from the list provided",
"5. Save your configuration for future reuse",
"6. You will be able to modify or delete your custom products later"
],
"add_new_product": "Add a new final product",
"new_product_name": "New product name (unique)",
"assembly_operation": "Assembly operation (optional)",
"none": "-- None --",
"components_to_link": "Components to link",
"create_product": "Create product",
"added": "added",
"modify_product": "Modify an added final product",
"products_to_modify": "Products to modify",
"delete": "Delete",
"linked_assembly_operation": "Linked assembly operation",
"components_linked_to": "Components linked to",
"update": "Update",
"updated": "updated",
"deleted": "deleted",
"save_restore_config": "Save or restore configuration",
"export_config": "Export configuration",
"download_json": "Download (JSON)",
"import_config": "Import a JSON configuration (max 100 KB)",
"file_too_large": "File too large (max 100 KB).",
"no_products_found": "No products found in the file.",
"select_products_to_restore": "Select products to restore",
"products_to_restore": "Products to restore",
"restore_selected": "Restore selected items",
"config_restored": "Partial configuration successfully restored.",
"import_error": "Import error:"
},
"analyse": {
"title": "Graph Analysis",
"help": "How to use this tab?",
"help_content": [
"1. Select the starting level (final product, component, or mineral)",
"2. Choose the desired destination level",
"3. Refine your selection by specifying either one or more specific minerals to target or specific items at each level (optional)",
"4. Define the analysis criteria by selecting the relevant vulnerability indices",
"5. Choose the index combination mode (AND/OR) according to your analysis needs",
"6. Explore the generated graph using zoom and panning controls; you can switch to full screen mode for the graph"
],
"selection_nodes": "Selection of start and end nodes",
"select_level": "-- Select a level --",
"start_level": "Start level",
"end_level": "End level",
"select_minerals": "Select one or more minerals",
"filter_by_minerals": "Filter by minerals (optional)",
"fine_selection": "Fine selection of items",
"filter_start_nodes": "Filter by start nodes (optional)",
"filter_end_nodes": "Filter by end nodes (optional)",
"vulnerability_filters": "Selection of filters to identify vulnerabilities",
"filter_ics": "Filter paths containing at least one critical mineral for a component (ICS > 66%)",
"filter_ivc": "Filter paths containing at least one critical mineral in relation to sectoral competition (IVC > 30)",
"filter_ihh": "Filter paths containing at least one critical operation in relation to geographical or industrial concentration (IHH countries or actors > 25)",
"apply_ihh_filter": "Apply IHH filter on:",
"countries": "Countries",
"actors": "Actors",
"filter_isg": "Filter paths containing an unstable country (ISG ≥ 60)",
"filter_logic": "Filter logic",
"or": "OR",
"and": "AND",
"run_analysis": "Run analysis",
"sankey": {
"no_paths": "No paths found for the specified criteria.",
"no_matching_paths": "No paths match the criteria.",
"filtered_hierarchy": "Hierarchy filtered by levels and nodes",
"download_dot": "Download filtered DOT file",
"relation": "Relation"
}
},
"visualisations": {
"title": "Graph Analysis",
"help": "How to use this tab?",
"help_content": [
"1. Explore the graphs presenting the Herfindahl-Hirschmann Index (IHH)",
"2. Analyze its relationship with the average criticality of minerals or their Competitive Vulnerability Index (IVC)",
"3. Zoom in on the graphs to better discover the information",
"",
"It is important to remember that the IHH has two thresholds:",
"* below 15, concentration is considered to be low",
"* above 25, it is considered to be high",
"",
"Thus, the higher a point is positioned in the top right of the graphs, the higher the risks.",
"The graphs present 2 horizontal and vertical lines to mark these thresholds."
],
"ihh_criticality": "Herfindahl-Hirschmann Index - IHH vs Criticality",
"ihh_criticality_desc": "The size of the points indicates the substitutability criticality of the mineral.",
"ihh_ivc": "Herfindahl-Hirschmann Index - IHH vs IVC",
"ihh_ivc_desc": "The size of the points indicates the competitive criticality of the mineral.",
"launch": "Launch",
"no_data": "No data to display.",
"categories": {
"assembly": "Assembly",
"manufacturing": "Manufacturing",
"processing": "Processing",
"extraction": "Extraction"
},
"axis_titles": {
"ihh_countries": "IHH Countries (%)",
"ihh_actors": "IHH Actors (%)",
"ihh_extraction": "IHH Extraction (%)",
"ihh_reserves": "IHH Reserves (%)"
},
"chart_titles": {
"concentration_criticality": "Concentration and Criticality {0}",
"concentration_resources": "Concentration of Critical Resources vs IVC Vulnerability"
}
},
"fiches": {
"title": "Card Discovery",
"help": "How to use this tab?",
"help_content": [
"1. Browse the list of available cards by category",
"2. Select a card to display its full content",
"3. Consult detailed data, graphs, and additional analyses",
"4. Use this information to deepen your understanding of the identified vulnerabilities",
"",
"The categories are as follows:",
"* Assembly: operation of assembling final products from components",
"* Related: various operations necessary to manufacture digital technology, but not directly entering its composition",
"* Criticalities: indices used to identify and evaluate vulnerabilities",
"* Manufacturing: operation of manufacturing components from minerals",
"* Mineral: description and operations of extraction and processing of minerals"
],
"no_files": "No cards available at the moment.",
"choose_category": "Choose a card category",
"select_folder": "-- Select a folder --",
"choose_file": "Choose a card",
"select_file": "-- Select a card --",
"loading_error": "Error loading the card:",
"download_pdf": "Download this card as PDF",
"pdf_unavailable": "The PDF file for this card is not available.",
"ticket_management": "Ticket management for this card",
"tickets": {
"create_new": "Create a new ticket linked to this card",
"model_load_error": "Unable to load the ticket template.",
"contribution_type": "Contribution type",
"specify": "Specify",
"other": "Other",
"concerned_card": "Concerned card",
"subject": "Subject of the proposal",
"preview": "Preview ticket",
"cancel": "Cancel",
"preview_title": "Ticket preview",
"summary": "Summary",
"title": "Title",
"labels": "Labels",
"confirm": "Confirm ticket creation",
"created": "Ticket created and form cleared.",
"model_error": "Template loading error:",
"no_linked_tickets": "No tickets linked to this card.",
"associated_tickets": "Tickets associated with this card",
"moderation_notice": "ticket(s) awaiting moderation are not displayed.",
"status": {
"awaiting": "Awaiting processing",
"in_progress": "In progress",
"completed": "Completed",
"rejected": "Rejected",
"others": "Others"
},
"no_title": "No title",
"unknown": "unknown",
"subject_label": "Subject",
"no_labels": "none",
"comments": "Comment(s):",
"no_comments": "No comments.",
"comment_error": "Error retrieving comments:",
"opened_by": "Opened by",
"on_date": "on",
"updated": "UPDATED"
}
}
},
"node_levels": {
"0": "Final product",
"1": "Component",
"2": "Mineral",
"10": "Operation",
"11": "Operation country",
"12": "Operation actor",
"99": "Geographic country"
},
"errors": {
"log_read_error": "Log reading error:",
"graph_preview_error": "Graph preview error:",
"graph_creation_error": "Error creating the graph:",
"ihh_criticality_error": "Error in IHH vs Criticality visualization:",
"ihh_ivc_error": "Error in IHH vs IVC visualization:",
"comment_fetch_error": "Error retrieving comments:",
"template_load_error": "Template loading error:",
"import_error": "Import error:"
},
"buttons": {
"download": "Download",
"run": "Run",
"save": "Save",
"cancel": "Cancel",
"confirm": "Confirm",
"filter": "Filter",
"search": "Search",
"create": "Create",
"update": "Update",
"delete": "Delete",
"preview": "Preview",
"export": "Export",
"import": "Import",
"restore": "Restore",
"browse_files": "Browse files"
},
"ui": {
"file_uploader": {
"drag_drop_here": "Drag and drop file here",
"size_limit": "100 KB limit per file • JSON"
}
}
}

276
assets/locales/fr.json Normal file
View File

@ -0,0 +1,276 @@
{
"app": {
"title": "Fabnum Analyse de chaîne",
"description": "Parcours de l'écosystème et identification des vulnérabilités.",
"dev_mode": "Vous êtes dans l'environnement de développement."
},
"header": {
"title": "FabNum - Chaîne de fabrication du numérique",
"subtitle": "Parcours de l'écosystème et identification des vulnérabilités."
},
"footer": {
"copyright": "Fabnum © 2025",
"contact": "Contact",
"license": "Licence",
"license_text": "CC BY-NC-ND",
"eco_note": "🌱 Calculs CO₂ via",
"eco_provider": "The Green Web Foundation",
"powered_by": "🚀 Propulsé par",
"powered_by_name": "Streamlit"
},
"sidebar": {
"menu": "Menu principal",
"navigation": "Navigation principale",
"theme": "Thème",
"theme_light": "Clair",
"theme_dark": "Sombre",
"theme_instructions_only": "Le changement de thème ne peut se faire que depuis l'onglet Instructions.",
"impact": "Impact environnemental",
"loading": "Chargement en cours…"
},
"auth": {
"title": "Authentification",
"username": "Identifiant_token",
"token": "Token d'accès personnel Gitea",
"login": "Se connecter",
"logout": "Se déconnecter",
"logged_as": "Connecté en tant que",
"error": "❌ Accès refusé.",
"gitea_error": "❌ Impossible de vérifier l'utilisateur auprès de Gitea.",
"success": "Déconnecté avec succès."
},
"navigation": {
"instructions": "Instructions",
"personnalisation": "Personnalisation",
"analyse": "Analyse",
"visualisations": "Visualisations",
"fiches": "Fiches"
},
"pages": {
"instructions": {
"title": "Instructions"
},
"personnalisation": {
"title": "Personnalisation des produits finaux",
"help": "Comment utiliser cet onglet ?",
"help_content": [
"1. Cliquez sur « Ajouter un produit final » pour créer un nouveau produit",
"2. Donnez un nom à votre produit",
"3. Sélectionnez une opération d'assemblage appropriée (si pertinent)",
"4. Choisissez les composants qui constituent votre produit dans la liste proposée",
"5. Sauvegardez votre configuration pour une réutilisation future",
"6. Vous pourrez par la suite modifier ou supprimer vos produits personnalisés"
],
"add_new_product": "Ajouter un nouveau produit final",
"new_product_name": "Nom du nouveau produit (unique)",
"assembly_operation": "Opération d'assemblage (optionnelle)",
"none": "-- Aucune --",
"components_to_link": "Composants à lier",
"create_product": "Créer le produit",
"added": "ajouté",
"modify_product": "Modifier un produit final ajouté",
"products_to_modify": "Produits à modifier",
"delete": "Supprimer",
"linked_assembly_operation": "Opération d'assemblage liée",
"components_linked_to": "Composants liés à",
"update": "Mettre à jour",
"updated": "mis à jour",
"deleted": "supprimé",
"save_restore_config": "Sauvegarder ou restaurer la configuration",
"export_config": "Exporter configuration",
"download_json": "Télécharger (JSON)",
"import_config": "Importer une configuration JSON (max 100 Ko)",
"file_too_large": "Fichier trop volumineux (max 100 Ko).",
"no_products_found": "Aucun produit trouvé dans le fichier.",
"select_products_to_restore": "Sélection des produits à restaurer",
"products_to_restore": "Produits à restaurer",
"restore_selected": "Restaurer les éléments sélectionnés",
"config_restored": "Configuration partielle restaurée avec succès.",
"import_error": "Erreur d'import :"
},
"analyse": {
"title": "Analyse du graphe",
"help": "Comment utiliser cet onglet ?",
"help_content": [
"1. Sélectionnez le niveau de départ (produit final, composant ou minerai)",
"2. Choisissez le niveau d'arrivée souhaité",
"3. Affinez votre sélection en spécifiant soit un ou des minerais à cibler spécifiquement ou des items précis à chaque niveau (optionnel)",
"4. Définissez les critères d'analyse en sélectionnant les indices de vulnérabilité pertinents",
"5. Choisissez le mode de combinaison des indices (ET/OU) selon votre besoin d'analyse",
"6. Explorez le graphique généré en utilisant les contrôles de zoom et de déplacement ; vous pouvez basculer en mode plein écran pour le graphe"
],
"selection_nodes": "Sélection des nœuds de départ et d'arrivée",
"select_level": "-- Sélectionner un niveau --",
"start_level": "Niveau de départ",
"end_level": "Niveau d'arrivée",
"select_minerals": "Sélectionner un ou plusieurs minerais",
"filter_by_minerals": "Filtrer par minerais (optionnel)",
"fine_selection": "Sélection fine des items",
"filter_start_nodes": "Filtrer par noeuds de départ (optionnel)",
"filter_end_nodes": "Filtrer par noeuds d'arrivée (optionnel)",
"vulnerability_filters": "Sélection des filtres pour identifier les vulnérabilités",
"filter_ics": "Filtrer les chemins contenant au moins minerai critique pour un composant (ICS > 66 %)",
"filter_ivc": "Filtrer les chemins contenant au moins un minerai critique par rapport à la concurrence sectorielle (IVC > 30)",
"filter_ihh": "Filtrer les chemins contenant au moins une opération critique par rapport à la concentration géographique ou industrielle (IHH pays ou acteurs > 25)",
"apply_ihh_filter": "Appliquer le filtre IHH sur :",
"countries": "Pays",
"actors": "Acteurs",
"filter_isg": "Filtrer les chemins contenant un pays instable (ISG ≥ 60)",
"filter_logic": "Logique de filtrage",
"or": "OU",
"and": "ET",
"run_analysis": "Lancer l'analyse",
"sankey": {
"no_paths": "Aucun chemin trouvé pour les critères spécifiés.",
"no_matching_paths": "Aucun chemin ne correspond aux critères.",
"filtered_hierarchy": "Hiérarchie filtrée par niveaux et noeuds",
"download_dot": "Télécharger le fichier DOT filtré",
"relation": "Relation"
}
},
"visualisations": {
"title": "Analyse du graphe",
"help": "Comment utiliser cet onglet ?",
"help_content": [
"1. Explorez les graphiques présentant l'Indice de Herfindahl-Hirschmann (IHH)",
"2. Analysez sa relation avec la criticité moyenne des minerais ou leur Indice de Vulnérabilité Concurrentielle (IVC)",
"3. Zoomer dans les graphes pour mieux découvrir les informations",
"",
"Il est important de se rappeler que l'IHH a deux seuils :",
"* en-dessous de 15, la concentration est considérée comme étant faible",
"* au-dessus de 25, elle est considérée comme étant forte",
"",
"Ainsi plus le positionnement d'un point est en haut à droite des graphiques, plus les risques sont élevés.",
"Les graphiques présentent 2 droites horizontales et vetrticales pour matérialiser ces seuils."
],
"ihh_criticality": "Indice de Herfindahl-Hirschmann - IHH vs Criticité",
"ihh_criticality_desc": "La taille des points donne l'indication de la criticité de substituabilité du minerai.",
"ihh_ivc": "Indice de Herfindahl-Hirschmann - IHH vs IVC",
"ihh_ivc_desc": "La taille des points donne l'indication de la criticité concurrentielle du minerai.",
"launch": "Lancer",
"no_data": "Aucune donnée à visualiser.",
"categories": {
"assembly": "Assemblage",
"manufacturing": "Fabrication",
"processing": "Traitement",
"extraction": "Extraction"
},
"axis_titles": {
"ihh_countries": "IHH Pays (%)",
"ihh_actors": "IHH Acteurs (%)",
"ihh_extraction": "IHH Extraction (%)",
"ihh_reserves": "IHH Réserves (%)"
},
"chart_titles": {
"concentration_criticality": "Concentration et criticité {0}",
"concentration_resources": "Concentration des ressources critiques vs vulnérabilité IVC"
}
},
"fiches": {
"title": "Découverte des fiches",
"help": "Comment utiliser cet onglet ?",
"help_content": [
"1. Parcourez la liste des fiches disponibles par catégorie",
"2. Sélectionnez une fiche pour afficher son contenu complet",
"3. Consultez les données détaillées, graphiques et analyses supplémentaires",
"4. Utilisez ces informations pour approfondir votre compréhension des vulnérabilités identifiées",
"",
"Les catégories sont les suivantes :",
"* Assemblage : opération d'assemblage des produits finaux à partir des composants",
"* Connexe : opérations diverses nécessaires pour fabriquer le numérique, mais n'entrant pas directement dans sa composition",
"* Criticités : indices utilisés pour identifier et évaluer les vulnérabilités",
"* Fabrication : opération de fabrication des composants à partir de minerais",
"* Minerai : description et opérations d'extraction et de traitement des minerais"
],
"no_files": "Aucune fiche disponible pour le moment.",
"choose_category": "Choisissez une catégorie de fiches",
"select_folder": "-- Sélectionner un dossier --",
"choose_file": "Choisissez une fiche",
"select_file": "-- Sélectionner une fiche --",
"loading_error": "Erreur lors du chargement de la fiche :",
"download_pdf": "Télécharger cette fiche en PDF",
"pdf_unavailable": "Le fichier PDF de cette fiche n'est pas disponible.",
"ticket_management": "Gestion des tickets pour cette fiche",
"tickets": {
"create_new": "Créer un nouveau ticket lié à cette fiche",
"model_load_error": "Impossible de charger le modèle de ticket.",
"contribution_type": "Type de contribution",
"specify": "Précisez",
"other": "Autre",
"concerned_card": "Fiche concernée",
"subject": "Sujet de la proposition",
"preview": "Prévisualiser le ticket",
"cancel": "Annuler",
"preview_title": "Prévisualisation du ticket",
"summary": "Résumé",
"title": "Titre",
"labels": "Labels",
"confirm": "Confirmer la création du ticket",
"created": "Ticket créé et formulaire vidé.",
"model_error": "Erreur chargement modèle :",
"no_linked_tickets": "Aucun ticket lié à cette fiche.",
"associated_tickets": "Tickets associés à cette fiche",
"moderation_notice": "ticket(s) en attente de modération ne sont pas affichés.",
"status": {
"awaiting": "En attente de traitement",
"in_progress": "En cours",
"completed": "Terminés",
"rejected": "Non retenus",
"others": "Autres"
},
"no_title": "Sans titre",
"unknown": "inconnu",
"subject_label": "Sujet",
"no_labels": "aucun",
"comments": "Commentaire(s) :",
"no_comments": "Aucun commentaire.",
"comment_error": "Erreur lors de la récupération des commentaires :",
"opened_by": "Ouvert par",
"on_date": "le",
"updated": "MAJ"
}
}
},
"node_levels": {
"0": "Produit final",
"1": "Composant",
"2": "Minerai",
"10": "Opération",
"11": "Pays d'opération",
"12": "Acteur d'opération",
"99": "Pays géographique"
},
"errors": {
"log_read_error": "Erreur lecture log:",
"graph_preview_error": "Erreur de prévisualisation du graphe :",
"graph_creation_error": "Erreur lors de la création du graphique :",
"ihh_criticality_error": "Erreur dans la visualisation IHH vs Criticité :",
"ihh_ivc_error": "Erreur dans la visualisation IHH vs IVC :",
"comment_fetch_error": "Erreur lors de la récupération des commentaires :",
"template_load_error": "Erreur chargement modèle :",
"import_error": "Erreur d'import :"
},
"buttons": {
"download": "Télécharger",
"run": "Lancer",
"save": "Enregistrer",
"cancel": "Annuler",
"confirm": "Confirmer",
"filter": "Filtrer",
"search": "Rechercher",
"create": "Créer",
"update": "Mettre à jour",
"delete": "Supprimer",
"preview": "Prévisualiser",
"export": "Exporter",
"import": "Importer",
"restore": "Restaurer",
"browse_files": "Parcourir les fichiers"
},
"ui": {
"file_uploader": {
"drag_drop_here": "Glissez-déposez votre fichier ici",
"size_limit": "Limite 100 Ko par fichier • JSON"
}
}
}

View File

@ -7,11 +7,16 @@
visibility: hidden;
}
body, html {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
body,
html {
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial,
sans-serif;
}
body, .stApp, .block-container {
body,
.stApp,
.block-container {
background-color: var(--bg-color) !important;
color: var(--text-color) !important;
}
@ -47,14 +52,20 @@ body, .stApp, .block-container {
margin-right: auto;
}
section:not([data-testid="stSidebar"]) button[data-testid="stBaseButton-primary"],
section:not([data-testid="stSidebar"]) button[data-testid="stBaseButton-secondary"] {
section:not([data-testid="stSidebar"])
button[data-testid="stBaseButton-primary"],
section:not([data-testid="stSidebar"])
button[data-testid="stBaseButton-secondary"] {
color: white !important;
background: darkgreen !important;
}
section:not([data-testid="stSidebar"]) button[data-testid="stBaseButton-primary"] p,
section:not([data-testid="stSidebar"]) button[data-testid="stBaseButton-secondary"] p {
section:not([data-testid="stSidebar"])
button[data-testid="stBaseButton-primary"]
p,
section:not([data-testid="stSidebar"])
button[data-testid="stBaseButton-secondary"]
p {
color: white !important;
}
@ -104,7 +115,9 @@ section:not([data-testid="stSidebar"]) div[role="radiogroup"] > label p {
color: var(--radio-text) !important;
}
section:not([data-testid="stSidebar"]) div[role="radiogroup"] > label[data-selected="true"] {
section:not([data-testid="stSidebar"])
div[role="radiogroup"]
> label[data-selected="true"] {
background-color: var(--radio-selected-bg) !important;
color: var(--radio-selected-text) !important;
}
@ -118,6 +131,10 @@ section[data-testid="stFileUploaderDropzone"] {
padding: 4px;
}
small {
display: none;
}
section:not([data-testid="stSidebar"]) div[data-testid="stSelectbox"] p,
section:not([data-testid="stSidebar"]) div[data-testid="stMultiSelect"] p,
section:not([data-testid="stSidebar"]) div[data-testid="stRadio"] p,
@ -208,7 +225,8 @@ table {
margin-bottom: 1.5em;
}
th, td {
th,
td {
border: 1px solid var(--table-border) !important;
padding: 8px;
text-align: left;
@ -231,53 +249,8 @@ table[role="table"] th[scope="col"] {
7. Composants spécifiques
========================================== */
/* --- 7.1 File Uploader (traductions) --- */
/* Hide original "Drag and drop file here" text */
div[data-testid="stFileUploaderDropzoneInstructions"] span:nth-of-type(1) {
visibility: hidden;
}
/* Insert French translation */
div[data-testid="stFileUploaderDropzoneInstructions"] span:nth-of-type(1)::after {
content: "Glissez-déposez votre fichier ici";
visibility: visible;
display: block;
font-size: inherit;
color: inherit;
}
/* Hide original "Browse files" button text */
div[data-testid="stFileUploaderDropzone"] button[data-testid="stBaseButton-secondary"] {
color: transparent !important;
position: relative;
}
/* Insert French translation for button */
div[data-testid="stFileUploaderDropzone"] button[data-testid="stBaseButton-secondary"]::after {
content: "Parcourir les fichiers";
visibility: visible !important;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
display: block;
font-size: inherit;
color: inherit !important;
}
/* Override Streamlit file uploader limit text */
div[data-testid="stFileUploaderDropzoneInstructions"] small {
visibility: hidden;
}
div[data-testid="stFileUploaderDropzoneInstructions"] small::after {
content: "Limite 100 Ko par fichier • JSON";
visibility: visible;
display: block;
font-size: inherit;
color: inherit;
margin-top: 0.25em;
}
/* --- 7.1 File Uploader --- */
/* File uploader styles intentionally left empty */
/* --- 7.2 Graphiques --- */
.stPlotlyChart text {
@ -312,7 +285,9 @@ details {
border-color: var(--details-border) !important;
}
section:not([data-testid="stSidebar"]) div:not[data-testid="stElementContainer"] p:not(#Authentification):not(#Theme) {
section:not([data-testid="stSidebar"])
div:not[data-testid="stElementContainer"]
p:not(#Authentification):not(#Theme) {
color: var(--paragraph-color) !important;
}
@ -321,7 +296,8 @@ section:not([data-testid="stSidebar"]) hr {
}
/* --- 7.4 Conteneurs de commentaires et tickets --- */
.conteneur_commentaire, .conteneur_ticket {
.conteneur_commentaire,
.conteneur_ticket {
background: var(--background-color);
padding: 1em;
border-radius: 8px;
@ -329,12 +305,14 @@ section:not([data-testid="stSidebar"]) hr {
border: 1px solid #ccc;
}
.commentaire_auteur, .ticket_auteur {
.commentaire_auteur,
.ticket_auteur {
color: var(--text-color) !important;
margin: 0;
}
.commentaire_contenu, .ticket_contenu {
.commentaire_contenu,
.ticket_contenu {
color: var(--text-color) !important;
margin: 0.5rem 0 0;
}

View File

@ -2,6 +2,7 @@ import streamlit as st
import requests
import logging
import os
from utils.translations import _
def initialiser_logger():
LOG_FILE_PATH = "/var/log/fabnum-auth.log"
@ -19,10 +20,11 @@ def initialiser_logger():
def connexion():
if "logged_in" not in st.session_state or not st.session_state.logged_in:
st.html("""
auth_title = str(_("auth.title", "Authentification"))
st.html(f"""
<section role="region" aria-label="region-authentification">
<div role="region" aria-labelledby="Authentification">
<p id="Authentification" class="decorative-heading">Authentification</p>
<p id="Authentification" class="decorative-heading">{auth_title}</p>
""")
GITEA_URL = "https://fabnum-git.peccini.fr/api/v1"
@ -40,9 +42,9 @@ def connexion():
with st.form("auth_form"):
# Ajout d'un champ identifiant fictif pour activer l'autocomplétion navigateur
# et permettre de stocker le token comme un mot de passe par le navigateur
identifiant = st.text_input("Identifiant_token", value="fabnum-connexion", key="nom_utilisateur")
token = st.text_input("Token d'accès personnel Gitea", type="password")
submitted = st.form_submit_button("Se connecter")
identifiant = st.text_input(str(_("auth.username", "Identifiant_token")), value="fabnum-connexion", key="nom_utilisateur")
token = st.text_input(str(_("auth.token", "Token d'accès personnel Gitea")), type="password")
submitted = st.form_submit_button(str(_("auth.login", "Se connecter")))
if submitted and token:
erreur = True
@ -76,11 +78,11 @@ def connexion():
st.rerun()
except requests.RequestException:
st.error("❌ Impossible de vérifier l'utilisateur auprès de Gitea.")
st.error(str(_("auth.gitea_error", "❌ Impossible de vérifier l'utilisateur auprès de Gitea.")))
if erreur:
logger.warning(f"Accès refusé pour tentative avec token depuis IP {ip}")
st.error("❌ Accès refusé.")
st.error(str(_("auth.error", "❌ Accès refusé.")))
st.html("""
</div>
@ -90,18 +92,19 @@ def connexion():
def bouton_deconnexion():
if st.session_state.get("logged_in", False):
st.html("""
auth_title = str(_("auth.title", "Authentification"))
st.html(f"""
<section role="region" aria-label="region-authentification">
<div role="region" aria-labelledby="Authentification">
<p id="Authentification" class="decorative-heading">Authentification</p>
<p id="Authentification" class="decorative-heading">{auth_title}</p>
""")
st.sidebar.markdown(f"Connecté en tant que `{st.session_state.username}`")
if st.sidebar.button("Se déconnecter"):
st.sidebar.markdown(f"{str(_('auth.logged_as', 'Connecté en tant que'))} `{st.session_state.username}`")
if st.sidebar.button(str(_("auth.logout", "Se déconnecter"))):
st.session_state.logged_in = False
st.session_state.username = ""
st.session_state.token = ""
st.success("Déconnecté avec succès.")
st.success(str(_("auth.success", "Déconnecté avec succès.")))
st.rerun()
st.html("""

View File

@ -1,4 +1,5 @@
import streamlit as st
from utils.translations import _
def afficher_pied_de_page():
@ -6,15 +7,15 @@ def afficher_pied_de_page():
<section role="region" aria-label="Contenu principal" id="main-content">
""", unsafe_allow_html=True)
st.markdown("""
st.markdown(f"""
<div role='contentinfo' aria-labelledby='footer-appli' class='wide-footer'>
<div class='info-footer'>
<p id='footer-appli' class='info-footer'>
Fabnum © 2025 <a href='mailto:stephan-pro@peccini.fr'>Contact</a> Licence <a href='https://creativecommons.org/licenses/by-nc-nd/4.0/deed.fr' target='_blank'>CC BY-NC-ND</a>
{_("footer.copyright", "Fabnum © 2025")} <a href='mailto:stephan-pro@peccini.fr'>{_("footer.contact", "Contact")}</a> {_("footer.license", "Licence")} <a href='https://creativecommons.org/licenses/by-nc-nd/4.0/deed.fr' target='_blank'>{_("footer.license_text", "CC BY-NC-ND")}</a>
</p>
<p class='footer-note'>
🌱 Calculs CO₂ via <a href='https://www.thegreenwebfoundation.org/' target='_blank'>The Green Web Foundation</a><br>
🚀 Propulsé par <a href='https://streamlit.io/' target='_blank'>Streamlit</a>
{_("footer.eco_note", "🌱 Calculs CO₂ via")} <a href='https://www.thegreenwebfoundation.org/' target='_blank'>{_("footer.eco_provider", "The Green Web Foundation")}</a><br>
{_("footer.powered_by", "🚀 Propulsé par")} <a href='https://streamlit.io/' target='_blank'>{_("footer.powered_by_name", "Streamlit")}</a>
</p>
</div>
</div>

View File

@ -1,18 +1,19 @@
import streamlit as st
from config import ENV
from utils.translations import _
def afficher_entete():
header = """
header = f"""
<header role="banner" aria-labelledby="entete-header">
<div class='wide-header'>
<p id='entete-header' class='titre-header'>FabNum - Chaîne de fabrication du numérique</p>
<p id='entete-header' class='titre-header'>{_("header.title", "FabNum - Chaîne de fabrication du numérique")}</p>
"""
if ENV == "dev":
header += "<p>🔧 Vous êtes dans l'environnement de développement.</p>"
header += f"<p>🔧 {_("app.dev_mode", "Vous êtes dans l'environnement de développement.")}</p>"
else:
header += "<p>Parcours de l'écosystème et identification des vulnérabilités.</p>"
header += f"<p>{_("header.subtitle", "Parcours de l'écosystème et identification des vulnérabilités.")}</p>"
header += """
</div>

View File

@ -1,26 +1,35 @@
import streamlit as st
from components.connexion import connexion, bouton_deconnexion
import streamlit.components.v1 as components
from utils.translations import _
def afficher_menu():
with st.sidebar:
st.markdown("""
<nav role="navigation" aria-label="Menu principal">
<div role="region" aria-label="Navigation principale" class="onglets-accessibles">
st.markdown(f"""
<nav role="navigation" aria-label="{str(_('sidebar.menu', 'Menu principal'))}">
<div role="region" aria-label="{str(_('sidebar.navigation', 'Navigation principale'))}" class="onglets-accessibles">
""", unsafe_allow_html=True)
# Définir la variable instructions_text une seule fois en haut de la fonction
instructions_text = str(_("navigation.instructions", "Instructions"))
if "onglet" not in st.session_state:
st.session_state.onglet = "Instructions"
st.session_state.onglet = instructions_text
onglet_choisi = None
onglets = ["Instructions", "Personnalisation", "Analyse", "Visualisations", "Fiches"]
onglets = [
str(_("navigation.instructions", "Instructions")),
str(_("navigation.personnalisation", "Personnalisation")),
str(_("navigation.analyse", "Analyse")),
str(_("navigation.visualisations", "Visualisations")),
str(_("navigation.fiches", "Fiches"))
]
for nom in onglets:
if st.session_state.onglet == nom:
st.markdown(f'<div class="bouton-fictif">{nom}</div>', unsafe_allow_html=True)
else:
if st.button(nom):
if st.button(str(nom)):
onglet_choisi = nom
st.markdown("""
@ -34,30 +43,42 @@ def afficher_menu():
# Pour éviter de perdre les informations dans les formulaires,
# le changement de thème n'est proposé que si l'utilisateur est sur l'onglet "Instructions"
#
if st.session_state.onglet == "Instructions":
if st.session_state.onglet == instructions_text:
if "theme_mode" not in st.session_state:
st.session_state.theme_mode = "Clair"
st.session_state.theme_mode = str(_("sidebar.theme_light", "Clair"))
st.markdown("""
theme_title = str(_("sidebar.theme", "Thème"))
st.markdown(f"""
<section role="region" aria-label="region-theme">
<div role="region" aria-labelledby="Theme">
<p id="Theme" class="decorative-heading">Thème</p>
<p id="Theme" class="decorative-heading">{theme_title}</p>
""", unsafe_allow_html=True)
theme = st.radio("Thème", ["Clair", "Sombre"], index=["Clair", "Sombre"].index(st.session_state.theme_mode), horizontal=True, label_visibility="hidden")
theme_options = [
str(_("sidebar.theme_light", "Clair")),
str(_("sidebar.theme_dark", "Sombre"))
]
theme = st.radio(
str(_("sidebar.theme", "Thème")),
theme_options,
index=theme_options.index(st.session_state.theme_mode),
horizontal=True,
label_visibility="hidden"
)
st.markdown("""
<hr />
</div>
</nav>""", unsafe_allow_html=True)
else :
st.markdown("""
theme_title = str(_("sidebar.theme", "Thème"))
st.markdown(f"""
<section role="region" aria-label="region-theme">
<div role="region" aria-labelledby="Theme">
<p id="Theme" class="decorative-heading">Thème</p>
<p id="Theme" class="decorative-heading">{theme_title}</p>
""", unsafe_allow_html=True)
st.info("Le changement de thème ne peut se faire que depuis l'onglet Instructions.")
st.info(str(_("sidebar.theme_instructions_only", "Le changement de thème ne peut se faire que depuis l'onglet Instructions.")))
st.markdown("""
<hr />
@ -80,6 +101,9 @@ def afficher_menu():
def afficher_impact(total_bytes):
impact_label = str(_("sidebar.impact", "Impact environnemental"))
loading_text = str(_("sidebar.loading", "Chargement en cours…"))
with st.sidebar:
components.html(f"""
<html lang="fr">
@ -115,9 +139,9 @@ def afficher_impact(total_bytes):
</head>
<body>
<hr />
<div role="region" aria-label="Impact environnemental" class="impact-environnement">
<p class="decorative-heading">Impact environnemental</p>
<p><span id="network-usage">Chargement en cours</span></p>
<div role="region" aria-label="{impact_label}" class="impact-environnement">
<p class="decorative-heading">{impact_label}</p>
<p><span id="network-usage">{loading_text}</span></p>
</div>
<script>

View File

@ -8,6 +8,9 @@ from utils.gitea import (
charger_instructions_depuis_gitea
)
# Import du module de traductions
from utils.translations import init_translations, _, set_language
def afficher_instructions_avec_expanders(markdown_content):
"""
Affiche le contenu markdown avec les sections de niveau 2 (## Titre) dans des expanders
@ -73,6 +76,12 @@ st.set_page_config(
initial_sidebar_state="expanded"
)
# Initialisation des traductions (langue française par défaut)
init_translations()
# Pour tester d'autres langues, décommenter cette ligne :
set_language("fr")
session_id = st.context.headers.get("x-session-id")
def get_total_bytes_for_session(session_id):
@ -90,35 +99,36 @@ def get_total_bytes_for_session(session_id):
return total_bytes
def charger_theme():
# Une seule lecture du fichier, mais injection à chaque run
# Chargement des fichiers CSS (une seule fois)
if "base_css_content" not in st.session_state:
with open("assets/styles/base.css") as f:
st.session_state["base_css_content"] = f.read()
st.markdown(f"<style>{st.session_state['base_css_content']}</style>", unsafe_allow_html=True)
# Chargement initial des thèmes (variables CSS uniquement)
if "theme_css_content_clair" not in st.session_state:
if "theme_css_content_light" not in st.session_state:
with open("assets/styles/theme-light.css") as f:
st.session_state["theme_css_content_clair"] = f.read()
st.session_state["theme_css_content_light"] = f.read()
if "theme_css_content_sombre" not in st.session_state:
if "theme_css_content_dark" not in st.session_state:
with open("assets/styles/theme-dark.css") as f:
st.session_state["theme_css_content_sombre"] = f.read()
st.session_state["theme_css_content_dark"] = f.read()
# Thème en cours
current_theme = st.session_state.get("theme_mode", "Clair").lower()
# Mappage des noms traduits vers les noms internes
theme_mapping = {
"clair": "light",
"sombre": "dark",
"light": "light",
"dark": "dark"
}
# Thème en cours (conversion du nom traduit vers l'identifiant interne)
current_theme_display = st.session_state.get("theme_mode", "Clair").lower()
current_theme = theme_mapping.get(current_theme_display, "light") # Par défaut light si non trouvé
theme_css = st.session_state[f"theme_css_content_{current_theme}"]
# Injection des variables du thème
# Injection des CSS dans le bon ordre
# 1. D'abord les variables du thème
st.markdown(f"<style>{theme_css}</style>", unsafe_allow_html=True)
# Chargement unique du CSS principal (base.css)
if "base_css_content" not in st.session_state:
with open("assets/styles/base.css") as f:
st.session_state["base_css_content"] = f.read()
# Injection du style principal basé sur les variables
# 2. Ensuite le CSS de base
st.markdown(f"<style>{st.session_state['base_css_content']}</style>", unsafe_allow_html=True)
def ouvrir_page():
@ -143,12 +153,19 @@ ouvrir_page()
dot_file_path = None
if st.session_state.onglet == "Instructions":
# Obtenir les noms traduits des onglets
instructions_tab = _("navigation.instructions", "Instructions")
fiches_tab = _("navigation.fiches", "Fiches")
personnalisation_tab = _("navigation.personnalisation", "Personnalisation")
analyse_tab = _("navigation.analyse", "Analyse")
visualisations_tab = _("navigation.visualisations", "Visualisations")
if st.session_state.onglet == instructions_tab:
markdown_content = charger_instructions_depuis_gitea(INSTRUCTIONS)
if markdown_content:
afficher_instructions_avec_expanders(markdown_content)
elif st.session_state.onglet == "Fiches":
elif st.session_state.onglet == fiches_tab:
interface_fiches()
else:
@ -156,13 +173,13 @@ else:
# Le graphe n'est pas nécessaire pour Instructions ou Fiches
G_temp, G_temp_ivc, dot_file_path = charger_graphe()
if dot_file_path and st.session_state.onglet == "Analyse":
if dot_file_path and st.session_state.onglet == analyse_tab:
interface_analyse(G_temp)
elif dot_file_path and st.session_state.onglet == "Visualisations":
elif dot_file_path and st.session_state.onglet == visualisations_tab:
interface_visualisations(G_temp, G_temp_ivc)
elif dot_file_path and st.session_state.onglet == "Personnalisation":
elif dot_file_path and st.session_state.onglet == personnalisation_tab:
G_temp = interface_personnalisation(G_temp)
fermer_page()

View File

@ -1,6 +1,17 @@
#
# This file is autogenerated by pip-compile with Python 3.12
# by the following command:
#
# pip-compile requirements.in
#
altair==5.5.0
beautifulsoup4==4.13.4
Jinja2==3.1.6
latex2mathml==3.78.0
Markdown==3.8
networkx==3.4.2
numpy==2.2.5
pandas==2.2.3
plotly==6.0.1
pypandoc==1.15
python-dotenv==1.1.0
python_dateutil==2.9.0.post0
python_frontmatter==1.1.0
PyYAML==6.0.1
Requests==2.32.3
streamlit==1.45.1
pygraphviz==1.14

84
utils/translations.py Normal file
View File

@ -0,0 +1,84 @@
import streamlit as st
import json
import os
import logging
# Configuration du logger
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("translations")
def load_translations(lang="fr"):
"""
Charge les traductions depuis le fichier JSON correspondant à la langue spécifiée.
Args:
lang (str): Code de langue (par défaut: "fr" pour français)
Returns:
dict: Dictionnaire des traductions ou un dictionnaire vide en cas d'erreur
"""
try:
file_path = os.path.join("assets", "locales", f"{lang}.json")
if not os.path.exists(file_path):
logger.warning(f"Fichier de traduction non trouvé: {file_path}")
return {}
with open(file_path, "r", encoding="utf-8") as f:
translations = json.load(f)
logger.info(f"Traductions chargées: {lang}")
return translations
except Exception as e:
logger.error(f"Erreur lors du chargement des traductions: {e}")
return {}
def get_translation(key, default=None):
"""
Récupère une traduction par sa clé.
Les clés peuvent être hiérarchiques, séparées par des points.
Exemple: "header.title" pour accéder à translations["header"]["title"]
Args:
key (str): Clé de traduction (peut être hiérarchique comme "header.title")
default (str, optional): Valeur par défaut si la traduction n'est pas trouvée
Returns:
str: Texte traduit ou valeur par défaut
"""
# Initialiser les traductions si nécessaire
if "translations" not in st.session_state:
st.session_state.translations = load_translations("fr")
st.session_state.lang = "fr"
# Si aucune traduction n'est chargée, retourner la valeur par défaut
if not st.session_state.get("translations"):
return default if default is not None else key
# Parcourir la hiérarchie des clés
keys = key.split(".")
current = st.session_state.translations
for k in keys:
if not isinstance(current, dict) or k not in current:
return default if default is not None else key
current = current[k]
return current
def set_language(lang="fr"):
"""
Force l'utilisation d'une langue spécifique.
Args:
lang (str): Code de langue à utiliser
"""
st.session_state.lang = lang
st.session_state.translations = load_translations(lang)
# Initialiser la langue française par défaut
def init_translations():
"""Initialise les traductions avec la langue française"""
if "translations" not in st.session_state:
set_language("fr")
# Raccourci pour get_translation
_ = get_translation

View File

@ -3,13 +3,19 @@ import altair as alt
import numpy as np
from collections import Counter
import pandas as pd
from utils.translations import _
def afficher_graphique_altair(df):
ordre_personnalise = ['Assemblage', 'Fabrication', 'Traitement', 'Extraction']
ordre_personnalise = [
str(_("pages.visualisations.categories.assembly", "Assemblage")),
str(_("pages.visualisations.categories.manufacturing", "Fabrication")),
str(_("pages.visualisations.categories.processing", "Traitement")),
str(_("pages.visualisations.categories.extraction", "Extraction"))
]
categories = [cat for cat in ordre_personnalise if cat in df['categorie'].unique()]
for cat in categories:
st.markdown(f"### {cat}")
st.markdown(f"### {str(cat)}")
df_cat = df[df['categorie'] == cat].copy()
coord_pairs = list(zip(df_cat['ihh_pays'].round(1), df_cat['ihh_acteurs'].round(1)))
@ -36,8 +42,8 @@ def afficher_graphique_altair(df):
df_cat['ihh_acteurs_text'] = df_cat['ihh_acteurs'] + 0.5
base = alt.Chart(df_cat).encode(
x=alt.X('ihh_pays:Q', title='IHH Pays (%)'),
y=alt.Y('ihh_acteurs:Q', title='IHH Acteurs (%)'),
x=alt.X('ihh_pays:Q', title=str(_("pages.visualisations.axis_titles.ihh_countries", "IHH Pays (%)"))),
y=alt.Y('ihh_acteurs:Q', title=str(_("pages.visualisations.axis_titles.ihh_actors", "IHH Acteurs (%)"))),
size=alt.Size('criticite_cat:Q', scale=alt.Scale(domain=[1, 2, 3], range=[50, 500, 1000]), legend=None),
color=alt.Color('criticite_cat:N', scale=alt.Scale(domain=[1, 2, 3], range=['darkgreen', 'orange', 'darkred']))
)
@ -64,7 +70,7 @@ def afficher_graphique_altair(df):
chart = (points + lines + labels + hline_15 + hline_25 + vline_15 + vline_25).properties(
width=500,
height=400,
title=f"Concentration et criticité {cat}"
title=str(_("pages.visualisations.chart_titles.concentration_criticality", "Concentration et criticité {0}")).format(str(cat))
).interactive()
st.altair_chart(chart, use_container_width=True)
@ -72,7 +78,7 @@ def afficher_graphique_altair(df):
def creer_graphes(donnees):
if not donnees:
st.warning("Aucune donnée à afficher.")
st.warning(str(_("pages.visualisations.no_data", "Aucune donnée à afficher.")))
return
try:
@ -103,8 +109,8 @@ def creer_graphes(donnees):
df['ihh_reserves_text'] = df['ihh_reserves'] + 0.5
base = alt.Chart(df).encode(
x=alt.X('ihh_extraction:Q', title='IHH Extraction (%)'),
y=alt.Y('ihh_reserves:Q', title='IHH Réserves (%)'),
x=alt.X('ihh_extraction:Q', title=str(_("pages.visualisations.axis_titles.ihh_extraction", "IHH Extraction (%)"))),
y=alt.Y('ihh_reserves:Q', title=str(_("pages.visualisations.axis_titles.ihh_reserves", "IHH Réserves (%)"))),
size=alt.Size('ivc_cat:Q', scale=alt.Scale(domain=[1, 2, 3], range=[50, 500, 1000]), legend=None),
color=alt.Color('ivc_cat:N', scale=alt.Scale(domain=[1, 2, 3], range=['darkgreen', 'orange', 'darkred'])),
tooltip=['nom:N', 'ivc:Q', 'ihh_extraction:Q', 'ihh_reserves:Q']
@ -132,13 +138,13 @@ def creer_graphes(donnees):
chart = (points + lines + labels + hline_15 + hline_25 + vline_15 + vline_25).properties(
width=600,
height=500,
title="Concentration des ressources critiques vs vulnérabilité IVC"
title=str(_("pages.visualisations.chart_titles.concentration_resources", "Concentration des ressources critiques vs vulnérabilité IVC"))
).interactive()
st.altair_chart(chart, use_container_width=True)
except Exception as e:
st.error(f"Erreur lors de la création du graphique : {e}")
st.error(f"{str(_('errors.graph_creation_error', 'Erreur lors de la création du graphique :'))} {e}")
def lancer_visualisation_ihh_criticite(graph):
@ -152,11 +158,11 @@ def lancer_visualisation_ihh_criticite(graph):
df = recuperer_donnees(graph, noeuds)
if df.empty:
st.warning("Aucune donnée à visualiser.")
st.warning(str(_("pages.visualisations.no_data", "Aucune donnée à visualiser.")))
else:
afficher_graphique_altair(df)
except Exception as e:
st.error(f"Erreur dans la visualisation IHH vs Criticité : {e}")
st.error(f"{str(_('errors.ihh_criticality_error', 'Erreur dans la visualisation IHH vs Criticité :'))} {e}")
def lancer_visualisation_ihh_ivc(graph):
@ -171,4 +177,4 @@ def lancer_visualisation_ihh_ivc(graph):
data = recuperer_donnees_2(graph, noeuds_niveau_2)
creer_graphes(data)
except Exception as e:
st.error(f"Erreur dans la visualisation IHH vs IVC : {e}")
st.error(f"{str(_('errors.ihh_ivc_error', 'Erreur dans la visualisation IHH vs IVC :'))} {e}")