Compare commits
29 Commits
e7ad23e390
...
f4a28db6f7
| Author | SHA1 | Date | |
|---|---|---|---|
| f4a28db6f7 | |||
|
|
99ae3123e9 | ||
| a76cde5c0f | |||
| c2a47c6eff | |||
|
|
059e94b0f3 | ||
|
|
96682783b6 | ||
| 863cce5c68 | |||
| 2577519996 | |||
|
|
120f5a7af8 | ||
|
|
91bb36fb8b | ||
|
|
55787586a8 | ||
|
|
1beb357e57 | ||
|
|
e9d129f616 | ||
|
|
d57cb1f0e2 | ||
|
|
8710014345 | ||
|
|
967ca4bcf2 | ||
|
|
5436ccff5e | ||
| acaa79d2a6 | |||
| ced0f83bdb | |||
| 4eb7cf4fee | |||
| f8c79b3875 | |||
| 5d5f181872 | |||
| bf048c70ef | |||
|
|
5421a2c3cc | ||
|
|
2fd0e28d12 | ||
|
|
fbe196e166 | ||
|
|
4cf33d74de | ||
| 1abec27b8b | |||
|
|
241ddf6794 |
14
.env
Normal file
14
.env
Normal file
@ -0,0 +1,14 @@
|
||||
ENV = "dev"
|
||||
ENV_CODE = "dev"
|
||||
PORT=8502
|
||||
DOT_FILE = "schema.txt"
|
||||
GITEA_URL = "https://fabnum-git.peccini.fr/api/v1"
|
||||
ORGANISATION = "fabnum"
|
||||
DEPOT_FICHES = "fiches"
|
||||
DEPOT_CODE = "code"
|
||||
ID_PROJET = "3"
|
||||
INSTRUCTIONS = "Instructions.md"
|
||||
FICHE_IHH = "Fiches/Criticités/Fiche technique IHH.md"
|
||||
FICHE_ICS = "Fiches/Criticités/Fiche technique ICS.md"
|
||||
FICHE_ISG = "Fiches/Criticités/Fiche technique ISG.md"
|
||||
FICHE_IVC = "Fiches/Criticités/Fiche technique IVC.md"
|
||||
14
.gitignore
vendored
14
.gitignore
vendored
@ -1,6 +1,5 @@
|
||||
# Ignorer fichiers sensibles
|
||||
.env
|
||||
*.env
|
||||
.env.local
|
||||
|
||||
# Ignorer fichiers utilisateurs
|
||||
*.pyc
|
||||
@ -12,17 +11,22 @@ __pycache__/
|
||||
.cache/
|
||||
*.log
|
||||
*.tmp
|
||||
*.old
|
||||
|
||||
# Ignorer config locale
|
||||
.ropeproject/
|
||||
.streamlit/
|
||||
venv
|
||||
venv/
|
||||
.venv/
|
||||
Local/
|
||||
HTML/
|
||||
static/
|
||||
|
||||
# Ignorer données Fiches (adapté à ton projet)
|
||||
schema.txt
|
||||
Instructions.md
|
||||
Fiches/
|
||||
|
||||
# Autres spécifiques si besoin
|
||||
.DS_Store
|
||||
|
||||
.zed
|
||||
.ropeproject
|
||||
|
||||
177
README.md
177
README.md
@ -0,0 +1,177 @@
|
||||
# Chaîne de fabrication du numérique
|
||||
|
||||
La chaîne de fabrication du numérique, qui s'étend de l'extraction des matières premières à l'assemblage final, joue un rôle central dans les activités économiques et sociétales.
|
||||
|
||||
Pourtant, cette chaîne reste insuffisamment étudiée dans son entièreté. De nombreuses analyses existantes portant sur la « criticité » des minerais ne vont pas au-delà d'une approche macroéconomique, souvent focalisée sur la seule rareté d'un métal ou sur le degré de concentration de sa production.
|
||||
|
||||
Or, dans le contexte actuel de « polycrise globale », marqué par des tensions géopolitiques, des aléas climatiques et une croissance rapide de la demande hors numérique (transition énergétique, défense, etc.), ces approches généralistes révèlent leurs limites.
|
||||
|
||||
L'objectif de ce projet est de proposer à la fois des données les plus complètes possibles sur cette chaîne, et de fournir les outils nécessaires pour la visualisation des relations, les analyses, la lecture des fiches des opérations (assemblage, fabrication, extraction, …).
|
||||
|
||||
Le code proposé répond à la partie outillage, avec une architecture modulaire et simplifiée pour faciliter la maintenance et l'évolution.
|
||||
|
||||
## Principe général
|
||||
|
||||
Le projet est bâti sur un backeng Gitea pour la gestion des fiches et des tickets d'évolution. (Accéder au backend)[https://fabnum-git.peccini.fr/FabNum/Fiches]
|
||||
|
||||
Le serveur qui héberge l'application héberge aussi le service Gitea, ce qui permet d'éliminer les temps de latence dus au réseau.
|
||||
|
||||
L'application est écrite en python et utilise majoritairement streamlit.
|
||||
|
||||
## Les données du code
|
||||
|
||||
### Requirements
|
||||
|
||||
Le fichier **requirements.txt** permet d'installer tout ce qui est nécessaire pour l'application.
|
||||
|
||||
python -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
|
||||
### Environnement
|
||||
|
||||
Le fichier **.env.local** qui contient GITEA_TOKEN n'est pas dans le dépôt car il contient la clé pour accéder au backend.
|
||||
|
||||
Pour l'environnement de pré-production, (https://fabnum-dev.peccini.fr)[https://fabnum-dev.peccini.fr] :
|
||||
|
||||
ENV=dev
|
||||
PORT=8502
|
||||
GITEA_URL = "https://fabnum-git.peccini.fr/api/v1"
|
||||
ORGANISATION = "fabnum"
|
||||
DEPOT_FICHES = "fiches"
|
||||
|
||||
Pour l'environnement de production, (https://fabnum.peccini.fr)[https://fabnum.peccini.fr], le fichier est identique sauf pour :
|
||||
|
||||
ENV=public
|
||||
PORT=8501
|
||||
|
||||
dev et public sont les deux branches officielles du dépôt.
|
||||
|
||||
L'application se lance simplement sous la forme :
|
||||
|
||||
streamlit run fabnum.py --server.port 8502
|
||||
|
||||
### Lancement par systemd
|
||||
|
||||
Ce script permet de lancer l'application en prenant en compte le fichier .env
|
||||
|
||||
Pour automatiser le lancement, il est intégré dans systemd :
|
||||
|
||||
**/etc/systemd/system/multi-user.target.wants/fabnum-dev.service**
|
||||
|
||||
[Unit]
|
||||
Description=Fabnum Dev - Streamlit App
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
User=fabnum
|
||||
WorkingDirectory=/home/fabnum/fabnum-dev
|
||||
ExecStart=/home/fabnum/fabnum-dev/venv/bin/streamlit run /home/fabnum/fabnum-dev/fa
|
||||
bnum.py
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
SELinuxContext=system_u:system_r:httpd_t:s0
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
|
||||
### fabnum.py
|
||||
|
||||
Le cœur de l'application. Ce script sert de point d'entrée et d'orchestrateur pour tous les modules fonctionnels :
|
||||
|
||||
- **Configuration de l'interface** : Initialisation de Streamlit et chargement des thèmes
|
||||
- **Gestion de la navigation** : Coordination entre les différents modules selon l'onglet sélectionné
|
||||
- **Chargement des données** : Acquisition des données depuis le backend Gitea
|
||||
- **Orchestration des modules** :
|
||||
- Module Fiches pour la consultation documentaire
|
||||
- Module Analyse pour l'étude des chaînes de dépendance
|
||||
- Module Visualisations pour les représentations graphiques
|
||||
- Module Personnalisation pour la modification du graphe
|
||||
|
||||
Ce fichier est conçu de manière modulaire, déléguant les fonctionnalités spécifiques aux modules spécialisés, ce qui facilite la maintenance et les évolutions futures.
|
||||
|
||||
## Architecture et principes de conception
|
||||
|
||||
### Modularité et simplification
|
||||
|
||||
L'application a été restructurée selon les principes suivants :
|
||||
|
||||
1. **Séparation des responsabilités** : Chaque module a une fonction bien définie
|
||||
2. **Modularité** : Les fonctionnalités sont décomposées en composants réutilisables
|
||||
3. **Simplification des fonctions** : Les fonctions complexes ont été divisées en sous-fonctions plus simples
|
||||
4. **Documentation intégrée** : Chaque module dispose de sa propre documentation
|
||||
|
||||
### Modules principaux
|
||||
|
||||
- **Module Analyse** : [Voir documentation](app/analyse/README.md)
|
||||
- Permet d'analyser les relations entre les différents éléments de la chaîne
|
||||
- Génère des visualisations Sankey pour identifier les vulnérabilités
|
||||
|
||||
- **Module Fiches** : [Voir documentation](app/fiches/README.md)
|
||||
- Gère la consultation et l'affichage des fiches techniques
|
||||
- Intègre un système de tickets pour la collaboration
|
||||
|
||||
- **Module Personnalisation** : [Voir documentation](app/personnalisation/README.md)
|
||||
- Permet d'ajouter et modifier des produits personnalisés
|
||||
- Offre des fonctionnalités d'import/export de configurations
|
||||
|
||||
- **Module Visualisations** : [Voir documentation](app/visualisations/README.md)
|
||||
- Propose diverses visualisations graphiques des données
|
||||
- Permet d'explorer les données sous différents angles
|
||||
|
||||
- **Composants d'interface** : [Voir documentation](components/README.md)
|
||||
- Fournit des éléments d'interface réutilisables
|
||||
- Assure la cohérence visuelle de l'application
|
||||
|
||||
- **Utilitaires partagés** : [Voir documentation](utils/README.md)
|
||||
- Centralise les fonctions communes utilisées par les différents modules
|
||||
- Gère l'interaction avec l'API Gitea et la manipulation des graphes
|
||||
|
||||
### Organisation du code
|
||||
|
||||
L'application est organisée de façon modulaire, avec une structure simplifiée qui facilite la maintenance :
|
||||
|
||||
```
|
||||
fabnum-dev/
|
||||
├── fabnum.py # Point d'entrée principal
|
||||
├── config.py # Configuration et variables d'environnement
|
||||
├── app/ # Modules fonctionnels principaux
|
||||
│ ├── analyse/ # Module d'analyse des chaînes de dépendance
|
||||
│ │ ├── interface.py # Interface utilisateur pour l'analyse
|
||||
│ │ ├── sankey.py # Génération des diagrammes Sankey
|
||||
│ │ └── README.md # Documentation du module
|
||||
│ ├── fiches/ # Gestion et affichage des fiches
|
||||
│ │ ├── interface.py # Interface utilisateur pour les fiches
|
||||
│ │ ├── generer.py # Génération des fiches
|
||||
│ │ ├── utils/ # Utilitaires spécifiques aux fiches
|
||||
│ │ └── README.md # Documentation du module
|
||||
│ ├── personnalisation/ # Personnalisation de la chaîne
|
||||
│ │ ├── interface.py # Interface principale
|
||||
│ │ ├── ajout.py # Ajout de produits
|
||||
│ │ ├── modification.py # Modification de produits
|
||||
│ │ ├── import_export.py # Import/export de configurations
|
||||
│ │ └── README.md # Documentation du module
|
||||
│ └── visualisations/ # Visualisations graphiques
|
||||
│ ├── interface.py # Interface des visualisations
|
||||
│ ├── graphes.py # Gestion des graphes à visualiser
|
||||
│ └── README.md # Documentation du module
|
||||
├── components/ # Composants d'interface réutilisables
|
||||
│ ├── sidebar.py # Barre latérale de navigation
|
||||
│ ├── header.py # En-tête de l'application
|
||||
│ ├── footer.py # Pied de page
|
||||
│ └── README.md # Documentation des composants
|
||||
├── utils/ # Utilitaires partagés
|
||||
│ ├── gitea.py # Connexion API Gitea
|
||||
│ ├── graph_utils.py # Manipulation des graphes
|
||||
│ └── README.md # Documentation des utilitaires
|
||||
├── assets/ # Ressources statiques
|
||||
│ ├── styles/ # Feuilles de style CSS
|
||||
│ └── impact_co2.js # Calcul d'impact environnemental
|
||||
├── .env # Configuration versionnée
|
||||
├── .env.local # Configuration locale (non versionnée)
|
||||
└── requirements.txt # Dépendances Python
|
||||
```
|
||||
|
||||
Chaque module dispose de sa propre documentation détaillée dans un fichier README.md.
|
||||
45
README_connexion.md
Normal file
45
README_connexion.md
Normal file
@ -0,0 +1,45 @@
|
||||
# Authentification à FabNum avec un token Gitea
|
||||
|
||||
Ce document explique comment générer et utiliser un token d'accès personnel Gitea pour vous connecter à l'application FabNum.
|
||||
|
||||
## Caractéristiques du token requis
|
||||
|
||||
Pour pouvoir vous connecter à FabNum, votre token Gitea doit répondre aux critères suivants :
|
||||
|
||||
- Être associé à un compte utilisateur qui est membre de l'équipe **"Administrateurs"** dans l'organisation **"FabNum"**
|
||||
- Disposer des permissions suffisantes pour :
|
||||
- Accéder aux informations de l'utilisateur
|
||||
- Lister les équipes d'une organisation
|
||||
- Vérifier l'appartenance d'un utilisateur à une équipe
|
||||
|
||||
## Procédure pour générer un nouveau token
|
||||
|
||||
1. **Connectez-vous à Gitea** :
|
||||
- Rendez-vous sur https://fabnum-git.peccini.fr et connectez-vous avec vos identifiants
|
||||
|
||||
2. **Accédez aux paramètres de votre profil** :
|
||||
- Cliquez sur votre avatar en haut à droite
|
||||
- Sélectionnez "Paramètres"
|
||||
|
||||
3. **Générez un nouveau token d'accès** :
|
||||
- Dans le menu latéral gauche, cliquez sur "Applications"
|
||||
- Faites défiler jusqu'à la section "Tokens d'accès personnel"
|
||||
- Cliquez sur "Générer un nouveau token"
|
||||
|
||||
4. **Configurez le token** :
|
||||
- Donnez un nom descriptif à votre token (par exemple "Token FabNum - Connexion")
|
||||
- **Important** : Sélectionnez les autorisations suivantes au minimum :
|
||||
- `read:user` - Accès en lecture pour les informations utilisateur
|
||||
- `read:org` - Accès en lecture pour les organisations et équipes
|
||||
|
||||
5. **Générez et copiez le token** :
|
||||
- Cliquez sur "Générer un token"
|
||||
- **IMPORTANT** : Copiez immédiatement le token généré et conservez-le en lieu sûr. Gitea ne l'affichera plus jamais après cette étape.
|
||||
|
||||
## Utilisation du token
|
||||
|
||||
Une fois le token généré, utilisez-le pour vous connecter à l'application FabNum :
|
||||
|
||||
1. Accédez à l'application FabNum
|
||||
2. Dans le formulaire d'authentification, collez votre token d'accès personnel Gitea
|
||||
3. Cliquez sur "Se connecter"
|
||||
42
app/analyse/README.md
Normal file
42
app/analyse/README.md
Normal file
@ -0,0 +1,42 @@
|
||||
# Module d'Analyse
|
||||
|
||||
Ce module permet d'analyser les relations entre les différentes parties de la chaîne de fabrication du numérique. Il offre des outils pour visualiser les flux et identifier les vulnérabilités potentielles dans la chaîne d'approvisionnement.
|
||||
|
||||
## Structure du module
|
||||
|
||||
Le module d'analyse comprend deux composants principaux :
|
||||
|
||||
- **interface.py** : Gère l'interface utilisateur pour paramétrer les analyses
|
||||
- **sankey.py** : Génère les diagrammes de flux (Sankey) pour visualiser les relations entre les éléments
|
||||
|
||||
## Fonctionnalités
|
||||
|
||||
### Interface d'analyse
|
||||
L'interface permet de :
|
||||
- Sélectionner les niveaux de départ et d'arrivée pour l'analyse (produits, composants, minerais, opérations, etc.)
|
||||
- Filtrer les données par minerais spécifiques
|
||||
- Effectuer une sélection fine des nœuds de départ et d'arrivée
|
||||
- Appliquer des filtres pour identifier les vulnérabilités :
|
||||
- Filtres ICS (criticité pour un composant)
|
||||
- Filtres IVC (criticité par rapport à la concurrence sectorielle)
|
||||
- Filtres IHH (concentration géographique ou industrielle)
|
||||
- Filtres ISG (instabilité des pays)
|
||||
- Choisir la logique de filtrage (OU, ET)
|
||||
|
||||
### Visualisation Sankey
|
||||
Le module génère des diagrammes Sankey qui :
|
||||
- Affichent les flux entre les différents éléments de la chaîne
|
||||
- Mettent en évidence les relations de dépendance
|
||||
- Permettent d'identifier visuellement les goulots d'étranglement potentiels
|
||||
- Sont interactifs et permettent d'explorer la chaîne de valeur
|
||||
|
||||
## Utilisation
|
||||
|
||||
1. Sélectionnez un niveau de départ (ex : Produit final, Composant)
|
||||
2. Choisissez un niveau d'arrivée (ex : Pays géographique, Acteur d'opération)
|
||||
3. Si nécessaire, filtrez par minerais spécifiques
|
||||
4. Affinez votre sélection avec des nœuds de départ et d'arrivée spécifiques
|
||||
5. Appliquez les filtres de vulnérabilité souhaités
|
||||
6. Lancez l'analyse pour générer le diagramme Sankey
|
||||
|
||||
Le diagramme résultant permet d'identifier visuellement les relations et points de vulnérabilité dans la chaîne d'approvisionnement du numérique.
|
||||
2
app/analyse/__init__.py
Normal file
2
app/analyse/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
# __init__.py – app/fiches
|
||||
from .interface import interface_analyse
|
||||
168
app/analyse/interface.py
Normal file
168
app/analyse/interface.py
Normal file
@ -0,0 +1,168 @@
|
||||
import streamlit as st
|
||||
from utils.translations import _
|
||||
|
||||
from .sankey import afficher_sankey
|
||||
|
||||
niveau_labels = {
|
||||
0: "Produit final",
|
||||
1: "Composant",
|
||||
2: "Minerai",
|
||||
10: "Opération",
|
||||
11: "Pays d'opération",
|
||||
12: "Acteur d'opération",
|
||||
99: "Pays géographique"
|
||||
}
|
||||
|
||||
inverse_niveau_labels = {v: k for k, v in niveau_labels.items()}
|
||||
|
||||
|
||||
def preparer_graphe(G):
|
||||
"""Nettoie et prépare le graphe pour l'analyse."""
|
||||
niveaux_temp = {
|
||||
node: int(str(attrs.get("niveau")).strip('"'))
|
||||
for node, attrs in G.nodes(data=True)
|
||||
if attrs.get("niveau") and str(attrs.get("niveau")).strip('"').isdigit()
|
||||
}
|
||||
G.remove_nodes_from([n for n in G.nodes() if n not in niveaux_temp])
|
||||
G.remove_nodes_from(
|
||||
[n for n in G.nodes() if niveaux_temp.get(n) == 10 and 'Reserves' in n])
|
||||
return G, niveaux_temp
|
||||
|
||||
|
||||
def selectionner_niveaux():
|
||||
"""Interface pour sélectionner les niveaux de départ et d'arrivée."""
|
||||
st.markdown(f"## {str(_('pages.analyse.selection_nodes'))}")
|
||||
valeur_defaut = str(_("pages.analyse.select_level"))
|
||||
niveau_choix = [valeur_defaut] + list(niveau_labels.values())
|
||||
|
||||
niveau_depart = st.selectbox(str(_("pages.analyse.start_level")), niveau_choix, key="analyse_niveau_depart")
|
||||
if niveau_depart == valeur_defaut:
|
||||
return None, None
|
||||
|
||||
niveau_depart_int = inverse_niveau_labels[niveau_depart]
|
||||
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(str(_("pages.analyse.end_level")), niveaux_arrivee_choix, key="analyse_niveau_arrivee")
|
||||
if analyse_niveau_arrivee == valeur_defaut:
|
||||
return niveau_depart_int, None
|
||||
|
||||
niveau_arrivee_int = inverse_niveau_labels[analyse_niveau_arrivee]
|
||||
return niveau_depart_int, niveau_arrivee_int
|
||||
|
||||
|
||||
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(f"### {str(_('pages.analyse.select_minerals'))}")
|
||||
# Tous les nœuds de niveau 2 (minerai)
|
||||
minerais_nodes = sorted([
|
||||
n for n, d in G.nodes(data=True)
|
||||
if d.get("niveau") and int(str(d.get("niveau")).strip('"')) == 2
|
||||
])
|
||||
|
||||
minerais_selection = st.multiselect(
|
||||
str(_("pages.analyse.filter_by_minerals")),
|
||||
minerais_nodes,
|
||||
key="analyse_minerais"
|
||||
)
|
||||
|
||||
return minerais_selection
|
||||
|
||||
|
||||
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(f"## {str(_('pages.analyse.fine_selection'))}")
|
||||
|
||||
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(str(_("pages.analyse.filter_start_nodes")),
|
||||
sorted(depart_nodes),
|
||||
key="analyse_noeuds_depart")
|
||||
noeuds_arrivee = st.multiselect(str(_("pages.analyse.filter_end_nodes")),
|
||||
sorted(arrivee_nodes),
|
||||
key="analyse_noeuds_arrivee")
|
||||
|
||||
noeuds_depart = noeuds_depart if noeuds_depart else None
|
||||
noeuds_arrivee = noeuds_arrivee if noeuds_arrivee else None
|
||||
|
||||
return noeuds_depart, noeuds_arrivee
|
||||
|
||||
|
||||
def configurer_filtres_vulnerabilite():
|
||||
"""Interface pour configurer les filtres de vulnérabilité."""
|
||||
st.markdown("---")
|
||||
st.markdown(f"## {str(_('pages.analyse.vulnerability_filters'))}")
|
||||
|
||||
filtrer_ics = st.checkbox(str(_("pages.analyse.filter_ics")),
|
||||
key="analyse_filtrer_ics")
|
||||
filtrer_ivc = st.checkbox(str(_("pages.analyse.filter_ivc")),
|
||||
key="analyse_filtrer_ivc")
|
||||
filtrer_ihh = st.checkbox(str(_("pages.analyse.filter_ihh")),
|
||||
key="analyse_filtrer_ihh")
|
||||
|
||||
ihh_type = "Pays"
|
||||
if filtrer_ihh:
|
||||
ihh_type = st.radio(str(_("pages.analyse.apply_ihh_filter")),
|
||||
[str(_("pages.analyse.countries")), str(_("pages.analyse.actors"))],
|
||||
horizontal=True,
|
||||
key="analyse_ihh_type")
|
||||
|
||||
filtrer_isg = st.checkbox(str(_("pages.analyse.filter_isg")),
|
||||
key="analyse_filtrer_isg")
|
||||
logique_filtrage = st.radio(str(_("pages.analyse.filter_logic")),
|
||||
[str(_("pages.analyse.or")), str(_("pages.analyse.and"))],
|
||||
horizontal=True,
|
||||
key="analyse_logique_filtrage")
|
||||
|
||||
return filtrer_ics, filtrer_ivc, filtrer_ihh, ihh_type, filtrer_isg, logique_filtrage
|
||||
|
||||
|
||||
def interface_analyse(G_temp):
|
||||
st.markdown(f"# {str(_('pages.analyse.title'))}")
|
||||
with st.expander(str(_("pages.analyse.help")), expanded=False):
|
||||
st.markdown("\n".join(_("pages.analyse.help_content")))
|
||||
st.markdown("---")
|
||||
|
||||
try:
|
||||
|
||||
# Préparation du graphe
|
||||
G_temp, niveaux_temp = preparer_graphe(G_temp)
|
||||
|
||||
# Sélection des niveaux
|
||||
niveau_depart, niveau_arrivee = selectionner_niveaux()
|
||||
if niveau_depart is None or niveau_arrivee is None:
|
||||
return
|
||||
|
||||
# Sélection des minerais si nécessaire
|
||||
minerais_selection = selectionner_minerais(G_temp, niveau_depart, niveau_arrivee)
|
||||
|
||||
# Sélection fine des noeuds
|
||||
noeuds_depart, noeuds_arrivee = selectionner_noeuds(G_temp, niveaux_temp, niveau_depart, niveau_arrivee)
|
||||
|
||||
# Configuration des filtres de vulnérabilité
|
||||
filtrer_ics, filtrer_ivc, filtrer_ihh, ihh_type, filtrer_isg, logique_filtrage = configurer_filtres_vulnerabilite()
|
||||
|
||||
# Lancement de l'analyse
|
||||
st.markdown("---")
|
||||
if st.button(str(_("pages.analyse.run_analysis")), type="primary", key="analyse_lancer"):
|
||||
afficher_sankey(
|
||||
G_temp,
|
||||
niveau_depart=niveau_depart,
|
||||
niveau_arrivee=niveau_arrivee,
|
||||
noeuds_depart=noeuds_depart,
|
||||
noeuds_arrivee=noeuds_arrivee,
|
||||
minerais=minerais_selection,
|
||||
filtrer_ics=filtrer_ics,
|
||||
filtrer_ivc=filtrer_ivc,
|
||||
filtrer_ihh=filtrer_ihh,
|
||||
ihh_type=ihh_type,
|
||||
filtrer_isg=filtrer_isg,
|
||||
logique_filtrage=logique_filtrage
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
st.error(f"{str(_('errors.graph_preview_error'))} {e}")
|
||||
363
app/analyse/sankey.py
Normal file
363
app/analyse/sankey.py
Normal file
@ -0,0 +1,363 @@
|
||||
import streamlit as st
|
||||
from networkx.drawing.nx_agraph import write_dot
|
||||
import pandas as pd
|
||||
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,
|
||||
extraire_chemins_vers,
|
||||
couleur_noeud
|
||||
)
|
||||
|
||||
niveau_labels = {
|
||||
0: "Produit final",
|
||||
1: "Composant",
|
||||
2: "Minerai",
|
||||
10: "Opération",
|
||||
11: "Pays d'opération",
|
||||
12: "Acteur d'opération",
|
||||
99: "Pays géographique"
|
||||
}
|
||||
|
||||
inverse_niveau_labels = {v: k for k, v in niveau_labels.items()}
|
||||
|
||||
def extraire_niveaux(G):
|
||||
"""Extrait les niveaux des nœuds du graphe"""
|
||||
niveaux = {}
|
||||
for node, attrs in G.nodes(data=True):
|
||||
niveau_str = attrs.get("niveau")
|
||||
try:
|
||||
if niveau_str:
|
||||
niveaux[node] = int(str(niveau_str).strip('"'))
|
||||
except ValueError:
|
||||
logging.warning(f"Niveau non entier pour le noeud {node}: {niveau_str}")
|
||||
return niveaux
|
||||
|
||||
def extraire_criticite(G, u, v):
|
||||
"""Extrait la criticité d'un lien entre deux nœuds"""
|
||||
data = G.get_edge_data(u, v)
|
||||
if not data:
|
||||
return 0
|
||||
if isinstance(data, dict) and all(isinstance(k, int) for k in data):
|
||||
return float(data[0].get("criticite", 0))
|
||||
return float(data.get("criticite", 0))
|
||||
|
||||
def extraire_chemins_selon_criteres(G, niveaux, niveau_depart, noeuds_depart, noeuds_arrivee, minerais):
|
||||
"""Extrait les chemins selon les critères spécifiés"""
|
||||
chemins = []
|
||||
if noeuds_depart and noeuds_arrivee:
|
||||
for nd in noeuds_depart:
|
||||
for na in noeuds_arrivee:
|
||||
tous_chemins = extraire_chemins_depuis(G, nd)
|
||||
chemins.extend([chemin for chemin in tous_chemins if na in chemin])
|
||||
elif noeuds_depart:
|
||||
for nd in noeuds_depart:
|
||||
chemins.extend(extraire_chemins_depuis(G, nd))
|
||||
elif noeuds_arrivee:
|
||||
for na in noeuds_arrivee:
|
||||
chemins.extend(extraire_chemins_vers(G, na, niveau_depart))
|
||||
else:
|
||||
sources_depart = [n for n in G.nodes() if niveaux.get(n) == niveau_depart]
|
||||
for nd in sources_depart:
|
||||
chemins.extend(extraire_chemins_depuis(G, nd))
|
||||
|
||||
if minerais:
|
||||
chemins = [chemin for chemin in chemins if any(n in minerais for n in chemin)]
|
||||
|
||||
return chemins
|
||||
|
||||
def verifier_critere_ihh(G, chemin, niveaux, ihh_type):
|
||||
"""Vérifie si un chemin respecte le critère IHH (concentration géographique ou industrielle)"""
|
||||
ihh_field = "ihh_pays" if ihh_type == "Pays" else "ihh_acteurs"
|
||||
for i in range(len(chemin) - 1):
|
||||
u, v = chemin[i], chemin[i + 1]
|
||||
niveau_u = niveaux.get(u)
|
||||
niveau_v = niveaux.get(v)
|
||||
|
||||
if niveau_u in (10, 1010) and int(G.nodes[u].get(ihh_field, 0)) > 25:
|
||||
return True
|
||||
if niveau_v in (10, 1010) and int(G.nodes[v].get(ihh_field, 0)) > 25:
|
||||
return True
|
||||
return False
|
||||
|
||||
def verifier_critere_ivc(G, chemin, niveaux):
|
||||
"""Vérifie si un chemin respecte le critère IVC (criticité par rapport à la concurrence sectorielle)"""
|
||||
for i in range(len(chemin) - 1):
|
||||
u = chemin[i]
|
||||
niveau_u = niveaux.get(u)
|
||||
if niveau_u in (2, 1002) and int(G.nodes[u].get("ivc", 0)) > 30:
|
||||
return True
|
||||
return False
|
||||
|
||||
def verifier_critere_ics(G, chemin, niveaux):
|
||||
"""Vérifie si un chemin respecte le critère ICS (criticité d'un minerai pour un composant)"""
|
||||
for i in range(len(chemin) - 1):
|
||||
u, v = chemin[i], chemin[i + 1]
|
||||
niveau_u = niveaux.get(u)
|
||||
niveau_v = niveaux.get(v)
|
||||
|
||||
if ((niveau_u == 1 and niveau_v == 2) or
|
||||
(niveau_u == 1001 and niveau_v == 1002) or
|
||||
(niveau_u == 10 and niveau_v in (1000, 1001))) and extraire_criticite(G, u, v) > 0.66:
|
||||
return True
|
||||
return False
|
||||
|
||||
def verifier_critere_isg(G, chemin, niveaux):
|
||||
"""Vérifie si un chemin contient un pays instable (ISG ≥ 60)"""
|
||||
for i in range(len(chemin) - 1):
|
||||
u, v = chemin[i], chemin[i + 1]
|
||||
|
||||
for n in (u, v):
|
||||
if niveaux.get(n) == 99 and int(G.nodes[n].get("isg", 0)) >= 60:
|
||||
return True
|
||||
elif niveaux.get(n) in (11, 12, 1011, 1012):
|
||||
for succ in G.successors(n):
|
||||
if niveaux.get(succ) == 99 and int(G.nodes[succ].get("isg", 0)) >= 60:
|
||||
return True
|
||||
return False
|
||||
|
||||
def extraire_liens_filtres(chemins, niveaux, niveau_depart, niveau_arrivee, niveaux_speciaux):
|
||||
"""Extrait les liens des chemins en respectant les niveaux"""
|
||||
liens = set()
|
||||
for chemin in chemins:
|
||||
for i in range(len(chemin) - 1):
|
||||
u, v = chemin[i], chemin[i + 1]
|
||||
niveau_u = niveaux.get(u, 999)
|
||||
niveau_v = niveaux.get(v, 999)
|
||||
if (
|
||||
(niveau_depart <= niveau_u <= niveau_arrivee or niveau_u in niveaux_speciaux)
|
||||
and (niveau_depart <= niveau_v <= niveau_arrivee or niveau_v in niveaux_speciaux)
|
||||
):
|
||||
liens.add((u, v))
|
||||
return liens
|
||||
|
||||
def filtrer_chemins_par_criteres(G, chemins, niveaux, niveau_depart, niveau_arrivee,
|
||||
filtrer_ics, filtrer_ivc, filtrer_ihh, ihh_type, filtrer_isg, logique_filtrage):
|
||||
"""Filtre les chemins selon les critères de vulnérabilité"""
|
||||
niveaux_speciaux = [1000, 1001, 1002, 1010, 1011, 1012]
|
||||
|
||||
# Extraction des liens sans filtrage
|
||||
liens_chemins = extraire_liens_filtres(chemins, niveaux, niveau_depart, niveau_arrivee, niveaux_speciaux)
|
||||
|
||||
# Si aucun filtre n'est appliqué, retourner tous les chemins
|
||||
if not any([filtrer_ics, filtrer_ivc, filtrer_ihh, filtrer_isg]):
|
||||
return liens_chemins, set()
|
||||
|
||||
# Application des filtres sur les chemins
|
||||
chemins_filtres = set()
|
||||
for chemin in chemins:
|
||||
# Vérification des critères pour ce chemin
|
||||
has_ihh = filtrer_ihh and verifier_critere_ihh(G, chemin, niveaux, ihh_type)
|
||||
has_ivc = filtrer_ivc and verifier_critere_ivc(G, chemin, niveaux)
|
||||
has_criticite = filtrer_ics and verifier_critere_ics(G, chemin, niveaux)
|
||||
has_isg_critique = filtrer_isg and verifier_critere_isg(G, chemin, niveaux)
|
||||
|
||||
# Appliquer la logique de filtrage
|
||||
if logique_filtrage == "ET":
|
||||
keep = True
|
||||
if filtrer_ihh: keep = keep and has_ihh
|
||||
if filtrer_ivc: keep = keep and has_ivc
|
||||
if filtrer_ics: keep = keep and has_criticite
|
||||
if filtrer_isg: keep = keep and has_isg_critique
|
||||
if keep:
|
||||
chemins_filtres.add(tuple(chemin))
|
||||
elif logique_filtrage == "OU":
|
||||
if has_ihh or has_ivc or has_criticite or has_isg_critique:
|
||||
chemins_filtres.add(tuple(chemin))
|
||||
|
||||
# Extraction des liens après filtrage
|
||||
liens_filtres = extraire_liens_filtres(
|
||||
chemins_filtres, niveaux, niveau_depart, niveau_arrivee, niveaux_speciaux
|
||||
)
|
||||
|
||||
return liens_filtres, chemins_filtres
|
||||
|
||||
def couleur_criticite(p):
|
||||
"""Retourne la couleur en fonction du niveau de criticité"""
|
||||
if p <= 0.33:
|
||||
return "darkgreen"
|
||||
elif p <= 0.66:
|
||||
return "orange"
|
||||
else:
|
||||
return "darkred"
|
||||
|
||||
def edge_info(G, u, v):
|
||||
"""Génère l'info-bulle pour un lien"""
|
||||
# Liste d'attributs à exclure des infobulles des liens
|
||||
attributs_exclus = ["poids", "color", "fontcolor"]
|
||||
|
||||
data = G.get_edge_data(u, v)
|
||||
if not data:
|
||||
return f"{str(_('pages.analyse.sankey.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() if k not in attributs_exclus]
|
||||
return f"{str(_('pages.analyse.sankey.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"""
|
||||
# Liste d'attributs à exclure des infobulles des nœuds
|
||||
node_attributs_exclus = ["fillcolor", "niveau"]
|
||||
|
||||
df_liens = pd.DataFrame(list(liens_chemins), columns=["source", "target"])
|
||||
df_liens = df_liens.groupby(["source", "target"]).size().reset_index(name="value")
|
||||
|
||||
df_liens["criticite"] = df_liens.apply(
|
||||
lambda row: extraire_criticite(G, row["source"], row["target"]), axis=1)
|
||||
df_liens["value"] = 0.1
|
||||
|
||||
# Ne garder que les nœuds effectivement connectés
|
||||
niveaux_speciaux = [1000, 1001, 1002, 1010, 1011, 1012]
|
||||
|
||||
# Inclure les nœuds connectés + tous les nœuds 10xx traversés dans les chemins
|
||||
noeuds_utilises = set(df_liens["source"]) | set(df_liens["target"])
|
||||
for chemin in chemins:
|
||||
for n in chemin:
|
||||
if niveaux.get(n) in niveaux_speciaux:
|
||||
noeuds_utilises.add(n)
|
||||
|
||||
df_liens["color"] = df_liens.apply(
|
||||
lambda row: couleur_criticite(row["criticite"]) if row["criticite"] > 0 else "white",
|
||||
axis=1
|
||||
)
|
||||
|
||||
all_nodes = pd.unique(df_liens[["source", "target"]].values.ravel())
|
||||
sorted_nodes = sorted(
|
||||
all_nodes, key=lambda x: niveaux.get(x, 99), reverse=True)
|
||||
node_indices = {name: i for i, name in enumerate(sorted_nodes)}
|
||||
|
||||
customdata = []
|
||||
for n in sorted_nodes:
|
||||
info = [f"{k}: {v}" for k, v in G.nodes[n].items() if k not in node_attributs_exclus]
|
||||
niveau = niveaux.get(n, 99)
|
||||
|
||||
# Ajout d'un ISG hérité si applicable
|
||||
if niveau in (11, 12, 1011, 1012):
|
||||
for succ in G.successors(n):
|
||||
if niveaux.get(succ) == 99 and "isg" in G.nodes[succ]:
|
||||
isg_val = G.nodes[succ]["isg"]
|
||||
info.append(f"isg (géographique): {isg_val}")
|
||||
break
|
||||
|
||||
customdata.append("<br>".join(info))
|
||||
|
||||
link_customdata = [
|
||||
edge_info(G, row["source"], row["target"]) for _, row in df_liens.iterrows()
|
||||
]
|
||||
|
||||
return df_liens, sorted_nodes, customdata, link_customdata, node_indices
|
||||
|
||||
def creer_graphique_sankey(G, niveaux, df_liens, sorted_nodes, customdata, link_customdata, node_indices):
|
||||
"""Crée et retourne le graphique Sankey"""
|
||||
sources = df_liens["source"].map(node_indices).tolist()
|
||||
targets = df_liens["target"].map(node_indices).tolist()
|
||||
values = df_liens["value"].tolist()
|
||||
|
||||
fig = go.Figure(go.Sankey(
|
||||
arrangement="snap",
|
||||
node=dict(
|
||||
pad=10,
|
||||
thickness=8,
|
||||
label=sorted_nodes,
|
||||
x=[niveaux.get(n, 99) / 100 for n in sorted_nodes],
|
||||
color=[couleur_noeud(n, niveaux, G) for n in sorted_nodes],
|
||||
customdata=customdata,
|
||||
hovertemplate="%{customdata}<extra></extra>"
|
||||
),
|
||||
link=dict(
|
||||
source=sources,
|
||||
target=targets,
|
||||
value=values,
|
||||
color=df_liens["color"].tolist(),
|
||||
customdata=link_customdata,
|
||||
hovertemplate="%{customdata}<extra></extra>",
|
||||
line=dict(
|
||||
width=1, # Set fixed width to 3 pixels (or use 2 if preferred)
|
||||
color="grey"
|
||||
),
|
||||
arrowlen=10
|
||||
)
|
||||
))
|
||||
|
||||
fig.update_layout(
|
||||
title_text=str(_("pages.analyse.sankey.filtered_hierarchy")),
|
||||
paper_bgcolor="white",
|
||||
plot_bgcolor="white"
|
||||
)
|
||||
|
||||
return fig
|
||||
|
||||
def exporter_graphe_filtre(G, liens_chemins):
|
||||
"""Gère l'export du graphe filtré au format DOT"""
|
||||
if not st.session_state.get("logged_in", False) or not liens_chemins:
|
||||
return
|
||||
|
||||
G_export = nx.DiGraph()
|
||||
for u, v in liens_chemins:
|
||||
G_export.add_node(u, **G.nodes[u])
|
||||
G_export.add_node(v, **G.nodes[v])
|
||||
data = G.get_edge_data(u, v)
|
||||
if isinstance(data, dict) and all(isinstance(k, int) for k in data):
|
||||
G_export.add_edge(u, v, **data[0])
|
||||
elif isinstance(data, dict):
|
||||
G_export.add_edge(u, v, **data)
|
||||
else:
|
||||
G_export.add_edge(u, v)
|
||||
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix=".dot", mode="w", encoding="utf-8") as f:
|
||||
write_dot(G_export, f.name)
|
||||
dot_path = f.name
|
||||
|
||||
with open(dot_path, encoding="utf-8") as f:
|
||||
st.download_button(
|
||||
label=str(_("pages.analyse.sankey.download_dot")),
|
||||
data=f.read(),
|
||||
file_name="graphe_filtré.dot",
|
||||
mime="text/plain"
|
||||
)
|
||||
|
||||
def afficher_sankey(
|
||||
G,
|
||||
niveau_depart, niveau_arrivee,
|
||||
noeuds_depart=None, noeuds_arrivee=None,
|
||||
minerais=None,
|
||||
filtrer_ics=False, filtrer_ivc=False,
|
||||
filtrer_ihh=False, ihh_type="Pays", filtrer_isg=False,
|
||||
logique_filtrage="OU"):
|
||||
|
||||
# Étape 1 : Extraction des niveaux des nœuds
|
||||
niveaux = extraire_niveaux(G)
|
||||
|
||||
# Étape 2 : Extraction des chemins selon les critères
|
||||
chemins = extraire_chemins_selon_criteres(G, niveaux, niveau_depart, noeuds_depart, noeuds_arrivee, minerais)
|
||||
|
||||
if not chemins:
|
||||
st.warning(str(_("pages.analyse.sankey.no_paths")))
|
||||
return
|
||||
|
||||
# Étape 3 : Filtrage des chemins selon les critères de vulnérabilité
|
||||
liens_chemins, chemins_filtres = filtrer_chemins_par_criteres(
|
||||
G, chemins, niveaux, niveau_depart, niveau_arrivee,
|
||||
filtrer_ics, filtrer_ivc, filtrer_ihh, ihh_type, filtrer_isg, logique_filtrage
|
||||
)
|
||||
|
||||
if not liens_chemins:
|
||||
st.warning(str(_("pages.analyse.sankey.no_matching_paths")))
|
||||
return
|
||||
|
||||
# Étape 4 : Préparation des données pour le graphique Sankey
|
||||
df_liens, sorted_nodes, customdata, link_customdata, node_indices = preparer_donnees_sankey(
|
||||
G, liens_chemins, niveaux,
|
||||
chemins_filtres if any([filtrer_ics, filtrer_ivc, filtrer_ihh, filtrer_isg]) else chemins
|
||||
)
|
||||
|
||||
# Étape 5 : Création et affichage du graphique Sankey
|
||||
fig = creer_graphique_sankey(G, niveaux, df_liens, sorted_nodes, customdata, link_customdata, node_indices)
|
||||
st.plotly_chart(fig)
|
||||
|
||||
# Étape 6 : Export optionnel du graphe filtré
|
||||
exporter_graphe_filtre(G, liens_chemins)
|
||||
44
app/fiches/README.md
Normal file
44
app/fiches/README.md
Normal file
@ -0,0 +1,44 @@
|
||||
# Module Fiches
|
||||
|
||||
Ce module gère la consultation, l'affichage et la manipulation des fiches techniques dans l'application FabNum. Il permet aux utilisateurs de naviguer dans la documentation structurée des différents éléments de la chaîne de fabrication du numérique.
|
||||
|
||||
## Structure du module
|
||||
|
||||
Le module fiches est organisé comme suit :
|
||||
|
||||
- **interface.py** : Point d'entrée principal qui gère l'interface utilisateur des fiches
|
||||
- **generer.py** : Responsable de la génération et du rendu des fiches
|
||||
- **utils/** : Contient des utilitaires spécifiques aux fiches
|
||||
- **dynamic/** : Composants dynamiques pour le rendu des fiches
|
||||
- **assemblage_fabrication/** : Traitement des opérations d'assemblage et de fabrication
|
||||
- **indice/** : Calcul et affichage des différents indices (IHH, IVC, etc.)
|
||||
- **minerai/** : Traitement spécifique aux fiches de minerais
|
||||
- **utils/** : Utilitaires partagés pour le rendu dynamique
|
||||
- **tickets/** : Gestion des tickets liés aux fiches
|
||||
|
||||
## Fonctionnalités
|
||||
|
||||
### Navigation et consultation
|
||||
- Affichage hiérarchique des catégories de fiches
|
||||
- Recherche de fiches par mots-clés
|
||||
- Visualisation des fiches au format Markdown avec rendu dynamique
|
||||
- Navigation entre fiches connexes
|
||||
|
||||
### Traitement dynamique
|
||||
- Génération de tableaux de synthèse pour les indices de criticité
|
||||
- Rendu des graphiques et visualisations intégrées aux fiches
|
||||
- Traitement des blocs YAML pour les opérations spécifiques
|
||||
|
||||
### Gestion des tickets
|
||||
- Création de tickets pour signaler des corrections ou des améliorations
|
||||
- Visualisation des tickets existants liés à une fiche
|
||||
- Interface structurée pour soumettre des contributions
|
||||
|
||||
## Utilisation
|
||||
|
||||
1. Accédez à l'onglet "Fiches" dans l'interface principale
|
||||
2. Explorez les catégories ou utilisez la recherche pour trouver une fiche
|
||||
3. Consultez le contenu de la fiche avec ses visualisations dynamiques
|
||||
4. Si nécessaire, utilisez l'option "Créer un ticket" pour proposer des modifications
|
||||
|
||||
Ce module est au cœur de la consultation documentaire de l'application, permettant de naviguer efficacement dans la base de connaissances sur la chaîne de fabrication du numérique.
|
||||
2
app/fiches/__init__.py
Normal file
2
app/fiches/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
# __init__.py – app/fiches
|
||||
from .interface import interface_fiches
|
||||
151
app/fiches/generer.py
Normal file
151
app/fiches/generer.py
Normal file
@ -0,0 +1,151 @@
|
||||
import re
|
||||
import os
|
||||
import yaml
|
||||
import markdown
|
||||
from bs4 import BeautifulSoup
|
||||
from latex2mathml.converter import convert as latex_to_mathml
|
||||
from .utils.fiche_utils import render_fiche_markdown
|
||||
import pypandoc
|
||||
import streamlit as st
|
||||
|
||||
from .utils.dynamic import (
|
||||
build_dynamic_sections,
|
||||
build_ivc_sections,
|
||||
build_ihh_sections,
|
||||
build_isg_sections,
|
||||
build_production_sections,
|
||||
build_minerai_sections
|
||||
)
|
||||
|
||||
# === Fonctions de transformation ===
|
||||
def remplacer_latex_par_mathml(markdown_text):
|
||||
def remplacer_bloc_display(match):
|
||||
formule_latex = match.group(1).strip()
|
||||
try:
|
||||
mathml = latex_to_mathml(formule_latex, display='block')
|
||||
return f'<div class="math-block">{mathml}</div>'
|
||||
except Exception as e:
|
||||
return f"<pre>Erreur LaTeX block: {e}</pre>"
|
||||
|
||||
def remplacer_bloc_inline(match):
|
||||
formule_latex = match.group(1).strip()
|
||||
try:
|
||||
mathml = latex_to_mathml(formule_latex, display='inline')
|
||||
return f'<span class="math-inline">{mathml}</span>'
|
||||
except Exception as e:
|
||||
return f"<code>Erreur LaTeX inline: {e}</code>"
|
||||
|
||||
markdown_text = re.sub(r"\$\$(.*?)\$\$", remplacer_bloc_display, markdown_text, flags=re.DOTALL)
|
||||
markdown_text = re.sub(r"(?<!\$)\$(.+?)\$(?!\$)", remplacer_bloc_inline, markdown_text, flags=re.DOTALL)
|
||||
return markdown_text
|
||||
|
||||
def markdown_to_html_rgaa(markdown_text, caption_text=None):
|
||||
html = markdown.markdown(markdown_text, extensions=['tables'])
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
for i, table in enumerate(soup.find_all("table"), start=1):
|
||||
table["role"] = "table"
|
||||
table["summary"] = caption_text
|
||||
if caption_text:
|
||||
caption = soup.new_tag("caption")
|
||||
caption.string = caption_text
|
||||
table.insert(len(table.contents), caption)
|
||||
for th in table.find_all("th"):
|
||||
th["scope"] = "col"
|
||||
return str(soup)
|
||||
|
||||
def rendu_html(contenu_md):
|
||||
lignes = contenu_md.split('\n')
|
||||
sections_n1 = []
|
||||
section_n1_actuelle = {"titre": None, "intro": [], "sections_n2": {}}
|
||||
section_n2_actuelle = None
|
||||
for ligne in lignes:
|
||||
if re.match(r'^#[^#]', ligne):
|
||||
if section_n1_actuelle["titre"] or section_n1_actuelle["intro"] or section_n1_actuelle["sections_n2"]:
|
||||
sections_n1.append(section_n1_actuelle)
|
||||
section_n1_actuelle = {"titre": ligne.strip('# ').strip(), "intro": [], "sections_n2": {}}
|
||||
section_n2_actuelle = None
|
||||
elif re.match(r'^##[^#]', ligne):
|
||||
section_n2_actuelle = ligne.strip('# ').strip()
|
||||
section_n1_actuelle["sections_n2"][section_n2_actuelle] = [f"## {section_n2_actuelle}"]
|
||||
elif section_n2_actuelle:
|
||||
section_n1_actuelle["sections_n2"][section_n2_actuelle].append(ligne)
|
||||
else:
|
||||
section_n1_actuelle["intro"].append(ligne)
|
||||
|
||||
if section_n1_actuelle["titre"] or section_n1_actuelle["intro"] or section_n1_actuelle["sections_n2"]:
|
||||
sections_n1.append(section_n1_actuelle)
|
||||
|
||||
bloc_titre = sections_n1[0]["titre"] if sections_n1 and sections_n1[0]["titre"] else "fiche"
|
||||
titre_id = re.sub(r'\W+', '-', bloc_titre.lower()).strip('-')
|
||||
|
||||
html_output = [f'<section role="region" aria-labelledby="{titre_id}">', f'<h1 id="{titre_id}">{bloc_titre}</h1>']
|
||||
for bloc in sections_n1:
|
||||
if bloc["titre"] and bloc["titre"] != bloc_titre:
|
||||
html_output.append(f"<h2>{bloc['titre']}</h2>")
|
||||
if bloc["intro"]:
|
||||
intro_md = remplacer_latex_par_mathml("\n".join(bloc["intro"]))
|
||||
html_intro = markdown_to_html_rgaa(intro_md)
|
||||
html_output.append(html_intro)
|
||||
for sous_titre, contenu in bloc["sections_n2"].items():
|
||||
contenu_md = remplacer_latex_par_mathml("\n".join(contenu))
|
||||
contenu_html = markdown_to_html_rgaa(contenu_md, caption_text=sous_titre)
|
||||
html_output.append(f"<details><summary>{sous_titre}</summary>{contenu_html}</details>")
|
||||
|
||||
html_output.append("</section>")
|
||||
|
||||
return html_output
|
||||
|
||||
def generer_fiche(md_source, dossier, nom_fichier, seuils):
|
||||
front_match = re.match(r"(?s)^---\n(.*?)\n---\n", md_source)
|
||||
context = yaml.safe_load(front_match.group(1)) if front_match else {}
|
||||
|
||||
type_fiche = context.get("type_fiche")
|
||||
if type_fiche == "indice":
|
||||
indice = context.get("indice_court")
|
||||
if indice == "ICS":
|
||||
md_source = build_dynamic_sections(md_source)
|
||||
elif indice == "IVC":
|
||||
md_source = build_ivc_sections(md_source)
|
||||
elif indice == "IHH":
|
||||
md_source = build_ihh_sections(md_source)
|
||||
elif indice == "ISG":
|
||||
md_source = build_isg_sections(md_source)
|
||||
elif type_fiche in ["assemblage", "fabrication"]:
|
||||
md_source = build_production_sections(md_source)
|
||||
elif type_fiche == "minerai":
|
||||
md_source = build_minerai_sections(md_source)
|
||||
|
||||
contenu_md = render_fiche_markdown(md_source, seuils, license_path="assets/licence.md")
|
||||
|
||||
md_path = os.path.join("Fiches", dossier, nom_fichier)
|
||||
os.makedirs(os.path.dirname(md_path), exist_ok=True)
|
||||
with open(md_path, "w", encoding="utf-8") as f:
|
||||
f.write(contenu_md)
|
||||
|
||||
# Génération automatique du PDF
|
||||
pdf_dir = os.path.join("static", "Fiches", dossier)
|
||||
os.makedirs(pdf_dir, exist_ok=True)
|
||||
|
||||
# Construire le chemin PDF correspondant (même nom que .md, mais .pdf)
|
||||
nom_pdf = os.path.splitext(nom_fichier)[0] + ".pdf"
|
||||
pdf_path = os.path.join(pdf_dir, nom_pdf)
|
||||
|
||||
try:
|
||||
pypandoc.convert_file(
|
||||
md_path,
|
||||
to="pdf",
|
||||
outputfile=pdf_path,
|
||||
extra_args=["--pdf-engine=xelatex", "-V", "geometry:margin=2cm"]
|
||||
)
|
||||
except Exception as e:
|
||||
st.error(f"[ERREUR] Génération PDF échouée pour {md_path}: {e}")
|
||||
|
||||
html_output = rendu_html(contenu_md)
|
||||
|
||||
html_dir = os.path.join("HTML", dossier)
|
||||
os.makedirs(html_dir, exist_ok=True)
|
||||
html_path = os.path.join(html_dir, os.path.splitext(nom_fichier)[0] + ".html")
|
||||
with open(html_path, "w", encoding="utf-8") as f:
|
||||
f.write("\n".join(html_output))
|
||||
|
||||
return html_path
|
||||
105
app/fiches/interface.py
Normal file
105
app/fiches/interface.py
Normal file
@ -0,0 +1,105 @@
|
||||
# === Constantes et imports ===
|
||||
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
|
||||
from .utils.tickets.core import rechercher_tickets_gitea
|
||||
|
||||
from config import GITEA_TOKEN, GITEA_URL, ORGANISATION, DEPOT_FICHES, ENV, FICHES_CRITICITE
|
||||
|
||||
from utils.gitea import charger_arborescence_fiches
|
||||
|
||||
from .utils.fiche_utils import load_seuils, doit_regenerer_fiche
|
||||
|
||||
from .generer import generer_fiche
|
||||
|
||||
def interface_fiches():
|
||||
st.markdown(f"# {str(_('pages.fiches.title'))}")
|
||||
with st.expander(str(_("pages.fiches.help")), expanded=False):
|
||||
st.markdown("\n".join(_("pages.fiches.help_content")))
|
||||
st.markdown("---")
|
||||
|
||||
if "fiches_arbo" not in st.session_state:
|
||||
st.session_state["fiches_arbo"] = charger_arborescence_fiches()
|
||||
|
||||
arbo = st.session_state.get("fiches_arbo", {})
|
||||
if not arbo:
|
||||
st.warning(str(_("pages.fiches.no_files")))
|
||||
return
|
||||
|
||||
dossiers = sorted(arbo.keys(), key=lambda x: x.lower())
|
||||
dossier_choisi = st.selectbox(
|
||||
str(_("pages.fiches.choose_category",)),
|
||||
[str(_("pages.fiches.select_folder"))] + dossiers
|
||||
)
|
||||
|
||||
if dossier_choisi and dossier_choisi != str(_("pages.fiches.select_folder")):
|
||||
fiches = arbo.get(dossier_choisi, [])
|
||||
noms_fiches = [f['nom'] for f in fiches]
|
||||
fiche_choisie = st.selectbox(
|
||||
str(_("pages.fiches.choose_file")),
|
||||
[str(_("pages.fiches.select_file"))] + noms_fiches
|
||||
)
|
||||
|
||||
if fiche_choisie and fiche_choisie != str(_("pages.fiches.select_file")):
|
||||
fiche_info = next((f for f in fiches if f["nom"] == fiche_choisie), None)
|
||||
if fiche_info:
|
||||
try:
|
||||
headers = {"Authorization": f"token {GITEA_TOKEN}"}
|
||||
reponse_fiche = requests.get(fiche_info["download_url"], headers=headers)
|
||||
reponse_fiche.raise_for_status()
|
||||
md_source = reponse_fiche.text
|
||||
|
||||
if "seuils" not in st.session_state:
|
||||
SEUILS = load_seuils("assets/config.yaml")
|
||||
st.session_state["seuils"] = SEUILS
|
||||
else:
|
||||
SEUILS = st.session_state["seuils"]
|
||||
|
||||
nom_fiche = os.path.splitext(fiche_choisie)[0]
|
||||
html_path = os.path.join("HTML", dossier_choisi, nom_fiche + ".html")
|
||||
path_relative = f"Documents/{dossier_choisi}/{fiche_choisie}"
|
||||
commits_url = f"{GITEA_URL}/repos/{ORGANISATION}/{DEPOT_FICHES}/commits?path={path_relative}&sha={ENV}"
|
||||
|
||||
regenerate = doit_regenerer_fiche(
|
||||
html_path=html_path,
|
||||
fiche_type=fiche_info.get("type", ""),
|
||||
fiche_choisie=fiche_choisie,
|
||||
commit_url=commits_url,
|
||||
fichiers_criticite=FICHES_CRITICITE
|
||||
)
|
||||
|
||||
if regenerate:
|
||||
html_path = generer_fiche(md_source, dossier_choisi, fiche_choisie, SEUILS)
|
||||
|
||||
with open(html_path, "r", encoding="utf-8") as f:
|
||||
st.markdown(f.read(), unsafe_allow_html=True)
|
||||
|
||||
if st.session_state.get("logged_in", False):
|
||||
pdf_name = nom_fiche + ".pdf"
|
||||
pdf_path = os.path.join("static", "Fiches", dossier_choisi, pdf_name)
|
||||
|
||||
# Bouton de téléchargement du PDF
|
||||
if os.path.exists(pdf_path):
|
||||
with open(pdf_path, "rb") as pdf_file:
|
||||
st.download_button(
|
||||
label=str(_("pages.fiches.download_pdf")),
|
||||
data=pdf_file,
|
||||
file_name=pdf_name,
|
||||
mime="application/pdf",
|
||||
help=str(_("pages.fiches.download_pdf")),
|
||||
key="telecharger_fiche_pdf"
|
||||
)
|
||||
else:
|
||||
st.warning(str(_("pages.fiches.pdf_unavailable")))
|
||||
|
||||
st.markdown(f"## {str(_('pages.fiches.ticket_management'))}")
|
||||
afficher_tickets_par_fiche(rechercher_tickets_gitea(fiche_choisie))
|
||||
formulaire_creation_ticket_dynamique(fiche_choisie)
|
||||
|
||||
except Exception as e:
|
||||
st.error(f"{str(_('pages.fiches.loading_error', 'Erreur lors du chargement de la fiche :'))} {e}")
|
||||
5
app/fiches/utils/dynamic/README.md
Normal file
5
app/fiches/utils/dynamic/README.md
Normal file
@ -0,0 +1,5 @@
|
||||
Ce répertoire contient le code nécessaire à la création d'une fiche, quel que soit son type.
|
||||
|
||||
Les sous-répertoires correspondent aux différents types de fiche.
|
||||
|
||||
Le sous-répertoire utils contient les parties communes du code.
|
||||
14
app/fiches/utils/dynamic/__init__.py
Normal file
14
app/fiches/utils/dynamic/__init__.py
Normal file
@ -0,0 +1,14 @@
|
||||
# __init__.py
|
||||
|
||||
from .indice.ics import build_dynamic_sections
|
||||
from .indice.ivc import build_ivc_sections
|
||||
from .indice.ihh import build_ihh_sections
|
||||
from .indice.isg import build_isg_sections
|
||||
from .assemblage_fabrication.production import build_production_sections
|
||||
from .minerai.minerai import (
|
||||
build_minerai_sections,
|
||||
build_minerai_ics_section,
|
||||
build_minerai_ivc_section,
|
||||
build_minerai_ics_composant_section
|
||||
)
|
||||
from .utils.pastille import pastille
|
||||
140
app/fiches/utils/dynamic/assemblage_fabrication/production.py
Normal file
140
app/fiches/utils/dynamic/assemblage_fabrication/production.py
Normal file
@ -0,0 +1,140 @@
|
||||
# production.py
|
||||
# Ce module gère à la fois les fiches d'assemblage ET de fabrication.
|
||||
|
||||
import re
|
||||
import yaml
|
||||
import streamlit as st
|
||||
from config import FICHES_CRITICITE
|
||||
|
||||
def build_production_sections(md: str) -> str:
|
||||
schema = None
|
||||
front_match = re.match(r"(?s)^---\n(.*?)\n---\n", md)
|
||||
if front_match:
|
||||
try:
|
||||
front_matter = yaml.safe_load(front_match.group(1))
|
||||
schema = front_matter.get("schema")
|
||||
type_fiche = front_matter.get("type_fiche")
|
||||
if type_fiche not in ["assemblage", "fabrication"] or not schema:
|
||||
return md
|
||||
except Exception as e:
|
||||
st.error(f"Erreur lors du chargement du front matter: {e}")
|
||||
return md
|
||||
|
||||
yaml_block = re.search(r"```yaml\n(.+?)\n```", md, re.DOTALL)
|
||||
if not yaml_block:
|
||||
return md
|
||||
|
||||
# Capture le bloc YAML complet pour le supprimer plus tard
|
||||
yaml_block_full = yaml_block.group(0)
|
||||
|
||||
try:
|
||||
yaml_data = yaml.safe_load(yaml_block.group(1))
|
||||
except Exception as e:
|
||||
st.error(f"Erreur lors du chargement du YAML: {e}")
|
||||
return md
|
||||
|
||||
if not isinstance(yaml_data, dict) or len(yaml_data) == 0:
|
||||
return md
|
||||
|
||||
produit_key = list(yaml_data.keys())[0]
|
||||
produit_data = yaml_data[produit_key]
|
||||
|
||||
pays_data = []
|
||||
for pays_key, pays_info in produit_data.items():
|
||||
nom_pays = pays_info.get('nom_du_pays', '')
|
||||
part_marche_pays = pays_info.get('part_de_marche', '0%')
|
||||
part_marche_num = float(part_marche_pays.strip('%'))
|
||||
|
||||
acteurs = []
|
||||
for acteur_key, acteur_info in pays_info.get('acteurs', {}).items():
|
||||
nom_acteur = acteur_info.get('nom_de_l_acteur', '')
|
||||
part_marche_acteur = acteur_info.get('part_de_marche', '0%')
|
||||
pays_origine = acteur_info.get('pays_d_origine', '')
|
||||
part_marche_acteur_num = float(part_marche_acteur.strip('%'))
|
||||
|
||||
acteurs.append({
|
||||
'nom': nom_acteur,
|
||||
'part_marche': part_marche_acteur,
|
||||
'part_marche_num': part_marche_acteur_num,
|
||||
'pays_origine': pays_origine
|
||||
})
|
||||
|
||||
acteurs_tries = sorted(acteurs, key=lambda x: x['part_marche_num'], reverse=True)
|
||||
pays_data.append({
|
||||
'nom': nom_pays,
|
||||
'part_marche': part_marche_pays,
|
||||
'part_marche_num': part_marche_num,
|
||||
'acteurs': acteurs_tries
|
||||
})
|
||||
|
||||
pays_tries = sorted(pays_data, key=lambda x: x['part_marche_num'], reverse=True)
|
||||
|
||||
lignes_tableau = [
|
||||
"| **Pays d'implantation** | **Entreprise** | **Pays d'origine** | **Part de marché** |",
|
||||
"| :-- | :-- | :-- | :-- |"
|
||||
]
|
||||
|
||||
for pays in pays_tries:
|
||||
for acteur in pays['acteurs']:
|
||||
part_marche_formattee = acteur['part_marche'].strip('%') + ' %'
|
||||
lignes_tableau.append(
|
||||
f"| {pays['nom']} | {acteur['nom']} | {acteur['pays_origine']} | {part_marche_formattee} |"
|
||||
)
|
||||
part_marche_pays_formattee = pays['part_marche'].strip('%') + ' %'
|
||||
lignes_tableau.append(
|
||||
f"| **{pays['nom']}** | **Total** | **{pays['nom']}** | **{part_marche_pays_formattee}** |"
|
||||
)
|
||||
|
||||
tableau_final = "\n".join(lignes_tableau)
|
||||
|
||||
if type_fiche == "fabrication":
|
||||
md_modifie = re.sub(
|
||||
r"<!---- AUTO-BEGIN:TABLEAU-FABRICANTS -->.*?<!---- AUTO-END:TABLEAU-FABRICANTS -->",
|
||||
f"<!---- AUTO-BEGIN:TABLEAU-FABRICANTS -->\n{tableau_final}\n<!---- AUTO-END:TABLEAU-FABRICANTS -->",
|
||||
md,
|
||||
flags=re.DOTALL
|
||||
)
|
||||
else:
|
||||
md_modifie = re.sub(
|
||||
r"<!---- AUTO-BEGIN:TABLEAU-ASSEMBLEURS -->.*?<!---- AUTO-END:TABLEAU-ASSEMBLEURS -->",
|
||||
f"<!---- AUTO-BEGIN:TABLEAU-ASSEMBLEURS -->\n{tableau_final}\n<!---- AUTO-END:TABLEAU-ASSEMBLEURS -->",
|
||||
md,
|
||||
flags=re.DOTALL
|
||||
)
|
||||
# Chercher et remplacer la section IHH si un schéma a été identifié
|
||||
if schema:
|
||||
# Charger le contenu de la fiche technique IHH
|
||||
try:
|
||||
# Essayer de lire le fichier depuis le système de fichiers
|
||||
with open(FICHES_CRITICITE["IHH"], "r", encoding="utf-8") as f:
|
||||
ihh_content = f.read()
|
||||
|
||||
# Chercher la section IHH correspondant au schéma et au type de fiche
|
||||
# Format de la section : ## Assemblage/Fabrication - [Schema]
|
||||
if type_fiche == "fabrication":
|
||||
ihh_section_pattern = rf"## Fabrication - {schema}\s*\n### Indice de Herfindahl-Hirschmann[\s\S]*?(?=\n## |$)"
|
||||
else: # type_fiche == "assemblage"
|
||||
ihh_section_pattern = rf"## Assemblage - {schema}\s*\n### Indice de Herfindahl-Hirschmann[\s\S]*?(?=\n## |$)"
|
||||
ihh_section_match = re.search(ihh_section_pattern, ihh_content)
|
||||
|
||||
if ihh_section_match:
|
||||
# Extraire la section complète sans le titre principal
|
||||
ihh_section = ihh_section_match.group(0).split("\n", 2)[2].strip()
|
||||
|
||||
# Remplacer la section IHH dans la fiche d'assemblage
|
||||
md_modifie = re.sub(
|
||||
r"<!---- AUTO-BEGIN:SECTION-IHH -->.*?<!---- AUTO-END:SECTION-IHH -->",
|
||||
f"<!---- AUTO-BEGIN:SECTION-IHH -->\n{ihh_section}\n<!---- AUTO-END:SECTION-IHH -->",
|
||||
md_modifie,
|
||||
flags=re.DOTALL
|
||||
)
|
||||
else:
|
||||
# Si aucune section IHH n'est trouvée pour ce schéma, laisser la section existante
|
||||
st.warning(f"Aucune section IHH trouvée pour le schéma {schema} dans la fiche technique IHH.")
|
||||
except Exception as e:
|
||||
st.error(f"Erreur lors de la lecture/traitement de la fiche IHH: {e}")
|
||||
|
||||
# Supprimer le bloc YAML du markdown final
|
||||
md_modifie = md_modifie.replace(yaml_block_full, "")
|
||||
|
||||
return md_modifie
|
||||
90
app/fiches/utils/dynamic/indice/ics.py
Normal file
90
app/fiches/utils/dynamic/indice/ics.py
Normal file
@ -0,0 +1,90 @@
|
||||
# ics.py
|
||||
|
||||
import re
|
||||
import yaml
|
||||
import pandas as pd
|
||||
import unicodedata
|
||||
import textwrap
|
||||
|
||||
PAIR_RE = re.compile(r"```yaml[^\n]*\n(.*?)```", re.S | re.I)
|
||||
|
||||
def _normalize_unicode(text: str) -> str:
|
||||
return unicodedata.normalize("NFKC", text)
|
||||
|
||||
def _pairs_dataframe(md: str) -> pd.DataFrame:
|
||||
rows = []
|
||||
for raw in PAIR_RE.findall(md):
|
||||
bloc = yaml.safe_load(raw)
|
||||
if isinstance(bloc, dict) and "pair" in bloc:
|
||||
rows.append(bloc["pair"])
|
||||
return pd.DataFrame(rows)
|
||||
|
||||
def _fill(segment: str, pair: dict) -> str:
|
||||
segment = _normalize_unicode(segment)
|
||||
for k, v in pair.items():
|
||||
val = f"{v:.2f}" if isinstance(v, (int, float)) else str(v)
|
||||
segment = re.sub(
|
||||
rf"{{{{\s*{re.escape(k)}\s*}}}}",
|
||||
val,
|
||||
segment,
|
||||
flags=re.I,
|
||||
)
|
||||
segment = re.sub(
|
||||
r"ICS\s*=\s*[-+]?\d+(?:\.\d+)?",
|
||||
f"ICS = {pair['ics']:.2f}",
|
||||
segment,
|
||||
count=1,
|
||||
)
|
||||
return segment
|
||||
|
||||
def _segments(md: str):
|
||||
blocs = list(PAIR_RE.finditer(md))
|
||||
for i, match in enumerate(blocs):
|
||||
pair = yaml.safe_load(match.group(1))["pair"]
|
||||
start = match.end()
|
||||
end = blocs[i + 1].start() if i + 1 < len(blocs) else len(md)
|
||||
segment = md[start:end]
|
||||
yield pair, segment
|
||||
|
||||
def _pivot(df: pd.DataFrame) -> str:
|
||||
out = []
|
||||
for min_, g in df.groupby("minerai"):
|
||||
out += [f"## {min_}",
|
||||
"| Composant | ICS | Faisabilité technique | Délai d'implémentation | Impact économique |",
|
||||
"| :-- | :--: | :--: | :--: | :--: |"]
|
||||
for _, r in g.sort_values("ics", ascending=False).iterrows():
|
||||
out += [f"| {r.composant} | {r.ics:.2f} | {r.f_tech:.2f} | "
|
||||
f"{r.delai:.2f} | {r.cout:.2f} |"]
|
||||
out.append("")
|
||||
return "\n".join(out)
|
||||
|
||||
def _synth(df: pd.DataFrame) -> str:
|
||||
lignes = ["| Composant | Minerai | ICS |", "| :-- | :-- | :--: |"]
|
||||
for _, r in df.sort_values("ics", ascending=False).iterrows():
|
||||
lignes.append(f"| {r.composant} | {r.minerai} | {r.ics:.2f} |")
|
||||
return "\n".join(lignes)
|
||||
|
||||
def build_dynamic_sections(md_raw: str) -> str:
|
||||
md_raw = _normalize_unicode(md_raw)
|
||||
df = _pairs_dataframe(md_raw)
|
||||
if df.empty:
|
||||
return md_raw
|
||||
|
||||
couples = ["# Criticité par couple Composant -> Minerai"]
|
||||
for pair, seg in _segments(md_raw):
|
||||
if pair:
|
||||
couples.append(_fill(seg, pair))
|
||||
couples_md = "\n".join(couples)
|
||||
|
||||
pivot_md = _pivot(df)
|
||||
synth_md = _synth(df)
|
||||
|
||||
md = re.sub(r"#\s+Criticité par couple.*", couples_md, md_raw, flags=re.S | re.I)
|
||||
md = re.sub(r"<!---- AUTO-BEGIN:PIVOT -->.*?<!---- AUTO-END:PIVOT -->",
|
||||
f"<!---- AUTO-BEGIN:PIVOT -->\n{pivot_md}\n<!---- AUTO-END:PIVOT -->",
|
||||
md, flags=re.S)
|
||||
md = re.sub(r"<!---- AUTO-BEGIN:TABLEAU-FINAL -->.*?<!---- AUTO-END:TABLEAU-FINAL -->",
|
||||
f"<!---- AUTO-BEGIN:TABLEAU-FINAL -->\n{synth_md}\n<!---- AUTO-END:TABLEAU-FINAL -->",
|
||||
md, flags=re.S)
|
||||
|
||||
return textwrap.dedent(md)
|
||||
180
app/fiches/utils/dynamic/indice/ihh.py
Normal file
180
app/fiches/utils/dynamic/indice/ihh.py
Normal file
@ -0,0 +1,180 @@
|
||||
# ihh.py
|
||||
|
||||
import re
|
||||
import yaml
|
||||
from jinja2 import Template
|
||||
from ..utils.pastille import pastille
|
||||
|
||||
IHH_RE = re.compile(r"```yaml\s+opération:(.*?)```", re.S | re.I)
|
||||
|
||||
def _extraire_donnees_operations(operations: list[dict]) -> dict:
|
||||
"""Extrait et organise les données des opérations par item."""
|
||||
data_by_item = {}
|
||||
|
||||
for op in operations:
|
||||
item_id = op.get('minerai', op.get('produit', op.get('composant', '')))
|
||||
if not item_id:
|
||||
continue
|
||||
|
||||
if item_id not in data_by_item:
|
||||
data_by_item[item_id] = {
|
||||
'type': 'minerai' if 'extraction' in op or 'reserves' in op or 'traitement' in op else
|
||||
'produit' if 'assemblage' in op else 'composant',
|
||||
'extraction_ihh_pays': '-',
|
||||
'extraction_ihh_acteurs': '-',
|
||||
'reserves_ihh_pays': '-',
|
||||
'traitement_ihh_pays': '-',
|
||||
'traitement_ihh_acteurs': '-',
|
||||
'assemblage_ihh_pays': '-',
|
||||
'assemblage_ihh_acteurs': '-',
|
||||
'fabrication_ihh_pays': '-',
|
||||
'fabrication_ihh_acteurs': '-'
|
||||
}
|
||||
|
||||
if 'extraction' in op:
|
||||
data_by_item[item_id]['extraction_ihh_pays'] = op['extraction'].get('ihh_pays', '-')
|
||||
data_by_item[item_id]['extraction_ihh_acteurs'] = op['extraction'].get('ihh_acteurs', '-')
|
||||
data_by_item[item_id]['reserves_ihh_pays'] = op['reserves'].get('ihh_pays', '-')
|
||||
data_by_item[item_id]['traitement_ihh_pays'] = op['traitement'].get('ihh_pays', '-')
|
||||
data_by_item[item_id]['traitement_ihh_acteurs'] = op['traitement'].get('ihh_acteurs', '-')
|
||||
elif 'assemblage' in op:
|
||||
data_by_item[item_id]['assemblage_ihh_pays'] = op['assemblage'].get('ihh_pays', '-')
|
||||
data_by_item[item_id]['assemblage_ihh_acteurs'] = op['assemblage'].get('ihh_acteurs', '-')
|
||||
elif 'fabrication' in op:
|
||||
data_by_item[item_id]['fabrication_ihh_pays'] = op['fabrication'].get('ihh_pays', '-')
|
||||
data_by_item[item_id]['fabrication_ihh_acteurs'] = op['fabrication'].get('ihh_acteurs', '-')
|
||||
|
||||
return data_by_item
|
||||
|
||||
|
||||
def _generer_tableau_produits(produits: dict) -> str:
|
||||
"""Génère un tableau markdown pour les produits."""
|
||||
if not produits:
|
||||
return ""
|
||||
|
||||
resultat = ["\n\n## Assemblage des produits\n"]
|
||||
lignes = [
|
||||
"| Produit | Assemblage IHH Pays | Assemblage IHH Acteurs |",
|
||||
"| :-- | :--: | :--: |"
|
||||
]
|
||||
|
||||
for produit, data in sorted(produits.items()):
|
||||
pastille_1 = pastille("IHH", data['assemblage_ihh_pays'])
|
||||
pastille_2 = pastille("IHH", data['assemblage_ihh_acteurs'])
|
||||
lignes.append(
|
||||
f"| {produit} | {pastille_1} {data['assemblage_ihh_pays']} | {pastille_2} {data['assemblage_ihh_acteurs']} |"
|
||||
)
|
||||
|
||||
resultat.append("\n".join(lignes))
|
||||
return "\n".join(resultat)
|
||||
|
||||
|
||||
def _generer_tableau_composants(composants: dict) -> str:
|
||||
"""Génère un tableau markdown pour les composants."""
|
||||
if not composants:
|
||||
return ""
|
||||
|
||||
resultat = ["\n\n## Fabrication des composants\n"]
|
||||
lignes = [
|
||||
"| Composant | Fabrication IHH Pays | Fabrication IHH Acteurs |",
|
||||
"| :-- | :--: | :--: |"
|
||||
]
|
||||
|
||||
for composant, data in sorted(composants.items()):
|
||||
pastille_1 = pastille("IHH", data['fabrication_ihh_pays'])
|
||||
pastille_2 = pastille("IHH", data['fabrication_ihh_acteurs'])
|
||||
lignes.append(
|
||||
f"| {composant} | {pastille_1} {data['fabrication_ihh_pays']} | {pastille_2} {data['fabrication_ihh_acteurs']} |"
|
||||
)
|
||||
|
||||
resultat.append("\n".join(lignes))
|
||||
return "\n".join(resultat)
|
||||
|
||||
|
||||
def _generer_tableau_minerais(minerais: dict) -> str:
|
||||
"""Génère un tableau markdown pour les minerais."""
|
||||
if not minerais:
|
||||
return ""
|
||||
|
||||
resultat = ["\n\n## Opérations sur les minerais\n"]
|
||||
lignes = [
|
||||
"| Minerai | Extraction IHH Pays | Extraction IHH Acteurs | Réserves IHH Pays | Traitement IHH Pays | Traitement IHH Acteurs |",
|
||||
"| :-- | :--: | :--: | :--: | :--: | :--: |"
|
||||
]
|
||||
|
||||
for minerai, data in sorted(minerais.items()):
|
||||
pastille_1 = pastille("IHH", data['extraction_ihh_pays'])
|
||||
pastille_2 = pastille("IHH", data['extraction_ihh_acteurs'])
|
||||
pastille_3 = pastille("IHH", data['reserves_ihh_pays'])
|
||||
pastille_4 = pastille("IHH", data['traitement_ihh_pays'])
|
||||
pastille_5 = pastille("IHH", data['traitement_ihh_acteurs'])
|
||||
lignes.append(
|
||||
f"| {minerai} | {pastille_1} {data['extraction_ihh_pays']} | {pastille_2} {data['extraction_ihh_acteurs']} | "
|
||||
f"{pastille_3} {data['reserves_ihh_pays']} | {pastille_4} {data['traitement_ihh_pays']} | {pastille_5} {data['traitement_ihh_acteurs']} |"
|
||||
)
|
||||
|
||||
resultat.append("\n".join(lignes))
|
||||
return "\n".join(resultat)
|
||||
|
||||
|
||||
def _synth_ihh(operations: list[dict]) -> str:
|
||||
"""Génère des tableaux de synthèse pour les indices HHI à partir des opérations."""
|
||||
# Extraction et organisation des données
|
||||
data_by_item = _extraire_donnees_operations(operations)
|
||||
|
||||
# Catégorisation des items
|
||||
produits = {k: v for k, v in data_by_item.items() if v['type'] == 'produit'}
|
||||
composants = {k: v for k, v in data_by_item.items() if v['type'] == 'composant'}
|
||||
minerais = {k: v for k, v in data_by_item.items() if v['type'] == 'minerai'}
|
||||
|
||||
# Génération des tableaux pour chaque catégorie
|
||||
tableaux = [
|
||||
_generer_tableau_produits(produits),
|
||||
_generer_tableau_composants(composants),
|
||||
_generer_tableau_minerais(minerais)
|
||||
]
|
||||
|
||||
# Assemblage du résultat final
|
||||
return "\n".join([t for t in tableaux if t])
|
||||
|
||||
def build_ihh_sections(md: str) -> str:
|
||||
segments = []
|
||||
operations = []
|
||||
intro = None
|
||||
|
||||
matches = list(IHH_RE.finditer(md))
|
||||
if matches:
|
||||
first = matches[0]
|
||||
intro = md[:first.start()].strip()
|
||||
else:
|
||||
return md
|
||||
|
||||
for m in matches:
|
||||
bloc_text = m.group(1)
|
||||
bloc = yaml.safe_load("opération:" + bloc_text)
|
||||
operations.append(bloc["opération"])
|
||||
|
||||
start = m.end()
|
||||
next_match = IHH_RE.search(md, start)
|
||||
end = next_match.start() if next_match else len(md)
|
||||
|
||||
section_template = md[start:end].strip()
|
||||
rendered = Template(section_template).render(**bloc["opération"])
|
||||
segments.append(rendered)
|
||||
|
||||
if intro:
|
||||
segments.insert(0, intro)
|
||||
|
||||
if "# Tableaux de synthèse" in md:
|
||||
synth_table = _synth_ihh(operations)
|
||||
md_final = "\n\n".join(segments)
|
||||
md_final = re.sub(
|
||||
r"(?:##?|#) Tableaux de synthèse\s*\n<!---- AUTO-BEGIN:TABLEAU-FINAL -->.*?<!---- AUTO-END:TABLEAU-FINAL -->",
|
||||
f"# Tableaux de synthèse\n<!---- AUTO-BEGIN:TABLEAU-FINAL -->\n{synth_table}\n<!---- AUTO-END:TABLEAU-FINAL -->",
|
||||
md_final,
|
||||
flags=re.S
|
||||
)
|
||||
else:
|
||||
md_final = "\n\n".join(segments)
|
||||
|
||||
return md_final
|
||||
50
app/fiches/utils/dynamic/indice/isg.py
Normal file
50
app/fiches/utils/dynamic/indice/isg.py
Normal file
@ -0,0 +1,50 @@
|
||||
# isg.py
|
||||
|
||||
import re
|
||||
import yaml
|
||||
from ..utils.pastille import pastille
|
||||
|
||||
def _synth_isg(md: str) -> str:
|
||||
yaml_block = re.search(r"```yaml\n(.+?)\n```", md, re.DOTALL)
|
||||
if not yaml_block:
|
||||
return "*(aucune donnée de pays trouvée)*"
|
||||
|
||||
yaml_data = yaml.safe_load(yaml_block.group(1))
|
||||
lignes = ["| Pays | WGI | FSI | NDGAIN | ISG |", "| :-- | :-- | :-- | :-- | :-- |"]
|
||||
sorted_pays = sorted(yaml_data.items(), key=lambda x: x[1]['pays'].lower())
|
||||
|
||||
for identifiant, data in sorted_pays:
|
||||
pays = data['pays']
|
||||
wgi_ps = data['wgi_ps']
|
||||
fsi = data['fsi']
|
||||
ndgain = data['ndgain']
|
||||
isg = data['isg']
|
||||
pastille_isg = pastille("ISG", isg)
|
||||
lignes.append(f"| {pays} | {wgi_ps} | {fsi} | {ndgain} | {pastille_isg} {isg} |")
|
||||
|
||||
return "\n".join(lignes)
|
||||
|
||||
def build_isg_sections(md: str) -> str:
|
||||
front_match = re.match(r"(?s)^---\n(.*?)\n---\n", md)
|
||||
if front_match:
|
||||
front_matter = yaml.safe_load(front_match.group(1))
|
||||
if front_matter.get("indice_court") != "ISG":
|
||||
return md
|
||||
|
||||
synth_table = _synth_isg(md)
|
||||
|
||||
md_final = re.sub(
|
||||
r"## Tableau de synthèse\s*\n<!---- AUTO-BEGIN:TABLEAU-FINAL -->.*?<!---- AUTO-END:TABLEAU-FINAL -->",
|
||||
f"## Tableau de synthèse\n<!---- AUTO-BEGIN:TABLEAU-FINAL -->\n{synth_table}\n<!---- AUTO-END:TABLEAU-FINAL -->",
|
||||
md,
|
||||
flags=re.S
|
||||
)
|
||||
|
||||
md_final = re.sub(
|
||||
r"# Criticité par pays\s*\n```yaml[\s\S]*?```\s*",
|
||||
"# Criticité par pays\n\n",
|
||||
md_final,
|
||||
flags=re.S
|
||||
)
|
||||
|
||||
return md_final
|
||||
70
app/fiches/utils/dynamic/indice/ivc.py
Normal file
70
app/fiches/utils/dynamic/indice/ivc.py
Normal file
@ -0,0 +1,70 @@
|
||||
# ivc.py
|
||||
|
||||
import re
|
||||
import yaml
|
||||
from jinja2 import Template
|
||||
|
||||
IVC_RE = re.compile(r"```yaml\s+minerai:(.*?)```", re.S | re.I)
|
||||
|
||||
def _synth_ivc(minerais: list[dict]) -> str:
|
||||
"""Crée un tableau de synthèse pour les IVC des minerais."""
|
||||
lignes = [
|
||||
"| Minerai | IVC | Vulnérabilité |",
|
||||
"| :-- | :-- | :-- |"
|
||||
]
|
||||
for minerai in minerais:
|
||||
lignes.append(
|
||||
f"| {minerai['nom']} | {minerai['ivc']} | {minerai['vulnerabilite']} |"
|
||||
)
|
||||
return "\n".join(lignes)
|
||||
|
||||
def _ivc_segments(md: str):
|
||||
"""Yield (dict, segment) pour chaque bloc IVC yaml."""
|
||||
pos = 0
|
||||
for m in IVC_RE.finditer(md):
|
||||
bloc = yaml.safe_load("minerai:" + m.group(1))
|
||||
start = m.end()
|
||||
next_match = IVC_RE.search(md, start)
|
||||
end = next_match.start() if next_match else len(md)
|
||||
yield bloc["minerai"], md[start:end].strip()
|
||||
pos = end
|
||||
yield None, md[pos:] # reste éventuel
|
||||
|
||||
def build_ivc_sections(md: str) -> str:
|
||||
"""Remplace les blocs YAML minerai + segment avec rendu Jinja2, conserve l'intro."""
|
||||
segments = []
|
||||
minerais = [] # Pour collecter les données de chaque minerai
|
||||
intro = None
|
||||
|
||||
matches = list(IVC_RE.finditer(md))
|
||||
if matches:
|
||||
first = matches[0]
|
||||
intro = md[:first.start()].strip()
|
||||
else:
|
||||
return md # pas de blocs à traiter
|
||||
|
||||
for m in matches:
|
||||
bloc = yaml.safe_load("minerai:" + m.group(1))
|
||||
minerais.append(bloc["minerai"]) # Collecte les données
|
||||
start = m.end()
|
||||
next_match = IVC_RE.search(md, start)
|
||||
end = next_match.start() if next_match else len(md)
|
||||
rendered = Template(md[start:end].strip()).render(**bloc["minerai"])
|
||||
segments.append(rendered)
|
||||
|
||||
if intro:
|
||||
segments.insert(0, intro)
|
||||
|
||||
# Créer et insérer le tableau de synthèse
|
||||
synth_table = _synth_ivc(minerais)
|
||||
md_final = "\n\n".join(segments)
|
||||
|
||||
# Remplacer la section du tableau final
|
||||
md_final = re.sub(
|
||||
r"## Tableau de synthèse\s*\n<!---- AUTO-BEGIN:TABLEAU-FINAL -->.*?<!---- AUTO-END:TABLEAU-FINAL -->",
|
||||
f"## Tableau de synthèse\n<!---- AUTO-BEGIN:TABLEAU-FINAL -->\n{synth_table}\n<!---- AUTO-END:TABLEAU-FINAL -->",
|
||||
md_final,
|
||||
flags=re.S
|
||||
)
|
||||
|
||||
return md_final
|
||||
585
app/fiches/utils/dynamic/minerai/minerai.py
Normal file
585
app/fiches/utils/dynamic/minerai/minerai.py
Normal file
@ -0,0 +1,585 @@
|
||||
import streamlit as st
|
||||
import re
|
||||
import yaml
|
||||
|
||||
def _build_extraction_tableau(md: str, produit: str) -> str:
|
||||
"""Génère le tableau d'extraction pour les fiches de minerai."""
|
||||
# Identifier la section d'extraction
|
||||
extraction_pattern = rf"Extraction_{re.escape(produit)}"
|
||||
extraction_match = re.search(f"{extraction_pattern}:", md)
|
||||
|
||||
if not extraction_match:
|
||||
return md # Pas de section d'extraction trouvée
|
||||
|
||||
# Récupérer les données structurées
|
||||
extraction_data = {}
|
||||
|
||||
# Rechercher tous les pays et leurs acteurs
|
||||
pays_pattern = rf"(\w+)_{extraction_pattern}:\s+nom_du_pays:\s+([^\n]+)\s+part_de_marche:\s+([^\n]+)"
|
||||
for pays_match in re.finditer(pays_pattern, md):
|
||||
code_pays, nom_pays, part_pays = pays_match.groups()
|
||||
try:
|
||||
part_marche_num = float(part_pays.strip().rstrip('%'))
|
||||
except ValueError:
|
||||
part_marche_num = 0
|
||||
|
||||
extraction_data[code_pays] = {
|
||||
'nom': nom_pays.strip(),
|
||||
'part_marche': part_pays.strip(),
|
||||
'part_marche_num': part_marche_num,
|
||||
'acteurs': []
|
||||
}
|
||||
|
||||
# Rechercher tous les acteurs pour ce pays
|
||||
acteur_pattern = rf"(\w+)_{code_pays}_{extraction_pattern}:\s+nom_de_l_acteur:\s+([^\n]+)\s+part_de_marche:\s+([^\n]+)\s+pays_d_origine:\s+([^\n]+)"
|
||||
for acteur_match in re.finditer(acteur_pattern, md):
|
||||
code_acteur, nom_acteur, part_acteur, pays_origine = acteur_match.groups()
|
||||
try:
|
||||
part_acteur_num = float(part_acteur.strip().rstrip('%'))
|
||||
except ValueError:
|
||||
part_acteur_num = 0
|
||||
|
||||
extraction_data[code_pays]['acteurs'].append({
|
||||
'nom': nom_acteur.strip(),
|
||||
'part_marche': part_acteur.strip(),
|
||||
'part_marche_num': part_acteur_num,
|
||||
'pays_origine': pays_origine.strip()
|
||||
})
|
||||
|
||||
# Préparer les données pour l'affichage
|
||||
pays_data = []
|
||||
for code_pays, pays_info in extraction_data.items():
|
||||
# Trier les acteurs par part de marché décroissante
|
||||
acteurs_tries = sorted(pays_info['acteurs'], key=lambda x: x['part_marche_num'], reverse=True)
|
||||
|
||||
pays_data.append({
|
||||
'nom': pays_info['nom'],
|
||||
'part_marche': pays_info['part_marche'],
|
||||
'part_marche_num': pays_info['part_marche_num'],
|
||||
'acteurs': acteurs_tries
|
||||
})
|
||||
|
||||
# Trier les pays par part de marché décroissante
|
||||
pays_tries = sorted(pays_data, key=lambda x: x['part_marche_num'], reverse=True)
|
||||
|
||||
# Générer le tableau des producteurs
|
||||
lignes_tableau = [
|
||||
"| **Pays d'implantation** | **Entreprise** | **Pays d'origine** | **Part de marché** |",
|
||||
"| :-- | :-- | :-- | :-- |"
|
||||
]
|
||||
|
||||
for pays in pays_tries:
|
||||
for acteur in pays['acteurs']:
|
||||
# Formater la part de marché (ajouter un espace avant %)
|
||||
part_marche_formattee = acteur['part_marche'].strip().replace('%', ' %')
|
||||
lignes_tableau.append(
|
||||
f"| {pays['nom']} | {acteur['nom']} | {acteur['pays_origine']} | {part_marche_formattee} |"
|
||||
)
|
||||
|
||||
# Ajouter la ligne de total pour le pays (en gras)
|
||||
part_marche_pays_formattee = pays['part_marche'].strip().replace('%', ' %')
|
||||
lignes_tableau.append(
|
||||
f"| **{pays['nom']}** | **Total** | **{pays['nom']}** | **{part_marche_pays_formattee}** |"
|
||||
)
|
||||
|
||||
# Construire le tableau final
|
||||
tableau_final = "\n".join(lignes_tableau)
|
||||
|
||||
# Remplacer la section du tableau dans la fiche
|
||||
md_modifie = re.sub(
|
||||
r"<!---- AUTO-BEGIN:TABLEAU-EXTRACTION -->.*?<!---- AUTO-END:TABLEAU-EXTRACTION -->",
|
||||
f"<!---- AUTO-BEGIN:TABLEAU-EXTRACTION -->\n{tableau_final}\n<!---- AUTO-END:TABLEAU-EXTRACTION -->",
|
||||
md,
|
||||
flags=re.DOTALL
|
||||
)
|
||||
|
||||
return md_modifie
|
||||
|
||||
def _build_traitement_tableau(md: str, produit: str) -> str:
|
||||
"""Génère le tableau de traitement pour les fiches de minerai."""
|
||||
# Identifier la section de traitement
|
||||
traitement_pattern = rf"Traitement_{re.escape(produit)}"
|
||||
traitement_match = re.search(f"{traitement_pattern}:", md)
|
||||
|
||||
if not traitement_match:
|
||||
return md # Pas de section de traitement trouvée
|
||||
|
||||
# Récupérer les données structurées
|
||||
traitement_data = {}
|
||||
|
||||
# Rechercher tous les pays et leurs acteurs
|
||||
pays_pattern = rf"(\w+)_{traitement_pattern}:\s+nom_du_pays:\s+([^\n]+)\s+part_de_marche:\s+([^\n]+)"
|
||||
for pays_match in re.finditer(pays_pattern, md):
|
||||
code_pays, nom_pays, part_pays = pays_match.groups()
|
||||
try:
|
||||
part_marche_num = float(part_pays.strip().rstrip('%'))
|
||||
except ValueError:
|
||||
part_marche_num = 0
|
||||
|
||||
traitement_data[code_pays] = {
|
||||
'nom': nom_pays.strip(),
|
||||
'part_marche': part_pays.strip(),
|
||||
'part_marche_num': part_marche_num,
|
||||
'acteurs': []
|
||||
}
|
||||
|
||||
# Rechercher tous les acteurs pour ce pays
|
||||
acteur_pattern = rf"(\w+)_{code_pays}_{traitement_pattern}:"
|
||||
for acteur_match in re.finditer(acteur_pattern, md):
|
||||
code_acteur = acteur_match.group(1)
|
||||
|
||||
# Récupérer les informations de l'acteur
|
||||
nom_acteur_match = re.search(rf"{code_acteur}_{code_pays}_{traitement_pattern}:\s+nom_de_l_acteur:\s+([^\n]+)", md)
|
||||
part_acteur_match = re.search(rf"{code_acteur}_{code_pays}_{traitement_pattern}:.*?part_de_marche:\s+([^\n]+)", md, re.DOTALL)
|
||||
pays_origine_match = re.search(rf"{code_acteur}_{code_pays}_{traitement_pattern}:.*?pays_d_origine:\s+([^\n]+)", md, re.DOTALL)
|
||||
|
||||
if nom_acteur_match and part_acteur_match and pays_origine_match:
|
||||
nom_acteur = nom_acteur_match.group(1).strip()
|
||||
part_acteur = part_acteur_match.group(1).strip()
|
||||
pays_origine = pays_origine_match.group(1).strip()
|
||||
|
||||
try:
|
||||
part_acteur_num = float(part_acteur.strip().rstrip('%'))
|
||||
except ValueError:
|
||||
part_acteur_num = 0
|
||||
|
||||
# Récupérer les origines du minerai
|
||||
origines_minerai = []
|
||||
for i in range(1, 10): # Vérifier jusqu'à 9 origines possibles
|
||||
origine_key = "minerai_origine" if i == 1 else f"minerai_origine_{i}"
|
||||
origine_pattern = rf"{code_acteur}_{code_pays}_{traitement_pattern}:.*?{origine_key}:\s+pays:\s+([^\n]+)\s+pourcentage:\s+([^\n]+)"
|
||||
origine_match = re.search(origine_pattern, md, re.DOTALL)
|
||||
|
||||
if origine_match:
|
||||
pays_origine_minerai = origine_match.group(1).strip()
|
||||
pourcentage_origine = origine_match.group(2).strip()
|
||||
origines_minerai.append(f"{pays_origine_minerai} ({pourcentage_origine})")
|
||||
else:
|
||||
break
|
||||
|
||||
origine_text = ", ".join(origines_minerai) if origines_minerai else ""
|
||||
|
||||
traitement_data[code_pays]['acteurs'].append({
|
||||
'nom': nom_acteur,
|
||||
'part_marche': part_acteur,
|
||||
'part_marche_num': part_acteur_num,
|
||||
'pays_origine': pays_origine,
|
||||
'origine_minerai': origine_text
|
||||
})
|
||||
|
||||
# Préparer les données pour l'affichage
|
||||
pays_data = []
|
||||
for code_pays, pays_info in traitement_data.items():
|
||||
# Trier les acteurs par part de marché décroissante
|
||||
acteurs_tries = sorted(pays_info['acteurs'], key=lambda x: x['part_marche_num'], reverse=True)
|
||||
|
||||
pays_data.append({
|
||||
'nom': pays_info['nom'],
|
||||
'part_marche': pays_info['part_marche'],
|
||||
'part_marche_num': pays_info['part_marche_num'],
|
||||
'acteurs': acteurs_tries
|
||||
})
|
||||
|
||||
# Trier les pays par part de marché décroissante
|
||||
pays_tries = sorted(pays_data, key=lambda x: x['part_marche_num'], reverse=True)
|
||||
|
||||
# Générer le tableau des producteurs
|
||||
lignes_tableau = [
|
||||
"| **Pays d'implantation** | **Entreprise** | **Pays d'origine** | **Origines du minerai** | **Part de marché** |",
|
||||
"| :-- | :-- | :-- | :-- | :-- |"
|
||||
]
|
||||
|
||||
for pays in pays_tries:
|
||||
for acteur in pays['acteurs']:
|
||||
# Formater la part de marché (ajouter un espace avant %)
|
||||
part_marche_formattee = acteur['part_marche'].strip().replace('%', ' %')
|
||||
lignes_tableau.append(
|
||||
f"| {pays['nom']} | {acteur['nom']} | {acteur['pays_origine']} | {acteur['origine_minerai']} | {part_marche_formattee} |"
|
||||
)
|
||||
|
||||
# Ajouter la ligne de total pour le pays (en gras)
|
||||
part_marche_pays_formattee = pays['part_marche'].strip().replace('%', ' %')
|
||||
lignes_tableau.append(
|
||||
f"| **{pays['nom']}** | **Total** | **{pays['nom']}** | **-** | **{part_marche_pays_formattee}** |"
|
||||
)
|
||||
|
||||
# Construire le tableau final
|
||||
tableau_final = "\n".join(lignes_tableau)
|
||||
|
||||
# Remplacer la section du tableau dans la fiche
|
||||
md_modifie = re.sub(
|
||||
r"<!---- AUTO-BEGIN:TABLEAU-TRAITEMENT -->.*?<!---- AUTO-END:TABLEAU-TRAITEMENT -->",
|
||||
f"<!---- AUTO-BEGIN:TABLEAU-TRAITEMENT -->\n{tableau_final}\n<!---- AUTO-END:TABLEAU-TRAITEMENT -->",
|
||||
md,
|
||||
flags=re.DOTALL
|
||||
)
|
||||
|
||||
return md_modifie
|
||||
|
||||
def _build_reserves_tableau(md: str, produit: str) -> str:
|
||||
"""Génère le tableau des réserves pour les fiches de minerai."""
|
||||
# Identifier la section des réserves
|
||||
reserves_pattern = rf"Reserves_{re.escape(produit)}"
|
||||
reserves_match = re.search(f"{reserves_pattern}:", md)
|
||||
|
||||
if not reserves_match:
|
||||
return md # Pas de section de réserves trouvée
|
||||
|
||||
# Récupérer les données structurées
|
||||
reserves_data = []
|
||||
|
||||
# Rechercher tous les pays et leurs parts de marché
|
||||
pays_pattern = rf"(\w+)_{reserves_pattern}:\s+nom_du_pays:\s+([^\n]+)\s+part_de_marche:\s+([^\n]+)"
|
||||
for pays_match in re.finditer(pays_pattern, md):
|
||||
code_pays, nom_pays, part_pays = pays_match.groups()
|
||||
try:
|
||||
part_marche_num = float(part_pays.strip().rstrip('%'))
|
||||
except ValueError:
|
||||
part_marche_num = 0
|
||||
|
||||
reserves_data.append({
|
||||
'nom': nom_pays.strip(),
|
||||
'part_marche': part_pays.strip(),
|
||||
'part_marche_num': part_marche_num
|
||||
})
|
||||
|
||||
# Trier les pays par part de marché décroissante
|
||||
reserves_data_triees = sorted(reserves_data, key=lambda x: x['part_marche_num'], reverse=True)
|
||||
|
||||
# Générer le tableau des réserves
|
||||
lignes_tableau = [
|
||||
"| **Pays d'implantation** | **Part de marché** |",
|
||||
"| :-- | :-- |"
|
||||
]
|
||||
|
||||
for pays in reserves_data_triees:
|
||||
# Formater la part de marché (ajouter un espace avant %)
|
||||
part_marche_formattee = pays['part_marche'].strip().replace('%', ' %')
|
||||
lignes_tableau.append(f"| {pays['nom']} | {part_marche_formattee} |")
|
||||
|
||||
# Construire le tableau final
|
||||
tableau_final = "\n".join(lignes_tableau)
|
||||
|
||||
# Remplacer la section du tableau dans la fiche
|
||||
md_modifie = re.sub(
|
||||
r"<!---- AUTO-BEGIN:TABLEAU-RESERVES -->.*?<!---- AUTO-END:TABLEAU-RESERVES -->",
|
||||
f"<!---- AUTO-BEGIN:TABLEAU-RESERVES -->\n{tableau_final}\n<!---- AUTO-END:TABLEAU-RESERVES -->",
|
||||
md,
|
||||
flags=re.DOTALL
|
||||
)
|
||||
|
||||
return md_modifie
|
||||
|
||||
def build_minerai_ivc_section(md: str) -> str:
|
||||
"""
|
||||
Ajoute les informations IVC depuis la fiche technique IVC.md pour un minerai spécifique.
|
||||
"""
|
||||
# Extraire le type de fiche et le produit depuis l'en-tête YAML
|
||||
type_fiche = None
|
||||
produit = None
|
||||
front_match = re.match(r"(?s)^---\n(.*?)\n---\n", md)
|
||||
if front_match:
|
||||
try:
|
||||
front_matter = yaml.safe_load(front_match.group(1))
|
||||
type_fiche = front_matter.get("type_fiche")
|
||||
produit = front_matter.get("schema")
|
||||
|
||||
# Vérifier si c'est bien une fiche de minerai
|
||||
if type_fiche != "minerai" or not produit:
|
||||
return md
|
||||
except Exception as e:
|
||||
st.error(f"Erreur lors du chargement du front matter: {e}")
|
||||
return md
|
||||
|
||||
# Injecter les informations IVC depuis la fiche technique
|
||||
try:
|
||||
# Charger le contenu de la fiche technique IVC
|
||||
ivc_path = "Fiches/Criticités/Fiche technique IVC.md"
|
||||
with open(ivc_path, "r", encoding="utf-8") as f:
|
||||
ivc_content = f.read()
|
||||
|
||||
# Chercher la section correspondant au minerai
|
||||
# Le pattern cherche un titre de niveau 2 commençant par le nom du produit
|
||||
section_pattern = rf"## {produit} - (.*?)(?=\n###|$)"
|
||||
section_match = re.search(section_pattern, ivc_content, re.DOTALL)
|
||||
|
||||
if section_match:
|
||||
# Extraire la partie après le nom du minerai (ex: "IVC : 4 - Vulnérabilité: Faible")
|
||||
ivc_value = section_match.group(1).strip()
|
||||
|
||||
# Extraire toutes les sous-sections
|
||||
# Le pattern cherche depuis le titre du minerai jusqu'à la prochaine section de même niveau ou fin de fichier
|
||||
full_section_pattern = rf"## {produit} - .*?\n([\s\S]*?)(?=\n## |$)"
|
||||
full_section_match = re.search(full_section_pattern, ivc_content)
|
||||
|
||||
if full_section_match:
|
||||
section_content = full_section_match.group(1).strip()
|
||||
|
||||
# Formater le contenu à insérer
|
||||
ivc_content_formatted = f"```\n{ivc_value}\n```\n\n{section_content}"
|
||||
|
||||
# Remplacer la section IVC dans le markdown
|
||||
md = re.sub(
|
||||
r"<!---- AUTO-BEGIN:SECTION-IVC-MINERAI -->.*?<!---- AUTO-END:SECTION-IVC-MINERAI -->",
|
||||
f"<!---- AUTO-BEGIN:SECTION-IVC-MINERAI -->\n{ivc_content_formatted}\n<!---- AUTO-END:SECTION-IVC-MINERAI -->",
|
||||
md,
|
||||
flags=re.DOTALL
|
||||
)
|
||||
except Exception as e:
|
||||
st.error(f"Erreur lors de la génération de la section IVC: {e}")
|
||||
|
||||
return md
|
||||
|
||||
def build_minerai_ics_section(md: str) -> str:
|
||||
"""
|
||||
Ajoute les informations ICS depuis la fiche technique ICS.md pour un minerai spécifique.
|
||||
"""
|
||||
# Extraire le type de fiche et le produit depuis l'en-tête YAML
|
||||
type_fiche = None
|
||||
produit = None
|
||||
front_match = re.match(r"(?s)^---\n(.*?)\n---\n", md)
|
||||
if front_match:
|
||||
try:
|
||||
front_matter = yaml.safe_load(front_match.group(1))
|
||||
type_fiche = front_matter.get("type_fiche")
|
||||
produit = front_matter.get("schema")
|
||||
|
||||
# Vérifier si c'est bien une fiche de minerai
|
||||
if type_fiche != "minerai" or not produit:
|
||||
return md
|
||||
except Exception as e:
|
||||
st.error(f"Erreur lors du chargement du front matter: {e}")
|
||||
return md
|
||||
|
||||
# Injecter les informations ICS depuis la fiche technique
|
||||
try:
|
||||
# Charger le contenu de la fiche technique ICS
|
||||
ics_path = "Fiches/Criticités/Fiche technique ICS.md"
|
||||
with open(ics_path, "r", encoding="utf-8") as f:
|
||||
ics_content = f.read()
|
||||
|
||||
# Extraire la section ICS pour le minerai
|
||||
# Dans le fichier ICS, on recherche dans la section "# Criticité par minerai"
|
||||
# puis on cherche la sous-section correspondant au minerai
|
||||
minerai_section_pattern = r"# Criticité par minerai\s*\n([\s\S]*?)(?=\n# |$)"
|
||||
minerai_section_match = re.search(minerai_section_pattern, ics_content)
|
||||
|
||||
if minerai_section_match:
|
||||
minerai_section = minerai_section_match.group(1)
|
||||
|
||||
# Chercher le minerai spécifique
|
||||
# Rechercher un titre de niveau 2 correspondant exactement au produit
|
||||
specific_minerai_pattern = rf"## {produit}\s*\n([\s\S]*?)(?=\n## |$)"
|
||||
specific_minerai_match = re.search(specific_minerai_pattern, minerai_section)
|
||||
|
||||
if specific_minerai_match:
|
||||
# Extraire le contenu de la section du minerai
|
||||
minerai_content = specific_minerai_match.group(1).strip()
|
||||
|
||||
# Remplacer la section ICS dans le markdown
|
||||
md = re.sub(
|
||||
r"<!---- AUTO-BEGIN:SECTION-ICS-MINERAI -->.*?<!---- AUTO-END:SECTION-ICS-MINERAI -->",
|
||||
f"<!---- AUTO-BEGIN:SECTION-ICS-MINERAI -->\n{minerai_content}\n<!---- AUTO-END:SECTION-ICS-MINERAI -->",
|
||||
md,
|
||||
flags=re.DOTALL
|
||||
)
|
||||
except Exception as e:
|
||||
st.error(f"Erreur lors de la génération de la section ICS: {e}")
|
||||
|
||||
return md
|
||||
|
||||
def build_minerai_ics_composant_section(md: str) -> str:
|
||||
"""
|
||||
Ajoute les informations ICS pour tous les composants liés à un minerai spécifique
|
||||
depuis la fiche technique ICS.md, en augmentant d'un niveau les titres.
|
||||
"""
|
||||
# Extraire le type de fiche et le produit depuis l'en-tête YAML
|
||||
type_fiche = None
|
||||
produit = None
|
||||
front_match = re.match(r"(?s)^---\n(.*?)\n---\n", md)
|
||||
if front_match:
|
||||
try:
|
||||
front_matter = yaml.safe_load(front_match.group(1))
|
||||
type_fiche = front_matter.get("type_fiche")
|
||||
produit = front_matter.get("schema")
|
||||
|
||||
# Vérifier si c'est bien une fiche de minerai
|
||||
if type_fiche != "minerai" or not produit:
|
||||
return md
|
||||
except Exception as e:
|
||||
st.error(f"Erreur lors du chargement du front matter: {e}")
|
||||
return md
|
||||
|
||||
# Injecter les informations ICS depuis la fiche technique
|
||||
try:
|
||||
# Charger le contenu de la fiche technique ICS
|
||||
ics_path = "Fiches/Criticités/Fiche technique ICS.md"
|
||||
with open(ics_path, "r", encoding="utf-8") as f:
|
||||
ics_content = f.read()
|
||||
|
||||
# Rechercher toutes les sections de composants liés au minerai
|
||||
# Le pattern cherche les titres de niveau 2 de la forme "## * -> Minerai"
|
||||
composant_sections_pattern = re.compile(
|
||||
rf"^## ([^>]+) -> {re.escape(produit)} - .*?\n([\s\S]*?)(?=^## |\Z)",
|
||||
re.MULTILINE | re.DOTALL
|
||||
)
|
||||
|
||||
composant_sections = re.finditer(composant_sections_pattern, ics_content)
|
||||
|
||||
all_composant_content = []
|
||||
|
||||
for match in composant_sections:
|
||||
composant = match.group(1).strip()
|
||||
section_content = match.group(2).strip()
|
||||
|
||||
# Augmenter le niveau des titres d'un cran
|
||||
# Titre de niveau 2 -> niveau 3
|
||||
section_title = f"### {composant} -> {produit}{match.group(0).split(produit)[1].split('\n')[0]}"
|
||||
|
||||
# Augmenter les niveaux des sous-titres (### -> ####)
|
||||
section_content = re.sub(r"### ", "#### ", section_content)
|
||||
|
||||
# Ajouter à la liste des contenus
|
||||
all_composant_content.append(f"{section_title}\n{section_content}")
|
||||
|
||||
# Combiner tous les contenus
|
||||
if all_composant_content:
|
||||
combined_content = "\n\n".join(all_composant_content)
|
||||
|
||||
# Remplacer la section ICS dans le markdown
|
||||
md = re.sub(
|
||||
r"<!---- AUTO-BEGIN:SECTION-ICS-COMPOSANT-MINERAI -->.*?<!---- AUTO-END:SECTION-ICS-COMPOSANT-MINERAI -->",
|
||||
f"<!---- AUTO-BEGIN:SECTION-ICS-COMPOSANT-MINERAI -->\n{combined_content}\n<!---- AUTO-END:SECTION-ICS-COMPOSANT-MINERAI -->",
|
||||
md,
|
||||
flags=re.DOTALL
|
||||
)
|
||||
except Exception as e:
|
||||
st.error(f"Erreur lors de la génération de la section ICS pour les composants: {e}")
|
||||
|
||||
return md
|
||||
|
||||
|
||||
def build_minerai_sections(md: str) -> str:
|
||||
"""Traite les fiches de minerai et génère les tableaux des producteurs."""
|
||||
# Extraire le type de fiche depuis l'en-tête YAML
|
||||
type_fiche = None
|
||||
produit = None
|
||||
front_match = re.match(r"(?s)^---\n(.*?)\n---\n", md)
|
||||
if front_match:
|
||||
try:
|
||||
front_matter = yaml.safe_load(front_match.group(1))
|
||||
type_fiche = front_matter.get("type_fiche")
|
||||
produit = front_matter.get("schema") # le produit à rechercher est schema pour faire le lien avec le graphe
|
||||
|
||||
# Vérifier si c'est bien une fiche de minerai
|
||||
if type_fiche != "minerai" or not produit:
|
||||
return md
|
||||
except Exception as e:
|
||||
st.error(f"Erreur lors du chargement du front matter: {e}")
|
||||
return md
|
||||
|
||||
# Traiter le tableau d'Extraction
|
||||
md = _build_extraction_tableau(md, produit)
|
||||
|
||||
# Traiter le tableau de Traitement
|
||||
md = _build_traitement_tableau(md, produit)
|
||||
|
||||
# Traiter le tableau des Réserves
|
||||
md = _build_reserves_tableau(md, produit)
|
||||
|
||||
# Supprimer les blocs YAML complets, y compris les délimiteurs ```yaml et ```
|
||||
# Rechercher d'abord les blocs avec délimiteurs YAML
|
||||
yaml_blocks = [
|
||||
rf"```yaml\s*\n{re.escape(produit)}.*?```",
|
||||
rf"```yaml\s*\nExtraction_{re.escape(produit)}.*?```",
|
||||
rf"```yaml\s*\nReserves_{re.escape(produit)}.*?```",
|
||||
rf"```yaml\s*\nTraitement_{re.escape(produit)}.*?```"
|
||||
]
|
||||
|
||||
for pattern in yaml_blocks:
|
||||
md = re.sub(pattern, "", md, flags=re.DOTALL)
|
||||
|
||||
# Supprimer également les blocs qui ne seraient pas entourés de délimiteurs
|
||||
patterns_to_remove = [
|
||||
rf"Extraction_{re.escape(produit)}:(?:.*?)(?=\n##|\Z)",
|
||||
rf"Reserves_{re.escape(produit)}:(?:.*?)(?=\n##|\Z)",
|
||||
rf"Traitement_{re.escape(produit)}:(?:.*?)(?=\n##|\Z)"
|
||||
]
|
||||
|
||||
for pattern in patterns_to_remove:
|
||||
md = re.sub(pattern, "", md, flags=re.DOTALL)
|
||||
|
||||
# Nettoyer les délimiteurs ```yaml et ``` qui pourraient rester
|
||||
md = re.sub(r"```yaml\s*\n", "", md)
|
||||
md = re.sub(r"```\s*\n", "", md)
|
||||
|
||||
# Injecter les sections IHH depuis la fiche technique
|
||||
try:
|
||||
# Charger le contenu de la fiche technique IHH
|
||||
ihh_path = "Fiches/Criticités/Fiche technique IHH.md"
|
||||
with open(ihh_path, "r", encoding="utf-8") as f:
|
||||
ihh_content = f.read()
|
||||
|
||||
# D'abord, extraire toute la section concernant le produit
|
||||
section_produit_pattern = rf"## Opérations - {produit}\s*\n([\s\S]*?)(?=\n## |$)"
|
||||
section_produit_match = re.search(section_produit_pattern, ihh_content, re.IGNORECASE)
|
||||
|
||||
if section_produit_match:
|
||||
section_produit = section_produit_match.group(1).strip()
|
||||
|
||||
# Maintenant, extraire les sous-sections individuelles à partir de la section du produit
|
||||
|
||||
# 1. Extraction - incluant le titre de niveau 3
|
||||
extraction_pattern = r"(### Indice de Herfindahl-Hirschmann - Extraction\s*\n[\s\S]*?)(?=### Indice de Herfindahl-Hirschmann - Réserves|$)"
|
||||
extraction_match = re.search(extraction_pattern, section_produit, re.IGNORECASE)
|
||||
|
||||
if extraction_match:
|
||||
extraction_ihh = extraction_match.group(1).strip()
|
||||
md = re.sub(
|
||||
r"<!---- AUTO-BEGIN:SECTION-IHH-EXTRACTION -->.*?<!---- AUTO-END:SECTION-IHH-EXTRACTION -->",
|
||||
f"<!---- AUTO-BEGIN:SECTION-IHH-EXTRACTION -->\n{extraction_ihh}\n<!---- AUTO-END:SECTION-IHH-EXTRACTION -->",
|
||||
md,
|
||||
flags=re.DOTALL
|
||||
)
|
||||
|
||||
# 2. Réserves - incluant le titre de niveau 3
|
||||
reserves_pattern = r"(### Indice de Herfindahl-Hirschmann - Réserves\s*\n[\s\S]*?)(?=### Indice de Herfindahl-Hirschmann - Traitement|$)"
|
||||
reserves_match = re.search(reserves_pattern, section_produit, re.IGNORECASE)
|
||||
|
||||
if reserves_match:
|
||||
reserves_ihh = reserves_match.group(1).strip()
|
||||
md = re.sub(
|
||||
r"<!---- AUTO-BEGIN:SECTION-IHH-RESERVES -->.*?<!---- AUTO-END:SECTION-IHH-RESERVES -->",
|
||||
f"<!---- AUTO-BEGIN:SECTION-IHH-RESERVES -->\n{reserves_ihh}\n<!---- AUTO-END:SECTION-IHH-RESERVES -->",
|
||||
md,
|
||||
flags=re.DOTALL
|
||||
)
|
||||
|
||||
# 3. Traitement - incluant le titre de niveau 3
|
||||
traitement_pattern = r"(### Indice de Herfindahl-Hirschmann - Traitement\s*\n[\s\S]*?)(?=$)"
|
||||
traitement_match = re.search(traitement_pattern, section_produit, re.IGNORECASE)
|
||||
|
||||
if traitement_match:
|
||||
traitement_ihh = traitement_match.group(1).strip()
|
||||
md = re.sub(
|
||||
r"<!---- AUTO-BEGIN:SECTION-IHH-TRAITEMENT -->.*?<!---- AUTO-END:SECTION-IHH-TRAITEMENT -->",
|
||||
f"<!---- AUTO-BEGIN:SECTION-IHH-TRAITEMENT -->\n{traitement_ihh}\n<!---- AUTO-END:SECTION-IHH-TRAITEMENT -->",
|
||||
md,
|
||||
flags=re.DOTALL
|
||||
)
|
||||
except Exception as e:
|
||||
st.error(f"Erreur lors de la génération des sections IHH: {e}")
|
||||
|
||||
# Nettoyer les doubles sauts de ligne
|
||||
md = re.sub(r"\n{3,}", "\n\n", md)
|
||||
|
||||
# Ajouter les informations IVC
|
||||
md = build_minerai_ivc_section(md)
|
||||
|
||||
# Ajouter les informations ICS
|
||||
md = build_minerai_ics_section(md)
|
||||
|
||||
# Ajouter les informations ICS pour les composants liés au minerai
|
||||
md = build_minerai_ics_composant_section(md)
|
||||
|
||||
return md
|
||||
30
app/fiches/utils/dynamic/utils/pastille.py
Normal file
30
app/fiches/utils/dynamic/utils/pastille.py
Normal file
@ -0,0 +1,30 @@
|
||||
# pastille.py
|
||||
|
||||
from typing import Any
|
||||
|
||||
PASTILLE_ICONS = {
|
||||
"vert": "✅",
|
||||
"orange": "🔶",
|
||||
"rouge": "🔴"
|
||||
}
|
||||
|
||||
def pastille(indice: str, valeur: Any, seuils: dict = None) -> str:
|
||||
try:
|
||||
import streamlit as st
|
||||
seuils = seuils or st.session_state.get("seuils", {})
|
||||
if indice not in seuils:
|
||||
return ""
|
||||
|
||||
seuil = seuils[indice]
|
||||
vert_max = seuil["vert"]["max"]
|
||||
rouge_min = seuil["rouge"]["min"]
|
||||
val = float(valeur)
|
||||
|
||||
if val < vert_max:
|
||||
return PASTILLE_ICONS["vert"]
|
||||
elif val > rouge_min:
|
||||
return PASTILLE_ICONS["rouge"]
|
||||
else:
|
||||
return PASTILLE_ICONS["orange"]
|
||||
except (KeyError, ValueError, TypeError):
|
||||
return ""
|
||||
115
app/fiches/utils/fiche_utils.py
Normal file
115
app/fiches/utils/fiche_utils.py
Normal file
@ -0,0 +1,115 @@
|
||||
"""
|
||||
fiche_utils.py – outils de lecture / rendu des fiches Markdown (indices et opérations)
|
||||
|
||||
Dépendances :
|
||||
pip install python-frontmatter pyyaml jinja2
|
||||
|
||||
Usage :
|
||||
from fiche_utils import load_seuils, render_fiche_markdown
|
||||
|
||||
seuils = load_seuils("config/indices_seuils.yaml")
|
||||
markdown_rendered = render_fiche_markdown(raw_md_text, seuils)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
import frontmatter, yaml, jinja2, re, pathlib
|
||||
from typing import Dict
|
||||
|
||||
from datetime import datetime, timezone
|
||||
import os
|
||||
from utils.gitea import recuperer_date_dernier_commit
|
||||
|
||||
|
||||
def load_seuils(path: str | pathlib.Path = "config/indices_seuils.yaml") -> Dict:
|
||||
"""Charge le fichier YAML des seuils et renvoie le dict 'seuils'."""
|
||||
data = yaml.safe_load(pathlib.Path(path).read_text(encoding="utf-8"))
|
||||
return data.get("seuils", {})
|
||||
|
||||
|
||||
def _migrate_metadata(meta: Dict) -> Dict:
|
||||
"""Normalise les clés YAML (ex : sheet_type → type_fiche)."""
|
||||
keymap = {
|
||||
"sheet_type": "type_fiche",
|
||||
"indice_code": "indice_court", # si besoin
|
||||
}
|
||||
for old, new in keymap.items():
|
||||
if old in meta and new not in meta:
|
||||
meta[new] = meta.pop(old)
|
||||
return meta
|
||||
|
||||
|
||||
def render_fiche_markdown(md_text: str, seuils: Dict, license_path: str = "assets/licence.md") -> str:
|
||||
"""Renvoie la fiche rendue (Markdown) :
|
||||
– placeholders Jinja2 remplacés ({{ … }})
|
||||
– table seuils injectée via dict 'seuils'.
|
||||
- licence ajoutée après le tableau de version et avant le premier titre de niveau 2
|
||||
"""
|
||||
post = frontmatter.loads(md_text)
|
||||
meta = _migrate_metadata(dict(post.metadata))
|
||||
body_template = post.content
|
||||
|
||||
# Instancie Jinja2 en 'StrictUndefined' pour signaler les placeholders manquants.
|
||||
env = jinja2.Environment(
|
||||
undefined=jinja2.StrictUndefined,
|
||||
autoescape=False,
|
||||
trim_blocks=True,
|
||||
lstrip_blocks=True,
|
||||
)
|
||||
tpl = env.from_string(body_template)
|
||||
rendered_body = tpl.render(**meta, seuils=seuils)
|
||||
|
||||
# Option : ajoute automatiquement titre + tableau version si absent.
|
||||
header = f"# {meta.get('indice', meta.get('titre',''))} ({meta.get('indice_court','')})"
|
||||
if not re.search(r"^# ", rendered_body, flags=re.M):
|
||||
rendered_body = f"""{header}
|
||||
|
||||
{rendered_body}"""
|
||||
|
||||
# Charger le contenu de la licence
|
||||
try:
|
||||
license_content = pathlib.Path(license_path).read_text(encoding="utf-8")
|
||||
|
||||
# Insérer la licence après le tableau de version et avant le premier titre h2
|
||||
# Trouver la position du premier titre h2
|
||||
h2_match = re.search(r"^## ", rendered_body, flags=re.M)
|
||||
|
||||
if h2_match:
|
||||
h2_position = h2_match.start()
|
||||
rendered_body = f"{rendered_body[:h2_position]}\n\n{license_content}\n\n{rendered_body[h2_position:]}"
|
||||
else:
|
||||
# S'il n'y a pas de titre h2, ajouter la licence à la fin
|
||||
rendered_body = f"{rendered_body}\n\n{license_content}"
|
||||
except Exception as e:
|
||||
# En cas d'erreur lors de la lecture du fichier de licence, continuer sans l'ajouter
|
||||
pass
|
||||
|
||||
return rendered_body
|
||||
|
||||
|
||||
def fichier_plus_recent(chemin_fichier, reference):
|
||||
try:
|
||||
modif = datetime.fromtimestamp(os.path.getmtime(chemin_fichier), tz=timezone.utc)
|
||||
return modif > reference
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def doit_regenerer_fiche(html_path, fiche_type, fiche_choisie, commit_url, fichiers_criticite):
|
||||
if not os.path.exists(html_path):
|
||||
return True
|
||||
|
||||
local_mtime = datetime.fromtimestamp(os.path.getmtime(html_path), tz=timezone.utc)
|
||||
remote_mtime = recuperer_date_dernier_commit(commit_url)
|
||||
|
||||
if remote_mtime is None or remote_mtime > local_mtime:
|
||||
return True
|
||||
|
||||
if fichier_plus_recent(fichiers_criticite.get("IHH"), local_mtime):
|
||||
return True
|
||||
|
||||
if fiche_type == "minerai" or "minerai" in fiche_choisie.lower():
|
||||
if fichier_plus_recent(fichiers_criticite.get("IVC"), local_mtime):
|
||||
return True
|
||||
if fichier_plus_recent(fichiers_criticite.get("ICS"), local_mtime):
|
||||
return True
|
||||
|
||||
return False
|
||||
22
app/fiches/utils/tickets/__init__.py
Normal file
22
app/fiches/utils/tickets/__init__.py
Normal file
@ -0,0 +1,22 @@
|
||||
# __init__.py du répertoire tickets
|
||||
|
||||
from .core import (
|
||||
rechercher_tickets_gitea,
|
||||
charger_fiches_et_labels,
|
||||
get_labels_existants,
|
||||
gitea_request,
|
||||
construire_corps_ticket_markdown,
|
||||
creer_ticket_gitea,
|
||||
nettoyer_labels
|
||||
)
|
||||
|
||||
from .display import (
|
||||
afficher_tickets_par_fiche,
|
||||
afficher_carte_ticket,
|
||||
recuperer_commentaires_ticket
|
||||
)
|
||||
|
||||
from .creation import (
|
||||
formulaire_creation_ticket_dynamique,
|
||||
charger_modele_ticket
|
||||
)
|
||||
119
app/fiches/utils/tickets/core.py
Normal file
119
app/fiches/utils/tickets/core.py
Normal file
@ -0,0 +1,119 @@
|
||||
# core.py
|
||||
|
||||
import csv
|
||||
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
|
||||
|
||||
|
||||
def gitea_request(method, url, **kwargs):
|
||||
headers = kwargs.pop("headers", {})
|
||||
headers["Authorization"] = f"token {GITEA_TOKEN}"
|
||||
try:
|
||||
response = requests.request(method, url, headers=headers, timeout=10, **kwargs)
|
||||
response.raise_for_status()
|
||||
return response
|
||||
except requests.RequestException as e:
|
||||
st.error(f"{str(_('errors.gitea_error'))} ({method.upper()}): {e}")
|
||||
return None
|
||||
|
||||
|
||||
def charger_fiches_et_labels():
|
||||
chemin_csv = os.path.join("assets", "fiches_labels.csv")
|
||||
dictionnaire_fiches = {}
|
||||
|
||||
try:
|
||||
with open(chemin_csv, mode="r", encoding="utf-8") as fichier_csv:
|
||||
lecteur = csv.DictReader(fichier_csv)
|
||||
for ligne in lecteur:
|
||||
fiche = ligne.get("Fiche")
|
||||
operations = ligne.get("Label opération")
|
||||
item = ligne.get("Label item")
|
||||
|
||||
if fiche and operations and item:
|
||||
dictionnaire_fiches[fiche.strip()] = {
|
||||
"operations": [op.strip() for op in operations.split("/")],
|
||||
"item": item.strip()
|
||||
}
|
||||
except FileNotFoundError:
|
||||
st.error(f"❌ {str(_('errors.file_not_found'))} {chemin_csv} {str(_('errors.is_missing'))}")
|
||||
except Exception as e:
|
||||
st.error(f"❌ {str(_('errors.file_loading'))} {str(e)}")
|
||||
|
||||
return dictionnaire_fiches
|
||||
|
||||
|
||||
def rechercher_tickets_gitea(fiche_selectionnee):
|
||||
params = {"state": "open"}
|
||||
url = f"{GITEA_URL}/repos/{ORGANISATION}/{DEPOT_FICHES}/issues"
|
||||
|
||||
reponse = gitea_request("get", url, params=params)
|
||||
if not reponse:
|
||||
return []
|
||||
|
||||
try:
|
||||
issues = reponse.json()
|
||||
except Exception as e:
|
||||
st.error(f"{str(_('errors.json_decode'))} {e}")
|
||||
return []
|
||||
|
||||
correspondances = charger_fiches_et_labels()
|
||||
cible = correspondances.get(fiche_selectionnee)
|
||||
if not cible:
|
||||
return []
|
||||
|
||||
labels_cibles = set([cible["item"]])
|
||||
tickets_associes = []
|
||||
|
||||
for issue in issues:
|
||||
if issue.get("ref") != f"refs/heads/{ENV}":
|
||||
continue
|
||||
issue_labels = set(label.get("name", "") for label in issue.get("labels", []))
|
||||
if labels_cibles.issubset(issue_labels):
|
||||
tickets_associes.append(issue)
|
||||
|
||||
return tickets_associes
|
||||
|
||||
|
||||
def get_labels_existants():
|
||||
url = f"{GITEA_URL}/repos/{ORGANISATION}/{DEPOT_FICHES}/labels"
|
||||
reponse = gitea_request("get", url)
|
||||
if not reponse:
|
||||
return {}
|
||||
|
||||
try:
|
||||
return {label['name']: label['id'] for label in reponse.json()}
|
||||
except Exception as e:
|
||||
st.error(f"{str(_('errors.label_parsing'))} {e}")
|
||||
return {}
|
||||
|
||||
|
||||
def nettoyer_labels(labels):
|
||||
return sorted(set(l.strip() for l in labels if isinstance(l, str) and l.strip()))
|
||||
|
||||
|
||||
def construire_corps_ticket_markdown(reponses):
|
||||
return "\n\n".join(f"## {section}\n{texte}" for section, texte in reponses.items())
|
||||
|
||||
|
||||
def creer_ticket_gitea(titre, corps, labels):
|
||||
data = {
|
||||
"title": titre,
|
||||
"body": corps,
|
||||
"labels": labels,
|
||||
"ref": f"refs/heads/{ENV}"
|
||||
}
|
||||
url = f"{GITEA_URL}/repos/{ORGANISATION}/{DEPOT_FICHES}/issues"
|
||||
|
||||
reponse = gitea_request("post", url, headers={"Content-Type": "application/json"}, data=json.dumps(data))
|
||||
if not reponse:
|
||||
return
|
||||
|
||||
issue_url = reponse.json().get("html_url", "")
|
||||
if issue_url:
|
||||
st.success(f"{str(_('pages.fiches.tickets.created_success'))} [Voir le ticket]({issue_url})")
|
||||
else:
|
||||
st.success(str(_('pages.fiches.tickets.created')))
|
||||
140
app/fiches/utils/tickets/creation.py
Normal file
140
app/fiches/utils/tickets/creation.py
Normal file
@ -0,0 +1,140 @@
|
||||
# creation.py
|
||||
|
||||
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
|
||||
|
||||
|
||||
def parser_modele_ticket(contenu_modele):
|
||||
"""Parse le modèle de ticket en sections."""
|
||||
sections = {}
|
||||
lignes = contenu_modele.splitlines()
|
||||
titre_courant, contenu = None, []
|
||||
|
||||
for ligne in lignes:
|
||||
if ligne.startswith("## "):
|
||||
if titre_courant:
|
||||
sections[titre_courant] = "\n".join(contenu).strip()
|
||||
titre_courant, contenu = ligne[3:].strip(), []
|
||||
elif titre_courant:
|
||||
contenu.append(ligne)
|
||||
|
||||
if titre_courant:
|
||||
sections[titre_courant] = "\n".join(contenu).strip()
|
||||
|
||||
return sections
|
||||
|
||||
|
||||
def generer_labels(fiche_selectionnee):
|
||||
"""Génère les labels basés sur la fiche sélectionnée."""
|
||||
labels, selected_ops = [], []
|
||||
correspondances = charger_fiches_et_labels()
|
||||
cible = correspondances.get(fiche_selectionnee)
|
||||
|
||||
if cible:
|
||||
if len(cible["operations"]) == 1:
|
||||
labels.append(cible["operations"][0])
|
||||
elif len(cible["operations"]) > 1:
|
||||
selected_ops = st.multiselect(str(_("pages.fiches.tickets.contribution_type")),
|
||||
cible["operations"],
|
||||
default=cible["operations"])
|
||||
|
||||
return labels, selected_ops, cible
|
||||
|
||||
|
||||
def creer_champs_formulaire(sections, fiche_selectionnee):
|
||||
"""Crée les champs du formulaire basés sur les sections."""
|
||||
reponses = {}
|
||||
|
||||
for section, aide in sections.items():
|
||||
if "Type de contribution" in section:
|
||||
options = sorted(set(re.findall(r"- \[.\] (.+)", aide)))
|
||||
if str(_("pages.fiches.tickets.other")) not in options:
|
||||
options.append(str(_("pages.fiches.tickets.other")))
|
||||
choix = st.radio(str(_("pages.fiches.tickets.contribution_type")), options)
|
||||
reponses[section] = st.text_input(str(_("pages.fiches.tickets.specify")), "") if choix == str(_("pages.fiches.tickets.other")) 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(str(_("pages.fiches.tickets.concerned_card")), value=url_fiche, disabled=True)
|
||||
elif "Sujet de la proposition" in section:
|
||||
reponses[section] = st.text_input(section, help=aide)
|
||||
else:
|
||||
reponses[section] = st.text_area(section, help=aide)
|
||||
|
||||
return reponses
|
||||
|
||||
|
||||
def afficher_controles_formulaire():
|
||||
"""Affiche les boutons de contrôle du formulaire."""
|
||||
col1, col2 = st.columns(2)
|
||||
if col1.button(str(_("pages.fiches.tickets.preview"))):
|
||||
st.session_state.previsualiser = True
|
||||
if col2.button(str(_("pages.fiches.tickets.cancel"))):
|
||||
st.session_state.previsualiser = False
|
||||
st.rerun()
|
||||
|
||||
|
||||
def gerer_previsualisation_et_soumission(reponses, labels, selected_ops, cible):
|
||||
"""Gère la prévisualisation et la soumission du ticket."""
|
||||
if not st.session_state.get("previsualiser", False):
|
||||
return
|
||||
|
||||
st.subheader(str(_("pages.fiches.tickets.preview_title")))
|
||||
for section, texte in reponses.items():
|
||||
st.markdown(f"#### {section}")
|
||||
st.code(texte, language="markdown")
|
||||
|
||||
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"**{str(_('pages.fiches.tickets.summary'))} :**\n- **{str(_('pages.fiches.tickets.title'))}** : `{titre_ticket}`\n- **{str(_('pages.fiches.tickets.labels'))}** : `{', '.join(final_labels)}`")
|
||||
|
||||
if st.button(str(_("pages.fiches.tickets.confirm"))):
|
||||
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:
|
||||
labels_ids.append(labels_existants["Backlog"])
|
||||
|
||||
corps = construire_corps_ticket_markdown(reponses)
|
||||
creer_ticket_gitea(titre_ticket, corps, labels_ids)
|
||||
|
||||
st.session_state.previsualiser = False
|
||||
st.success(str(_("pages.fiches.tickets.created")))
|
||||
|
||||
|
||||
def formulaire_creation_ticket_dynamique(fiche_selectionnee):
|
||||
"""Fonction principale pour le formulaire de création de ticket."""
|
||||
with st.expander(str(_("pages.fiches.tickets.create_new")), expanded=False):
|
||||
# Chargement et vérification du modèle
|
||||
contenu_modele = charger_modele_ticket()
|
||||
if not contenu_modele:
|
||||
st.error(str(_("pages.fiches.tickets.model_load_error")))
|
||||
return
|
||||
|
||||
# Traitement du modèle et génération du formulaire
|
||||
sections = parser_modele_ticket(contenu_modele)
|
||||
labels, selected_ops, cible = generer_labels(fiche_selectionnee)
|
||||
reponses = creer_champs_formulaire(sections, fiche_selectionnee)
|
||||
|
||||
# Gestion des contrôles et de la prévisualisation
|
||||
afficher_controles_formulaire()
|
||||
gerer_previsualisation_et_soumission(reponses, labels, selected_ops, cible)
|
||||
|
||||
|
||||
def charger_modele_ticket():
|
||||
from config import GITEA_URL, GITEA_TOKEN, ORGANISATION, DEPOT_FICHES
|
||||
|
||||
headers = {"Authorization": f"token {GITEA_TOKEN}"}
|
||||
url = f"{GITEA_URL}/repos/{ORGANISATION}/{DEPOT_FICHES}/contents/.gitea/ISSUE_TEMPLATE/Contenu.md"
|
||||
try:
|
||||
r = requests.get(url, headers=headers, timeout=10)
|
||||
r.raise_for_status()
|
||||
return base64.b64decode(r.json().get("content", "")).decode("utf-8")
|
||||
except Exception as e:
|
||||
st.error(f"{str(_('pages.fiches.tickets.model_error'))} {e}")
|
||||
return ""
|
||||
116
app/fiches/utils/tickets/display.py
Normal file
116
app/fiches/utils/tickets/display.py
Normal file
@ -0,0 +1,116 @@
|
||||
# display.py
|
||||
|
||||
import streamlit as st
|
||||
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",
|
||||
str(_("pages.fiches.tickets.status.awaiting")),
|
||||
str(_("pages.fiches.tickets.status.in_progress")),
|
||||
str(_("pages.fiches.tickets.status.completed")),
|
||||
str(_("pages.fiches.tickets.status.rejected"))]:
|
||||
if statut in labels:
|
||||
return statut
|
||||
return str(_("pages.fiches.tickets.status.others"))
|
||||
|
||||
|
||||
def afficher_tickets_par_fiche(tickets):
|
||||
if not tickets:
|
||||
st.info(str(_("pages.fiches.tickets.no_linked_tickets")))
|
||||
return
|
||||
|
||||
st.markdown(str(_("pages.fiches.tickets.associated_tickets")))
|
||||
tickets_groupes = defaultdict(list)
|
||||
for ticket in tickets:
|
||||
statut = extraire_statut_par_label(ticket)
|
||||
tickets_groupes[statut].append(ticket)
|
||||
|
||||
nb_backlogs = len(tickets_groupes["Backlog"])
|
||||
if nb_backlogs:
|
||||
st.info(f"⤇ {nb_backlogs} {str(_('pages.fiches.tickets.moderation_notice'))}")
|
||||
|
||||
ordre_statuts = [
|
||||
str(_("pages.fiches.tickets.status.awaiting")),
|
||||
str(_("pages.fiches.tickets.status.in_progress")),
|
||||
str(_("pages.fiches.tickets.status.completed")),
|
||||
str(_("pages.fiches.tickets.status.rejected")),
|
||||
str(_("pages.fiches.tickets.status.others"))
|
||||
]
|
||||
for statut in ordre_statuts:
|
||||
if tickets_groupes[statut]:
|
||||
with st.expander(f"{statut} ({len(tickets_groupes[statut])})", expanded=(statut == "En cours")):
|
||||
for ticket in tickets_groupes[statut]:
|
||||
afficher_carte_ticket(ticket)
|
||||
|
||||
|
||||
def recuperer_commentaires_ticket(issue_index):
|
||||
from config import GITEA_URL, GITEA_TOKEN, ORGANISATION, DEPOT_FICHES
|
||||
import requests
|
||||
|
||||
headers = {"Authorization": f"token {GITEA_TOKEN}"}
|
||||
url = f"{GITEA_URL}/repos/{ORGANISATION}/{DEPOT_FICHES}/issues/{issue_index}/comments"
|
||||
try:
|
||||
response = requests.get(url, headers=headers, timeout=10)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
st.error(f"{str(_('pages.fiches.tickets.comment_error'))} {e}")
|
||||
return []
|
||||
|
||||
|
||||
def afficher_carte_ticket(ticket):
|
||||
titre = ticket.get("title", str(_("pages.fiches.tickets.no_title")))
|
||||
url = ticket.get("html_url", "")
|
||||
user = ticket.get("user", {}).get("login", str(_("pages.fiches.tickets.unknown")))
|
||||
created = ticket.get("created_at", "")
|
||||
updated = ticket.get("updated_at", "")
|
||||
body = ticket.get("body", "")
|
||||
labels = [l["name"] for l in ticket.get("labels", []) if "name" in l]
|
||||
|
||||
sujet = ""
|
||||
match = re.search(r"## Sujet de la proposition\s+(.+?)(\n|$)", body, re.DOTALL)
|
||||
if match:
|
||||
sujet = match.group(1).strip()
|
||||
|
||||
def format_date(iso):
|
||||
try:
|
||||
return parser.isoparse(iso).strftime("%d/%m/%Y")
|
||||
except:
|
||||
return "?"
|
||||
|
||||
date_created_str = format_date(created)
|
||||
maj_info = f"({str(_('pages.fiches.tickets.updated'))} {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', str(_("pages.fiches.tickets.unknown"))))
|
||||
contenu = html.escape(commentaire.get('body', ''))
|
||||
date = format_date(commentaire.get('created_at', ''))
|
||||
commentaires_html += f"""
|
||||
<div class=\"conteneur_commentaire\">
|
||||
<p class=\"commentaire_auteur\"><strong>{auteur}</strong> <small>({date})</small></p>
|
||||
<p class=\"commentaire_contenu\">{contenu}</p>
|
||||
</div>
|
||||
"""
|
||||
|
||||
with st.container():
|
||||
st.markdown(f"""
|
||||
<div class=\"conteneur_ticket\">
|
||||
<h4><a href='{url}' target='_blank'>{titre}</a></h4>
|
||||
<p>{str(_("pages.fiches.tickets.opened_by"))} <strong>{html.escape(user)}</strong> {str(_("pages.fiches.tickets.on_date"))} {date_created_str} {maj_info}</p>
|
||||
<p>{str(_("pages.fiches.tickets.subject_label"))} : <strong>{html.escape(sujet)}</strong></p>
|
||||
<p>Labels : {' • '.join(labels) if labels else str(_("pages.fiches.tickets.no_labels"))}</p>
|
||||
</div>
|
||||
""", unsafe_allow_html=True)
|
||||
st.markdown(body, unsafe_allow_html=False)
|
||||
st.markdown("---")
|
||||
st.markdown(str(_("pages.fiches.tickets.comments")))
|
||||
st.markdown(commentaires_html or str(_("pages.fiches.tickets.no_comments")), unsafe_allow_html=True)
|
||||
40
app/personnalisation/README.md
Normal file
40
app/personnalisation/README.md
Normal file
@ -0,0 +1,40 @@
|
||||
# Module de Personnalisation
|
||||
|
||||
Ce module permet aux utilisateurs de personnaliser la chaîne de fabrication en ajoutant, modifiant ou important des produits finaux et leurs composants. Il offre une interface interactive pour manipuler le graphe de la chaîne de fabrication.
|
||||
|
||||
## Structure du module
|
||||
|
||||
Le module de personnalisation est organisé comme suit :
|
||||
|
||||
- **interface.py** : Point d'entrée principal qui coordonne les différentes fonctionnalités de personnalisation
|
||||
- **ajout.py** : Gère l'ajout de nouveaux produits finaux et leurs relations avec les composants
|
||||
- **modification.py** : Permet de modifier les produits existants et leurs connexions
|
||||
- **import_export.py** : Facilite l'import et l'export de configurations de graphes
|
||||
|
||||
## Fonctionnalités
|
||||
|
||||
### Ajout de produits
|
||||
- Interface pour ajouter de nouveaux produits finaux personnalisés
|
||||
- Sélection des composants à associer au produit
|
||||
- Définition des opérations d'assemblage
|
||||
- Mise à jour automatique du graphe
|
||||
|
||||
### Modification de produits
|
||||
- Sélection des produits existants à modifier
|
||||
- Modification des relations avec les composants
|
||||
- Option de suppression des produits
|
||||
- Mise à jour des opérations d'assemblage
|
||||
|
||||
### Import/Export
|
||||
- Exportation du graphe au format JSON pour sauvegarde ou partage
|
||||
- Importation de configurations préexistantes
|
||||
- Restauration de versions précédentes du graphe
|
||||
|
||||
## Utilisation
|
||||
|
||||
1. Accédez à l'onglet "Personnalisation" dans l'interface principale
|
||||
2. Pour ajouter un produit, utilisez la section "Ajouter un produit final"
|
||||
3. Pour modifier un produit existant, utilisez la section "Modifier un produit final ajouté"
|
||||
4. Pour sauvegarder ou partager votre configuration, utilisez les options d'import/export
|
||||
|
||||
Ce module permet de créer des scénarios personnalisés pour analyser différentes configurations de la chaîne de fabrication du numérique et évaluer leurs vulnérabilités potentielles.
|
||||
6
app/personnalisation/__init__.py
Normal file
6
app/personnalisation/__init__.py
Normal file
@ -0,0 +1,6 @@
|
||||
# __init__.py – app/personnalisation
|
||||
|
||||
from .interface import interface_personnalisation
|
||||
from .ajout import ajouter_produit
|
||||
from .modification import modifier_produit
|
||||
from .import_export import importer_exporter_graph
|
||||
23
app/personnalisation/ajout.py
Normal file
23
app/personnalisation/ajout.py
Normal file
@ -0,0 +1,23 @@
|
||||
import streamlit as st
|
||||
from utils.translations import _
|
||||
|
||||
def ajouter_produit(G):
|
||||
st.markdown(f"## {str(_('pages.personnalisation.add_new_product'))}")
|
||||
new_prod = st.text_input(str(_("pages.personnalisation.new_product_name")), 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(str(_("pages.personnalisation.assembly_operation")), [str(_("pages.personnalisation.none"))] + ops_dispo, index=0)
|
||||
niveau1 = sorted([n for n, d in G.nodes(data=True) if d.get("niveau") == "1"])
|
||||
sel_comps = st.multiselect(str(_("pages.personnalisation.components_to_link")), options=niveau1)
|
||||
if st.button(str(_("pages.personnalisation.create_product"))):
|
||||
G.add_node(new_prod, niveau="0", personnalisation="oui", label=new_prod)
|
||||
if sel_new_op != str(_("pages.personnalisation.none")):
|
||||
G.add_edge(new_prod, sel_new_op)
|
||||
for comp in sel_comps:
|
||||
G.add_edge(new_prod, comp)
|
||||
st.success(f"{new_prod} {str(_('pages.personnalisation.added'))}")
|
||||
return G
|
||||
53
app/personnalisation/import_export.py
Normal file
53
app/personnalisation/import_export.py
Normal file
@ -0,0 +1,53 @@
|
||||
import streamlit as st
|
||||
import json
|
||||
from utils.translations import get_translation as _
|
||||
|
||||
def importer_exporter_graph(G):
|
||||
st.markdown(f"## {_('pages.personnalisation.save_restore_config')}")
|
||||
if st.button(str(_("pages.personnalisation.export_config"))):
|
||||
nodes = [n for n, d in G.nodes(data=True) if d.get("personnalisation") == "oui"]
|
||||
edges = [(u, v) for u, v in G.edges() if u in nodes]
|
||||
conf = {"nodes": nodes, "edges": edges}
|
||||
json_str = json.dumps(conf, ensure_ascii=False)
|
||||
st.download_button(
|
||||
label=str(_("pages.personnalisation.download_json")),
|
||||
data=json_str,
|
||||
file_name="config_personnalisation.json",
|
||||
mime="application/json"
|
||||
)
|
||||
|
||||
uploaded = st.file_uploader(str(_("pages.personnalisation.import_config")), type=["json"])
|
||||
if uploaded:
|
||||
if uploaded.size > 100 * 1024:
|
||||
st.error(_("pages.personnalisation.file_too_large"))
|
||||
else:
|
||||
try:
|
||||
conf = json.loads(uploaded.read().decode("utf-8"))
|
||||
all_nodes = conf.get("nodes", [])
|
||||
all_edges = conf.get("edges", [])
|
||||
|
||||
if not all_nodes:
|
||||
st.warning(_("pages.personnalisation.no_products_found"))
|
||||
else:
|
||||
st.markdown(f"### {_('pages.personnalisation.select_products_to_restore')}")
|
||||
sel_nodes = st.multiselect(
|
||||
str(_("pages.personnalisation.products_to_restore")),
|
||||
options=all_nodes,
|
||||
default=all_nodes,
|
||||
key="restaurer_selection"
|
||||
)
|
||||
|
||||
if st.button(str(_("pages.personnalisation.restore_selected")), type="primary"):
|
||||
for node in sel_nodes:
|
||||
if not G.has_node(node):
|
||||
G.add_node(node, niveau="0", personnalisation="oui", label=node)
|
||||
|
||||
for u, v in all_edges:
|
||||
if u in sel_nodes and v in sel_nodes + list(G.nodes()) and not G.has_edge(u, v):
|
||||
G.add_edge(u, v)
|
||||
|
||||
st.success(_("pages.personnalisation.config_restored"))
|
||||
except Exception as e:
|
||||
st.error(f"{_('errors.import_error')} {e}")
|
||||
|
||||
return G
|
||||
19
app/personnalisation/interface.py
Normal file
19
app/personnalisation/interface.py
Normal file
@ -0,0 +1,19 @@
|
||||
# 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(f"# {str(_('pages.personnalisation.title'))}")
|
||||
with st.expander(str(_("pages.personnalisation.help")), expanded=False):
|
||||
st.markdown("\n".join(_("pages.personnalisation.help_content")))
|
||||
st.markdown("---")
|
||||
|
||||
G = ajouter_produit(G)
|
||||
G = modifier_produit(G)
|
||||
G = importer_exporter_graph(G)
|
||||
|
||||
return G
|
||||
87
app/personnalisation/modification.py
Normal file
87
app/personnalisation/modification.py
Normal file
@ -0,0 +1,87 @@
|
||||
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."""
|
||||
return sorted([n for n, d in G.nodes(data=True) if d.get("niveau") == "0" and d.get("personnalisation") == "oui"])
|
||||
|
||||
def supprimer_produit(G, prod):
|
||||
"""Supprime un produit du graphe."""
|
||||
G.remove_node(prod)
|
||||
st.success(f"{prod} {str(_('pages.personnalisation.deleted'))}")
|
||||
st.session_state.pop("prod_sel", None)
|
||||
return G
|
||||
|
||||
def get_operations_disponibles(G):
|
||||
"""Récupère la liste des opérations d'assemblage disponibles."""
|
||||
return 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))
|
||||
])
|
||||
|
||||
def get_operations_actuelles(G, prod):
|
||||
"""Récupère les opérations actuellement liées au produit."""
|
||||
return [succ for succ in G.successors(prod) if G.nodes[succ].get("niveau") == "10"]
|
||||
|
||||
def get_composants_niveau1(G):
|
||||
"""Récupère la liste des composants de niveau 1."""
|
||||
return sorted([n for n, d in G.nodes(data=True) if d.get("niveau") == "1"])
|
||||
|
||||
def get_composants_lies(G, prod):
|
||||
"""Récupère les composants actuellement liés au produit."""
|
||||
return [succ for succ in G.successors(prod) if G.nodes[succ].get("niveau") == "1"]
|
||||
|
||||
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 == none_option or op != sel_op:
|
||||
G.remove_edge(prod, op)
|
||||
if sel_op != none_option and (not curr_ops or sel_op not in curr_ops):
|
||||
G.add_edge(prod, sel_op)
|
||||
return G
|
||||
|
||||
def mettre_a_jour_composants(G, prod, linked, nouveaux):
|
||||
"""Met à jour les composants liés au produit."""
|
||||
for comp in set(linked) - set(nouveaux):
|
||||
G.remove_edge(prod, comp)
|
||||
for comp in set(nouveaux) - set(linked):
|
||||
G.add_edge(prod, comp)
|
||||
return G
|
||||
|
||||
def modifier_produit(G):
|
||||
st.markdown(f"## {str(_('pages.personnalisation.modify_product'))}")
|
||||
|
||||
# Sélection du produit à modifier
|
||||
produits0 = get_produits_personnalises(G)
|
||||
sel_display = st.multiselect(str(_("pages.personnalisation.products_to_modify")), options=produits0)
|
||||
|
||||
if not sel_display:
|
||||
return G
|
||||
|
||||
# Obtention du produit sélectionné
|
||||
prod = sel_display[0]
|
||||
|
||||
# Suppression du produit si demandé
|
||||
if st.button(f"{str(_('pages.personnalisation.delete'))} {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(str(_("pages.personnalisation.linked_assembly_operation")), [str(_("pages.personnalisation.none"))] + ops_dispo, index=default_idx)
|
||||
|
||||
# Gestion des composants
|
||||
niveau1 = get_composants_niveau1(G)
|
||||
linked = get_composants_lies(G, prod)
|
||||
nouveaux = st.multiselect(f"{str(_('pages.personnalisation.components_linked_to'))} {prod}", options=niveau1, default=linked)
|
||||
|
||||
# Mise à jour des liens si demandé
|
||||
if st.button(f"{str(_('pages.personnalisation.update'))} {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} {str(_('pages.personnalisation.updated'))}")
|
||||
|
||||
return G
|
||||
39
app/visualisations/README.md
Normal file
39
app/visualisations/README.md
Normal file
@ -0,0 +1,39 @@
|
||||
# Module de Visualisations
|
||||
|
||||
Ce module offre diverses visualisations graphiques pour analyser et comprendre la chaîne de fabrication du numérique. Il permet d'explorer les données sous différents angles et de produire des représentations visuelles informatives.
|
||||
|
||||
## Structure du module
|
||||
|
||||
Le module de visualisations est principalement constitué de :
|
||||
|
||||
- **interface.py** : Gère l'interface utilisateur pour les différentes visualisations disponibles
|
||||
- Utilise les bibliothèques de visualisation comme Plotly, Altair ou matplotlib pour générer les graphiques
|
||||
|
||||
## Fonctionnalités
|
||||
|
||||
### Types de visualisations
|
||||
|
||||
- **Cartographie** : Affichage des données sur des cartes pour visualiser la distribution géographique
|
||||
- **Graphiques de répartition** : Visualisation de la distribution des acteurs, pays et ressources
|
||||
- **Indices de criticité** : Représentations graphiques des différents indices :
|
||||
- IHH (Indice Herfindahl-Hirschman) pour la concentration des marchés
|
||||
- IVC (Indice de Vulnérabilité Concurrentielle)
|
||||
- ISG (Indice de Stabilité Géopolitique)
|
||||
- **Statistiques comparatives** : Comparaison entre différentes ressources ou composants
|
||||
|
||||
### Personnalisation des visualisations
|
||||
|
||||
- Filtres pour ajuster les données affichées
|
||||
- Options de tri et de regroupement
|
||||
- Configuration des paramètres visuels (couleurs, échelles, etc.)
|
||||
- Possibilité d'exporter les visualisations
|
||||
|
||||
## Utilisation
|
||||
|
||||
1. Accédez à l'onglet "Visualisations" dans l'interface principale
|
||||
2. Sélectionnez le type de visualisation souhaité
|
||||
3. Configurez les paramètres selon vos besoins d'analyse
|
||||
4. Interagissez avec les graphiques pour explorer les données
|
||||
5. Utilisez les options d'exportation si nécessaire pour sauvegarder les résultats
|
||||
|
||||
Ces visualisations constituent un outil puissant pour l'analyse des vulnérabilités et des dépendances dans la chaîne de fabrication du numérique, permettant d'identifier rapidement les points critiques.
|
||||
2
app/visualisations/__init__.py
Normal file
2
app/visualisations/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
# __init__.py – app/fiches
|
||||
from .interface import interface_visualisations
|
||||
195
app/visualisations/graphes.py
Normal file
195
app/visualisations/graphes.py
Normal file
@ -0,0 +1,195 @@
|
||||
import streamlit as st
|
||||
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):
|
||||
# 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")),
|
||||
"Fabrication": str(_("pages.visualisations.categories.manufacturing")),
|
||||
"Traitement": str(_("pages.visualisations.categories.processing")),
|
||||
"Extraction": str(_("pages.visualisations.categories.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)
|
||||
|
||||
offset_x = []
|
||||
offset_y = {}
|
||||
seen = Counter()
|
||||
for pair in coord_pairs:
|
||||
rank = seen[pair]
|
||||
seen[pair] += 1
|
||||
if counts[pair] > 1:
|
||||
angle = rank * 1.5
|
||||
radius = 0.8 + 0.4 * rank
|
||||
offset_x.append(radius * np.cos(angle))
|
||||
offset_y[pair] = radius * np.sin(angle)
|
||||
else:
|
||||
offset_x.append(0)
|
||||
offset_y[pair] = 0
|
||||
|
||||
df_cat['ihh_pays'] += offset_x
|
||||
df_cat['ihh_acteurs'] += [offset_y[p] for p in coord_pairs]
|
||||
df_cat['ihh_pays_text'] = df_cat['ihh_pays'] + 0.5
|
||||
df_cat['ihh_acteurs_text'] = df_cat['ihh_acteurs'] + 0.5
|
||||
|
||||
base = alt.Chart(df_cat).encode(
|
||||
x=alt.X('ihh_pays:Q', title=str(_("pages.visualisations.axis_titles.ihh_countries"))),
|
||||
y=alt.Y('ihh_acteurs:Q', title=str(_("pages.visualisations.axis_titles.ihh_actors"))),
|
||||
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']))
|
||||
)
|
||||
|
||||
points = base.mark_circle(opacity=0.6)
|
||||
lines = alt.Chart(df_cat).mark_rule(strokeWidth=0.5, color='gray').encode(
|
||||
x='ihh_pays:Q', x2='ihh_pays_text:Q',
|
||||
y='ihh_acteurs:Q', y2='ihh_acteurs_text:Q'
|
||||
)
|
||||
|
||||
labels = alt.Chart(df_cat).mark_text(
|
||||
align='left', dx=3, dy=-3, fontSize=8, font='Arial', angle=335
|
||||
).encode(
|
||||
x='ihh_pays_text:Q',
|
||||
y='ihh_acteurs_text:Q',
|
||||
text='nom:N'
|
||||
)
|
||||
|
||||
hline_15 = alt.Chart(df_cat).mark_rule(strokeDash=[2,2], color='green').encode(y=alt.datum(15))
|
||||
hline_25 = alt.Chart(df_cat).mark_rule(strokeDash=[2,2], color='red').encode(y=alt.datum(25))
|
||||
hline_100 = alt.Chart(df_cat).mark_rule(strokeDash=[2,2], color='white').encode(y=alt.datum(100))
|
||||
vline_15 = alt.Chart(df_cat).mark_rule(strokeDash=[2,2], color='green').encode(x=alt.datum(15))
|
||||
vline_25 = alt.Chart(df_cat).mark_rule(strokeDash=[2,2], color='red').encode(x=alt.datum(25))
|
||||
vline_100 = alt.Chart(df_cat).mark_rule(strokeDash=[2,2], color='white').encode(x=alt.datum(100))
|
||||
|
||||
chart = (points + lines + labels + hline_15 + hline_25 + hline_100 + vline_15 + vline_25 + vline_100).properties(
|
||||
width=500,
|
||||
height=400,
|
||||
title=str(_("pages.visualisations.chart_titles.concentration_criticality")).format(cat_traduit)
|
||||
).interactive()
|
||||
|
||||
st.altair_chart(chart, use_container_width=True)
|
||||
|
||||
|
||||
def creer_graphes(donnees):
|
||||
if not donnees:
|
||||
st.warning(str(_("pages.visualisations.no_data")))
|
||||
return
|
||||
|
||||
try:
|
||||
df = pd.DataFrame(donnees)
|
||||
df['ivc_cat'] = df['ivc'].apply(lambda x: 1 if x <= 15 else (2 if x <= 30 else 3))
|
||||
|
||||
from collections import Counter
|
||||
coord_pairs = list(zip(df['ihh_extraction'].round(1), df['ihh_reserves'].round(1)))
|
||||
counts = Counter(coord_pairs)
|
||||
|
||||
offset_x, offset_y = [], {}
|
||||
seen = Counter()
|
||||
for pair in coord_pairs:
|
||||
rank = seen[pair]
|
||||
seen[pair] += 1
|
||||
if counts[pair] > 1:
|
||||
angle = rank * 1.5
|
||||
radius = 0.8 + 0.4 * rank
|
||||
offset_x.append(radius * np.cos(angle))
|
||||
offset_y[pair] = radius * np.sin(angle)
|
||||
else:
|
||||
offset_x.append(0)
|
||||
offset_y[pair] = 0
|
||||
|
||||
df['ihh_extraction'] += offset_x
|
||||
df['ihh_reserves'] += [offset_y[p] for p in coord_pairs]
|
||||
df['ihh_extraction_text'] = df['ihh_extraction'] + 0.5
|
||||
df['ihh_reserves_text'] = df['ihh_reserves'] + 0.5
|
||||
|
||||
base = alt.Chart(df).encode(
|
||||
x=alt.X('ihh_extraction:Q', title=str(_("pages.visualisations.axis_titles.ihh_extraction"))),
|
||||
y=alt.Y('ihh_reserves:Q', title=str(_("pages.visualisations.axis_titles.ihh_reserves"))),
|
||||
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']
|
||||
)
|
||||
|
||||
points = base.mark_circle(opacity=0.6)
|
||||
lines = alt.Chart(df).mark_rule(strokeWidth=0.5, color='gray').encode(
|
||||
x='ihh_extraction:Q', x2='ihh_extraction_text:Q',
|
||||
y='ihh_reserves:Q', y2='ihh_reserves_text:Q'
|
||||
)
|
||||
|
||||
labels = alt.Chart(df).mark_text(
|
||||
align='left', dx=10, dy=-10, fontSize=10, font='Arial', angle=335
|
||||
).encode(
|
||||
x='ihh_extraction_text:Q',
|
||||
y='ihh_reserves_text:Q',
|
||||
text='nom:N'
|
||||
)
|
||||
|
||||
hline_15 = alt.Chart(df).mark_rule(strokeDash=[2,2], color='green').encode(y=alt.datum(15))
|
||||
hline_25 = alt.Chart(df).mark_rule(strokeDash=[2,2], color='red').encode(y=alt.datum(25))
|
||||
hline_100 = alt.Chart(df).mark_rule(strokeDash=[2,2], color='red').encode(y=alt.datum(100))
|
||||
vline_15 = alt.Chart(df).mark_rule(strokeDash=[2,2], color='green').encode(x=alt.datum(15))
|
||||
vline_25 = alt.Chart(df).mark_rule(strokeDash=[2,2], color='red').encode(x=alt.datum(25))
|
||||
vline_100 = alt.Chart(df).mark_rule(strokeDash=[2,2], color='red').encode(x=alt.datum(100))
|
||||
|
||||
chart = (points + lines + labels + hline_15 + hline_25 + hline_100 + vline_15 + vline_25 + vline_100).properties(
|
||||
width=600,
|
||||
height=500,
|
||||
title=str(_("pages.visualisations.chart_titles.concentration_resources"))
|
||||
).interactive()
|
||||
|
||||
st.altair_chart(chart, use_container_width=True)
|
||||
|
||||
except Exception as e:
|
||||
st.error(f"{str(_('errors.graph_creation_error'))} {e}")
|
||||
|
||||
|
||||
def lancer_visualisation_ihh_criticite(graph):
|
||||
try:
|
||||
import networkx as nx
|
||||
from utils.graph_utils import recuperer_donnees
|
||||
|
||||
niveaux = nx.get_node_attributes(graph, "niveau")
|
||||
noeuds = [n for n, v in niveaux.items() if v == "10" and "Reserves" not in n]
|
||||
noeuds.sort()
|
||||
|
||||
df = recuperer_donnees(graph, noeuds)
|
||||
if df.empty:
|
||||
st.warning(str(_("pages.visualisations.no_data")))
|
||||
else:
|
||||
afficher_graphique_altair(df)
|
||||
except Exception as e:
|
||||
st.error(f"{str(_('errors.ihh_criticality_error'))} {e}")
|
||||
|
||||
|
||||
def lancer_visualisation_ihh_ivc(graph):
|
||||
try:
|
||||
from utils.graph_utils import recuperer_donnees_2
|
||||
noeuds_niveau_2 = [
|
||||
n for n, data in graph.nodes(data=True)
|
||||
if data.get("niveau") == "2" and "ivc" in data
|
||||
]
|
||||
if not noeuds_niveau_2:
|
||||
return
|
||||
data = recuperer_donnees_2(graph, noeuds_niveau_2)
|
||||
creer_graphes(data)
|
||||
except Exception as e:
|
||||
st.error(f"{str(_('errors.ihh_ivc_error'))} {e}")
|
||||
35
app/visualisations/interface.py
Normal file
35
app/visualisations/interface.py
Normal file
@ -0,0 +1,35 @@
|
||||
import streamlit as st
|
||||
from utils.translations import _
|
||||
|
||||
from .graphes import (
|
||||
lancer_visualisation_ihh_criticite,
|
||||
lancer_visualisation_ihh_ivc
|
||||
)
|
||||
|
||||
|
||||
def interface_visualisations(G_temp, G_temp_ivc):
|
||||
st.markdown(f"# {str(_('pages.visualisations.title'))}")
|
||||
with st.expander(str(_("pages.visualisations.help")), expanded=False):
|
||||
st.markdown("\n".join(_("pages.visualisations.help_content")))
|
||||
st.markdown("---")
|
||||
|
||||
st.markdown(f"""## {str(_("pages.visualisations.ihh_criticality"))}
|
||||
|
||||
{str(_("pages.visualisations.ihh_criticality_desc"))}
|
||||
""")
|
||||
if st.button(str(_("buttons.run")), key="btn_ihh_criticite"):
|
||||
try:
|
||||
lancer_visualisation_ihh_criticite(G_temp)
|
||||
except Exception as e:
|
||||
st.error(f"{str(_('errors.ihh_criticality_error'))} {e}")
|
||||
|
||||
st.markdown(f"""## {str(_("pages.visualisations.ihh_ivc"))}
|
||||
|
||||
{str(_("pages.visualisations.ihh_ivc_desc"))}
|
||||
""")
|
||||
|
||||
if st.button(str(_("buttons.run")), key="btn_ihh_ivc"):
|
||||
try:
|
||||
lancer_visualisation_ihh_ivc(G_temp_ivc)
|
||||
except Exception as e:
|
||||
st.error(f"{str(_('errors.ihh_ivc_error'))} {e}")
|
||||
40
assets/README.md
Normal file
40
assets/README.md
Normal file
@ -0,0 +1,40 @@
|
||||
# Les assets de FabNum
|
||||
|
||||
## Styles
|
||||
|
||||
Le fichier **styles.css** a été construit pour agir sur le styme produit par Streamlit ou pour décorer des éléments construits par fabnum.py
|
||||
|
||||
Il sera important de regarder s'il est possible d'interagir avec le css de Streamlit sans passer par des déclarations !important
|
||||
|
||||
## Icone
|
||||
|
||||
Le fichier **weakness.png** sert d'icone à l'onglet.
|
||||
|
||||
## Dictionnaire
|
||||
|
||||
Le fichier **fiches_labels.csv** permet de faire le lien entre les fiches de l'application et les labels de la gestion des tickets.
|
||||
|
||||
Il est constitué sous la forme suivante :
|
||||
|
||||
Fiche,Label opération,Label item
|
||||
Fiche assemblage casques VR.md,Assemblage,CasquesVR
|
||||
Fiche assemblage imprimante.md,Assemblage,Imprimante
|
||||
Fiche assemblage IoT_Wearables.md,Assemblage,IoTWearables
|
||||
…
|
||||
Fiche minerai tungstene.md,Extraction/Traitement/Réserves,Tungstene
|
||||
Fiche minerai verre.md,Extraction/Traitement/Réserves,Verre
|
||||
Fiche minerai yttrium.md,Extraction/Traitement/Réserves,Yttrium
|
||||
Fiche minerai zinc.md,Extraction/Traitement/Réserves,Zinc
|
||||
Fiche technique ICS.md,Criticité,ICS
|
||||
Fiche technique IHH.md,Criticité,IHH
|
||||
Fiche technique IVC.md,Criticité,IVC
|
||||
|
||||
La première colonne donne le nom **exact** de la fiche telle qu'elle se trouve dans le dépôt.
|
||||
|
||||
La deuxième colonne donne le ou les labels des opérations qui peuvent être associées à la fiche. S'il y a plusieurs labels possibles, ils sont concaténés avec un "/" comme séparateur.
|
||||
|
||||
La dernière colonne donne le label de l'item associé à la fiche.
|
||||
|
||||
Les labels associés aux fiches dans ce fichier doivent, bien évidemment, être **totalement égaux** aux labels de la gestion des tickets.
|
||||
|
||||
Ces labels avec en plus la branche permettent de faire le lien exact entre une fiche présentée dans l'application et le système de gestion des tickets.
|
||||
28
assets/config.yaml
Normal file
28
assets/config.yaml
Normal file
@ -0,0 +1,28 @@
|
||||
version: 1.1
|
||||
date: 2025-05-06
|
||||
|
||||
seuils:
|
||||
IVC: # Indice de vulnérabilité concurrentielle
|
||||
vert: { max: 5 }
|
||||
orange: { min: 5, max: 15 }
|
||||
rouge: { min: 15 }
|
||||
|
||||
IHH: # Index Herfindahl-Hirschman
|
||||
vert: { max: 15 }
|
||||
orange: { min: 15, max: 25 }
|
||||
rouge: { min: 25 }
|
||||
|
||||
ICS: # Indice de criticité de substitution
|
||||
vert: { max: 0.30 }
|
||||
orange: { min: 0.30, max: 0.60 }
|
||||
rouge: { min: 0.60 }
|
||||
|
||||
ISG: # Indice de stabilité géopolitique (nouveau)
|
||||
vert: { max: 40 }
|
||||
orange: { min: 40, max: 70 }
|
||||
rouge: { min: 70 }
|
||||
|
||||
ITH: # Indice « en préparation »
|
||||
vert: { max: null } # à définir
|
||||
orange: { min: null, max: null }
|
||||
rouge: { min: null }
|
||||
@ -36,15 +36,15 @@ Fiche minerai aluminium.md,Extraction/Traitement/Réserves,Aluminium
|
||||
Fiche minerai antimoine.md,Extraction/Traitement/Réserves,Antimoine
|
||||
Fiche minerai argent.md,Extraction/Traitement/Réserves,Argent
|
||||
Fiche minerai arsenic.md,Extraction/Traitement/Réserves,Arsenic
|
||||
Fiche minerai beryllium.md,Extraction/Traitement/Réserves,Beryllium
|
||||
Fiche minerai ceramiques.md,Extraction/Traitement/Réserves,Ceramiques
|
||||
Fiche minerai cerium.md,Extraction/Traitement/Réserves,Cerium
|
||||
Fiche minerai béryllium.md,Extraction/Traitement/Réserves,Beryllium
|
||||
Fiche minerai céramiques.md,Extraction/Traitement/Réserves,Ceramiques
|
||||
Fiche minerai cérium.md,Extraction/Traitement/Réserves,Cerium
|
||||
Fiche minerai chrome.md,Extraction/Traitement/Réserves,Chrome
|
||||
Fiche minerai cobalt.md,Extraction/Traitement/Réserves,Cobalt
|
||||
Fiche minerai cuivre.md,Extraction/Traitement/Réserves,Cuivre
|
||||
Fiche minerai dysprosium.md,Extraction/Traitement/Réserves,Dysprosium
|
||||
Fiche minerai erbium.md,Extraction/Traitement/Réserves,Erbium
|
||||
Fiche minerai etain.md,Extraction/Traitement/Réserves,Etain
|
||||
Fiche minerai étain.md,Extraction/Traitement/Réserves,Etain
|
||||
Fiche minerai europium.md,Extraction/Traitement/Réserves,Europium
|
||||
Fiche minerai fluorite.md,Extraction/Traitement/Réserves,Fluorite
|
||||
Fiche minerai gadolinium.md,Extraction/Traitement/Réserves,Gadolinium
|
||||
@ -56,9 +56,9 @@ Fiche minerai holmium.md,Extraction/Traitement/Réserves,Holmium
|
||||
Fiche minerai indiumetain.md,Extraction/Traitement/Réserves,IndiumEtain
|
||||
Fiche minerai lanthane.md,Extraction/Traitement/Réserves,Lanthane
|
||||
Fiche minerai lithium.md,Extraction/Traitement/Réserves,Lithium
|
||||
Fiche minerai magnesium.md,Extraction/Traitement/Réserves,Magnesium
|
||||
Fiche minerai manganese.md,Extraction/Traitement/Réserves,Manganese
|
||||
Fiche minerai neodyme.md,Extraction/Traitement/Réserves,Neodyme
|
||||
Fiche minerai magnésium.md,Extraction/Traitement/Réserves,Magnesium
|
||||
Fiche minerai manganèse.md,Extraction/Traitement/Réserves,Manganese
|
||||
Fiche minerai néodyme.md,Extraction/Traitement/Réserves,Neodyme
|
||||
Fiche minerai nickel.md,Extraction/Traitement/Réserves,Nickel
|
||||
Fiche minerai or.md,Extraction/Traitement/Réserves,Or
|
||||
Fiche minerai palladium.md,Extraction/Traitement/Réserves,Palladium
|
||||
@ -67,8 +67,8 @@ Fiche minerai phosphore.md,Extraction/Traitement/Réserves,Phosphore
|
||||
Fiche minerai plastiques.md,Extraction/Traitement/Réserves,Plastiques
|
||||
Fiche minerai platine.md,Extraction/Traitement/Réserves,Platine
|
||||
Fiche minerai plomb.md,Extraction/Traitement/Réserves,Plomb
|
||||
Fiche minerai polystyrene.md,Extraction/Traitement/Réserves,Polystyrene
|
||||
Fiche minerai praseodyme.md,Extraction/Traitement/Réserves,Praseodyme
|
||||
Fiche minerai polystyrène.md,Extraction/Traitement/Réserves,Polystyrene
|
||||
Fiche minerai praséodyme.md,Extraction/Traitement/Réserves,Praseodyme
|
||||
Fiche minerai pvc.md,Extraction/Traitement/Réserves,PVC
|
||||
Fiche minerai quartz.md,Extraction/Traitement/Réserves,Quartz
|
||||
Fiche minerai samarium.md,Extraction/Traitement/Réserves,Samarium
|
||||
@ -77,7 +77,7 @@ Fiche minerai silicium.md,Extraction/Traitement/Réserves,Silicium
|
||||
Fiche minerai tantale.md,Extraction/Traitement/Réserves,Tantale
|
||||
Fiche minerai terbium.md,Extraction/Traitement/Réserves,Terbium
|
||||
Fiche minerai titane.md,Extraction/Traitement/Réserves,Titane
|
||||
Fiche minerai tungstene.md,Extraction/Traitement/Réserves,Tungstene
|
||||
Fiche minerai tungstène.md,Extraction/Traitement/Réserves,Tungstene
|
||||
Fiche minerai verre.md,Extraction/Traitement/Réserves,Verre
|
||||
Fiche minerai yttrium.md,Extraction/Traitement/Réserves,Yttrium
|
||||
Fiche minerai zinc.md,Extraction/Traitement/Réserves,Zinc
|
||||
|
||||
|
14
assets/impact_co2.js
Normal file
14
assets/impact_co2.js
Normal file
@ -0,0 +1,14 @@
|
||||
import tgwf from "https://cdn.skypack.dev/@tgwf/co2";
|
||||
|
||||
export function calculerImpactCO2(totalBytes) {
|
||||
const emissions = new tgwf.co2();
|
||||
const greenHost = true;
|
||||
|
||||
let estimatedCO2 = emissions.perByte(totalBytes, greenHost).toFixed(1);
|
||||
let totalMB = (totalBytes / (1024 * 1024)).toFixed(1);
|
||||
|
||||
const target = document.getElementById("network-usage");
|
||||
if (target) {
|
||||
target.innerHTML = `Transfert : ${totalMB} Mo<br>CO₂eq estimé : ${estimatedCO2} g`;
|
||||
}
|
||||
}
|
||||
3
assets/licence.md
Normal file
3
assets/licence.md
Normal file
@ -0,0 +1,3 @@
|
||||
Document généré avec [FabNum](https://fabnum.peccini.fr) par [Stéphan Peccini](mailto:stephan-pro@peccini.fr)
|
||||
|
||||
Licence : [CC BY-NC-ND 4.0](https://creativecommons.org/licenses/by-nc-nd/4.0/deed.fr) - Attribution - Utilisation non commerciale - Pas d’Œuvre dérivée
|
||||
276
assets/locales/en copy.json
Normal file
276
assets/locales/en copy.json
Normal 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": "Visualizations",
|
||||
"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/en.json
Normal file
276
assets/locales/en.json
Normal 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": "Visualizations",
|
||||
"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
276
assets/locales/fr.json
Normal 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": "Visualisations",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,127 +0,0 @@
|
||||
/* styles.css */
|
||||
|
||||
body,
|
||||
html {
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial,
|
||||
sans-serif;
|
||||
}
|
||||
|
||||
.stAppHeader {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
/* Conteneur principal */
|
||||
.block-container {
|
||||
max-width: 1024px !important;
|
||||
padding-left: 2rem;
|
||||
padding-right: 2rem;
|
||||
padding: 0rem 1rem 10rem;
|
||||
}
|
||||
|
||||
.stVerticalBlock {
|
||||
gap: 0.5rem !important;
|
||||
}
|
||||
|
||||
/* Lien normal (non visité) */
|
||||
a {
|
||||
color: #1b5e20; /* vert foncé */
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* Lien visité */
|
||||
a:visited {
|
||||
color: #388e3c; /* vert moyen */
|
||||
}
|
||||
|
||||
/* Lien au survol */
|
||||
a:hover {
|
||||
color: #145a1a; /* vert encore plus foncé */
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Lien actif */
|
||||
a:active {
|
||||
color: #2e7d32; /* action en cours - nuance */
|
||||
}
|
||||
|
||||
/* Couleur des boutons primaires et sliders */
|
||||
.stButton > button,
|
||||
.stSlider > div > div {
|
||||
background-color: darkgreen !important;
|
||||
color: white !important;
|
||||
border: 1px solid grey;
|
||||
}
|
||||
|
||||
/* Style pour impression */
|
||||
@media print {
|
||||
body {
|
||||
font-size: 12pt;
|
||||
color: black;
|
||||
background: white;
|
||||
}
|
||||
nav,
|
||||
footer,
|
||||
.stSidebar {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* En-tête large */
|
||||
.wide-header {
|
||||
width: 100vw;
|
||||
margin-left: calc(-50vw + 50%);
|
||||
background-color: #f9f9f9;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
border-bottom: 1px solid #ddd;
|
||||
text-align: center;
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.titre-header {
|
||||
font-size: 2rem !important;
|
||||
font-weight: bolder !important;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
/* Accessibilité RGAA pour les onglets */
|
||||
div[role="radiogroup"] > label {
|
||||
background-color: #eee;
|
||||
color: #333;
|
||||
padding: 0.5em 1em;
|
||||
border-radius: 0.4em;
|
||||
margin-right: 0.5em;
|
||||
cursor: pointer;
|
||||
border: 1px solid #ccc;
|
||||
}
|
||||
div[role="radiogroup"] > label[data-selected="true"] {
|
||||
background-color: #1b5e20 !important;
|
||||
color: white !important;
|
||||
font-weight: bold;
|
||||
border: 2px solid #145a1a;
|
||||
}
|
||||
|
||||
/* Style du graphique Plotly */
|
||||
.stPlotlyChart text {
|
||||
font-family: Verdana !important;
|
||||
fill: black !important;
|
||||
font-size: 14px !important;
|
||||
}
|
||||
|
||||
/* Pied de page */
|
||||
.wide-footer {
|
||||
width: 100vw;
|
||||
margin-left: calc(-50vw + 50%);
|
||||
margin-top: 2rem;
|
||||
background-color: #f9f9f9;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
border-bottom: 1px solid #ddd;
|
||||
text-align: center;
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.info-footer {
|
||||
font-size: 1rem !important;
|
||||
color: #555;
|
||||
font-weight: 800;
|
||||
}
|
||||
347
assets/styles/base.css
Normal file
347
assets/styles/base.css
Normal file
@ -0,0 +1,347 @@
|
||||
/* --- Base.css --- */
|
||||
|
||||
/* ==========================================
|
||||
1. Reset et base
|
||||
========================================== */
|
||||
.stAppHeader {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
body,
|
||||
html {
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial,
|
||||
sans-serif;
|
||||
}
|
||||
|
||||
body,
|
||||
.stApp,
|
||||
.block-container {
|
||||
background-color: var(--bg-color) !important;
|
||||
color: var(--text-color) !important;
|
||||
}
|
||||
|
||||
/* ==========================================
|
||||
2. Layout et containers
|
||||
========================================== */
|
||||
.block-container {
|
||||
max-width: 1024px !important;
|
||||
padding: 0 1rem 10rem;
|
||||
}
|
||||
|
||||
.stVerticalBlock {
|
||||
gap: 0.5rem !important;
|
||||
}
|
||||
|
||||
/* ==========================================
|
||||
3. Composants d'interface
|
||||
========================================== */
|
||||
|
||||
/* --- 3.1 Boutons --- */
|
||||
.stButton > button,
|
||||
.stDownloadButton > button,
|
||||
.stFormSubmitButton > button,
|
||||
.stSlider > div > div {
|
||||
background-color: darkgreen !important;
|
||||
color: white !important;
|
||||
border: 1px solid grey;
|
||||
}
|
||||
|
||||
.st-key-FormSubmitter-auth_form-Se-connecter {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
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 {
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.bouton-fictif {
|
||||
display: inline-flex;
|
||||
-moz-box-align: center;
|
||||
align-items: center;
|
||||
-moz-box-pack: center;
|
||||
justify-content: center;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
min-height: 2.5rem;
|
||||
margin-bottom: 20px;
|
||||
line-height: 1;
|
||||
text-transform: none;
|
||||
font-size: x-large;
|
||||
font-family: inherit;
|
||||
user-select: none;
|
||||
border: 1px solid rgba(49, 51, 63, 0.2);
|
||||
background-color: darkgrey !important;
|
||||
color: darkgreen !important;
|
||||
font-weight: bold !important;
|
||||
width: 100%;
|
||||
letter-spacing: 0.2em;
|
||||
}
|
||||
|
||||
button[data-testid="stBaseButton-headerNoPadding"] svg {
|
||||
fill: var(--text-color) !important;
|
||||
}
|
||||
|
||||
/* --- 3.2 Onglets et radiogroup --- */
|
||||
div[role="radiogroup"] > label {
|
||||
padding: 0.5em 1em;
|
||||
border-radius: 0.4em;
|
||||
margin-right: 0.5em;
|
||||
cursor: pointer;
|
||||
border: 1px solid #fff;
|
||||
}
|
||||
|
||||
div[role="radiogroup"] > label[data-selected="true"] {
|
||||
font-weight: bold;
|
||||
border: 2px solid #145a1a;
|
||||
}
|
||||
|
||||
section:not([data-testid="stSidebar"]) div[role="radiogroup"] > label p {
|
||||
background-color: var(--radio-bg) !important;
|
||||
color: var(--radio-text) !important;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/* --- 3.3 Champs de formulaire --- */
|
||||
div[data-baseweb="select"],
|
||||
section:not([data-testid="stSidebar"]) div[data-baseweb="base-input"],
|
||||
section[data-testid="stFileUploaderDropzone"] {
|
||||
border: 1px solid var(--input-border) !important;
|
||||
border-radius: 5px;
|
||||
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,
|
||||
section:not([data-testid="stSidebar"]) div[data-testid="stCheckbox"] p,
|
||||
section:not([data-testid="stSidebar"]) div[data-testid="stTextInput"] p,
|
||||
section:not([data-testid="stSidebar"]) div[data-testid="stTextArea"] p,
|
||||
section:not([data-testid="stSidebar"]) div[data-testid="stAlertContentInfo"] p {
|
||||
color: var(--text-color) !important;
|
||||
}
|
||||
|
||||
/* ==========================================
|
||||
4. Header et Footer
|
||||
========================================== */
|
||||
|
||||
/* --- 4.1 Header --- */
|
||||
.wide-header {
|
||||
width: 100vw;
|
||||
margin-left: calc(-50vw + 50%);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
border-bottom: 1px solid #ddd;
|
||||
text-align: center;
|
||||
padding-top: 1rem;
|
||||
margin-top: -1.25em;
|
||||
background-color: var(--header-bg);
|
||||
}
|
||||
|
||||
.titre-header {
|
||||
font-size: 2rem !important;
|
||||
font-weight: bolder !important;
|
||||
color: var(--header-title);
|
||||
}
|
||||
|
||||
/* --- 4.2 Footer --- */
|
||||
.wide-footer {
|
||||
width: 100vw;
|
||||
margin-left: calc(-50vw + 50%);
|
||||
margin-top: 3rem;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
border-top: 1px solid #ddd;
|
||||
text-align: center;
|
||||
padding-top: 1rem;
|
||||
background-color: var(--footer-bg);
|
||||
}
|
||||
|
||||
.info-footer {
|
||||
font-size: 1rem !important;
|
||||
font-weight: 800;
|
||||
color: var(--footer-text);
|
||||
}
|
||||
|
||||
/* ==========================================
|
||||
5. Sidebar
|
||||
========================================== */
|
||||
section[data-testid="stSidebar"] {
|
||||
background-color: #ccc !important;
|
||||
color: #111 !important;
|
||||
}
|
||||
|
||||
section[data-testid="stSidebar"] .stButton > button {
|
||||
background-color: darkgreen !important;
|
||||
color: white !important;
|
||||
font-weight: bold !important;
|
||||
border: 1px solid #ccc !important;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
section[data-testid="stSidebar"] .decorative-heading {
|
||||
font-size: 1.25rem;
|
||||
font-weight: bold;
|
||||
margin-bottom: 0.5rem;
|
||||
text-align: center;
|
||||
color: #145a1a;
|
||||
}
|
||||
|
||||
section[data-testid="stSidebar"] div[role="radiogroup"] {
|
||||
justify-content: center !important;
|
||||
display: flex !important;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
/* ==========================================
|
||||
6. Tables
|
||||
========================================== */
|
||||
table {
|
||||
border: 1px solid var(--table-border) !important;
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin-bottom: 1.5em;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
border: 1px solid var(--table-border) !important;
|
||||
padding: 8px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
caption {
|
||||
caption-side: top;
|
||||
font-weight: bold;
|
||||
padding: 0.5em;
|
||||
text-align: left;
|
||||
caption-side: bottom;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
table[role="table"] th[scope="col"] {
|
||||
background-color: var(--background-color);
|
||||
}
|
||||
|
||||
/* ==========================================
|
||||
7. Composants spécifiques
|
||||
========================================== */
|
||||
|
||||
/* --- 7.1 File Uploader --- */
|
||||
/* File uploader styles intentionally left empty */
|
||||
|
||||
/* --- 7.2 Graphiques --- */
|
||||
.stPlotlyChart text {
|
||||
fill: var(--plot-text) !important;
|
||||
}
|
||||
|
||||
.stPlotlyChart text {
|
||||
fill: black !important;
|
||||
text-shadow: none !important;
|
||||
font-weight: bold !important;
|
||||
font-size: 14px !important;
|
||||
font-family: Verdana, sans-serif !important;
|
||||
}
|
||||
|
||||
/* Cache complètement la section d'actions Vega */
|
||||
.vega-actions {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Et aussi le <details> parent, s'il faut tout masquer */
|
||||
details[title="Click to view actions"] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* --- 7.3 Détails et paragraphes --- */
|
||||
details {
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 6px;
|
||||
padding: 0.5em;
|
||||
margin-bottom: 0.5em;
|
||||
background-color: var(--background-color);
|
||||
border-color: var(--details-border) !important;
|
||||
}
|
||||
|
||||
section:not([data-testid="stSidebar"])
|
||||
div:not[data-testid="stElementContainer"]
|
||||
p:not(#Authentification):not(#Theme) {
|
||||
color: var(--paragraph-color) !important;
|
||||
}
|
||||
|
||||
section:not([data-testid="stSidebar"]) hr {
|
||||
background-color: var(--hr-color) !important;
|
||||
}
|
||||
|
||||
/* --- 7.4 Conteneurs de commentaires et tickets --- */
|
||||
.conteneur_commentaire,
|
||||
.conteneur_ticket {
|
||||
background: var(--background-color);
|
||||
padding: 1em;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1em;
|
||||
border: 1px solid #ccc;
|
||||
}
|
||||
|
||||
.commentaire_auteur,
|
||||
.ticket_auteur {
|
||||
color: var(--text-color) !important;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.commentaire_contenu,
|
||||
.ticket_contenu {
|
||||
color: var(--text-color) !important;
|
||||
margin: 0.5rem 0 0;
|
||||
}
|
||||
|
||||
/* --- 7.5 Blocs mathématiques --- */
|
||||
.math-block {
|
||||
display: block;
|
||||
text-align: center;
|
||||
margin: 1em 0;
|
||||
border: 1px solid var(--math-block-border);
|
||||
border-radius: 10px;
|
||||
background: var(--math-block-bg);
|
||||
font-size: x-large;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.math-block math {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* ==========================================
|
||||
8. Éléments spécifiques
|
||||
========================================== */
|
||||
div.stElementContainer.element-container.st-key-nom_utilisateur {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.st-key-telecharger_fiche_pdf {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
30
assets/styles/theme-dark.css
Normal file
30
assets/styles/theme-dark.css
Normal file
@ -0,0 +1,30 @@
|
||||
:root {
|
||||
--bg-color: #222;
|
||||
--text-color: #eee;
|
||||
|
||||
--header-bg: #060606;
|
||||
--header-title: #bbb;
|
||||
|
||||
--footer-bg: #060606;
|
||||
--footer-text: #ddd;
|
||||
|
||||
--radio-bg: #222;
|
||||
--radio-text: #ddd;
|
||||
--radio-selected-bg: #e5b2ef;
|
||||
--radio-selected-text: black;
|
||||
|
||||
--plot-text: white;
|
||||
|
||||
--paragraph-color: white;
|
||||
|
||||
--input-border: #222;
|
||||
|
||||
--details-border: lightgray;
|
||||
|
||||
--table-border: #ccc;
|
||||
|
||||
--hr-color: #eee;
|
||||
|
||||
--math-block-bg: #222;
|
||||
--math-block-border: #ccc;
|
||||
}
|
||||
30
assets/styles/theme-light.css
Normal file
30
assets/styles/theme-light.css
Normal file
@ -0,0 +1,30 @@
|
||||
:root {
|
||||
--bg-color: #eee;
|
||||
--text-color: #222;
|
||||
|
||||
--header-bg: #f9f9f9;
|
||||
--header-title: #555;
|
||||
|
||||
--footer-bg: #f9f9f9;
|
||||
--footer-text: #333;
|
||||
|
||||
--radio-bg: #eee;
|
||||
--radio-text: #333;
|
||||
--radio-selected-bg: #1b5e20;
|
||||
--radio-selected-text: white;
|
||||
|
||||
--plot-text: black;
|
||||
|
||||
--paragraph-color: black;
|
||||
|
||||
--input-border: #aaa;
|
||||
|
||||
--details-border: darkgray;
|
||||
|
||||
--table-border: #333;
|
||||
|
||||
--hr-color: #222;
|
||||
|
||||
--math-block-bg: #ddd;
|
||||
--math-block-border: #ccc;
|
||||
}
|
||||
48
components/README.md
Normal file
48
components/README.md
Normal file
@ -0,0 +1,48 @@
|
||||
# Module Components
|
||||
|
||||
Ce module contient les composants d'interface utilisateur réutilisables pour l'application FabNum. Ces composants permettent de maintenir une apparence cohérente et de simplifier le développement en encapsulant des éléments d'interface communs.
|
||||
|
||||
## Structure du module
|
||||
|
||||
Le module components comprend plusieurs fichiers clés :
|
||||
|
||||
- **sidebar.py** : Gestion de la barre latérale de navigation et des fonctionnalités associées
|
||||
- **header.py** : Composant d'en-tête unifié pour toutes les pages
|
||||
- **footer.py** : Pied de page standardisé incluant les mentions légales et informations de contact
|
||||
- **fiches.py** : Composants spécifiques à l'affichage et à la manipulation des fiches
|
||||
|
||||
## Fonctionnalités
|
||||
|
||||
### Barre latérale (sidebar.py)
|
||||
- Menu de navigation principal entre les différentes sections
|
||||
- Options de configuration et de personnalisation
|
||||
- Affichage des informations sur l'impact environnemental
|
||||
- Gestion du thème (clair/sombre)
|
||||
|
||||
### En-tête (header.py)
|
||||
- Logo et identification visuelle de l'application
|
||||
- Titre et sous-titre de l'application
|
||||
- Messages système et notifications
|
||||
|
||||
### Pied de page (footer.py)
|
||||
- Informations légales et mentions de copyright
|
||||
- Liens vers les ressources externes
|
||||
- Informations de contact et de support
|
||||
|
||||
## Utilisation
|
||||
|
||||
Ces composants sont conçus pour être facilement intégrés dans les différentes pages de l'application. Exemple d'utilisation :
|
||||
|
||||
```python
|
||||
from components.sidebar import afficher_menu
|
||||
from components.header import afficher_entete
|
||||
from components.footer import afficher_pied_de_page
|
||||
|
||||
# Dans votre application principale
|
||||
afficher_entete()
|
||||
afficher_menu()
|
||||
# Contenu principal de la page
|
||||
afficher_pied_de_page()
|
||||
```
|
||||
|
||||
Cette approche modulaire permet de maintenir une interface cohérente tout en facilitant les mises à jour de l'interface utilisateur.
|
||||
113
components/connexion.py
Normal file
113
components/connexion.py
Normal file
@ -0,0 +1,113 @@
|
||||
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"
|
||||
if not os.path.exists(os.path.dirname(LOG_FILE_PATH)):
|
||||
os.makedirs(os.path.dirname(LOG_FILE_PATH), exist_ok=True)
|
||||
|
||||
logger = logging.getLogger("auth_logger")
|
||||
logger.setLevel(logging.INFO)
|
||||
if not logger.hasHandlers():
|
||||
fh = logging.FileHandler(LOG_FILE_PATH)
|
||||
formatter = logging.Formatter('%(asctime)s %(levelname)s %(message)s')
|
||||
fh.setFormatter(formatter)
|
||||
logger.addHandler(fh)
|
||||
return logger
|
||||
|
||||
def connexion():
|
||||
if "logged_in" not in st.session_state or not st.session_state.logged_in:
|
||||
auth_title = str(_("auth.title"))
|
||||
st.html(f"""
|
||||
<section role="region" aria-label="region-authentification">
|
||||
<div role="region" aria-labelledby="Authentification">
|
||||
<p id="Authentification" class="decorative-heading">{auth_title}</p>
|
||||
""")
|
||||
|
||||
GITEA_URL = "https://fabnum-git.peccini.fr/api/v1"
|
||||
ORGANISATION = "FabNum"
|
||||
EQUIPE_CIBLE = "Administrateurs"
|
||||
|
||||
logger = initialiser_logger()
|
||||
|
||||
if "logged_in" not in st.session_state:
|
||||
st.session_state.logged_in = False
|
||||
st.session_state.username = ""
|
||||
st.session_state.token = ""
|
||||
|
||||
if not st.session_state.logged_in:
|
||||
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(str(_("auth.username")), value="fabnum-connexion", key="nom_utilisateur")
|
||||
token = st.text_input(str(_("auth.token")), type="password")
|
||||
submitted = st.form_submit_button(str(_("auth.login")))
|
||||
|
||||
if submitted and token:
|
||||
erreur = True
|
||||
headers = {"Authorization": f"token {token}"}
|
||||
ip = os.environ.get("REMOTE_ADDR", "inconnu")
|
||||
username = "inconnu"
|
||||
|
||||
try:
|
||||
user_response = requests.get(f"{GITEA_URL}/user", headers=headers, timeout=5)
|
||||
user_response.raise_for_status()
|
||||
utilisateur = user_response.json()
|
||||
username = utilisateur.get("login", "inconnu")
|
||||
logger.info(f"Tentative par {username} depuis IP {ip}")
|
||||
|
||||
teams_url = f"{GITEA_URL}/orgs/{ORGANISATION}/teams"
|
||||
teams_response = requests.get(teams_url, headers=headers, timeout=5)
|
||||
teams_response.raise_for_status()
|
||||
equipes = teams_response.json()
|
||||
equipe_admin = next((e for e in equipes if e["name"] == EQUIPE_CIBLE), None)
|
||||
|
||||
if equipe_admin:
|
||||
team_id = equipe_admin["id"]
|
||||
check_url = f"{GITEA_URL}/teams/{team_id}/members/{username}"
|
||||
check_response = requests.get(check_url, headers=headers, timeout=5)
|
||||
if check_response.status_code == 200:
|
||||
st.session_state.logged_in = True
|
||||
st.session_state.username = username
|
||||
st.session_state.token = token
|
||||
erreur = False
|
||||
logger.info(f"Connexion réussie pour {username} depuis IP {ip}")
|
||||
st.rerun()
|
||||
|
||||
except requests.RequestException:
|
||||
st.error(str(_("auth.gitea_error")))
|
||||
|
||||
if erreur:
|
||||
logger.warning(f"Accès refusé pour tentative avec token depuis IP {ip}")
|
||||
st.error(str(_("auth.error")))
|
||||
|
||||
st.html("""
|
||||
</div>
|
||||
</section>
|
||||
""")
|
||||
|
||||
|
||||
def bouton_deconnexion():
|
||||
if st.session_state.get("logged_in", False):
|
||||
auth_title = str(_("auth.title"))
|
||||
st.html(f"""
|
||||
<section role="region" aria-label="region-authentification">
|
||||
<div role="region" aria-labelledby="Authentification">
|
||||
<p id="Authentification" class="decorative-heading">{auth_title}</p>
|
||||
""")
|
||||
|
||||
st.sidebar.markdown(f"{str(_('auth.logged_as'))} `{st.session_state.username}`")
|
||||
if st.sidebar.button(str(_("auth.logout"))):
|
||||
st.session_state.logged_in = False
|
||||
st.session_state.username = ""
|
||||
st.session_state.token = ""
|
||||
st.success(str(_("auth.success")))
|
||||
st.rerun()
|
||||
|
||||
st.html("""
|
||||
</div>
|
||||
</section>
|
||||
""")
|
||||
22
components/footer.py
Normal file
22
components/footer.py
Normal file
@ -0,0 +1,22 @@
|
||||
import streamlit as st
|
||||
from utils.translations import _
|
||||
|
||||
|
||||
def afficher_pied_de_page():
|
||||
st.markdown("""
|
||||
<section role="region" aria-label="Contenu principal" id="main-content">
|
||||
""", unsafe_allow_html=True)
|
||||
|
||||
st.markdown(f"""
|
||||
<div role='contentinfo' aria-labelledby='footer-appli' class='wide-footer'>
|
||||
<div class='info-footer'>
|
||||
<p id='footer-appli' class='info-footer'>
|
||||
{_("footer.copyright")} – <a href='mailto:stephan-pro@peccini.fr'>{_("footer.contact")}</a> – {_("footer.license")} <a href='https://creativecommons.org/licenses/by-nc-nd/4.0/deed.fr' target='_blank'>{_("footer.license_text")}</a>
|
||||
</p>
|
||||
<p class='footer-note'>
|
||||
{_("footer.eco_note")} <a href='https://www.thegreenwebfoundation.org/' target='_blank'>{_("footer.eco_provider")}</a><br>
|
||||
{_("footer.powered_by")} <a href='https://streamlit.io/' target='_blank'>{_("footer.powered_by_name")}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
""", unsafe_allow_html=True)
|
||||
23
components/header.py
Normal file
23
components/header.py
Normal file
@ -0,0 +1,23 @@
|
||||
import streamlit as st
|
||||
from config import ENV
|
||||
from utils.translations import _
|
||||
|
||||
|
||||
def afficher_entete():
|
||||
header = f"""
|
||||
<header role="banner" aria-labelledby="entete-header">
|
||||
<div class='wide-header'>
|
||||
<p id='entete-header' class='titre-header'>{_("header.title")}</p>
|
||||
"""
|
||||
|
||||
if ENV == "dev":
|
||||
header += f"<p>🔧 {_("app.dev_mode")}</p>"
|
||||
else:
|
||||
header += f"<p>{_("header.subtitle")}</p>"
|
||||
|
||||
header += """
|
||||
</div>
|
||||
</header>
|
||||
"""
|
||||
|
||||
st.markdown(header, unsafe_allow_html=True)
|
||||
164
components/sidebar.py
Normal file
164
components/sidebar.py
Normal file
@ -0,0 +1,164 @@
|
||||
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(f"""
|
||||
<nav role="navigation" aria-label="{str(_('sidebar.menu'))}">
|
||||
<div role="region" aria-label="{str(_('sidebar.navigation'))}" 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"))
|
||||
if "onglet" not in st.session_state:
|
||||
st.session_state.onglet = instructions_text
|
||||
|
||||
onglet_choisi = None
|
||||
onglets = [
|
||||
str(_("navigation.instructions")),
|
||||
str(_("navigation.personnalisation")),
|
||||
str(_("navigation.analyse")),
|
||||
str(_("navigation.visualisations")),
|
||||
str(_("navigation.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(str(nom)):
|
||||
onglet_choisi = nom
|
||||
|
||||
st.markdown("""
|
||||
<hr />
|
||||
</div>
|
||||
</nav>""", unsafe_allow_html=True)
|
||||
|
||||
# === GESTION DU THÈME ===
|
||||
#
|
||||
# Le changement de thème induit un st.rerun qui vide les formula
|
||||
# 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_text:
|
||||
if "theme_mode" not in st.session_state:
|
||||
st.session_state.theme_mode = str(_("sidebar.theme_light"))
|
||||
|
||||
theme_title = str(_("sidebar.theme"))
|
||||
st.markdown(f"""
|
||||
<section role="region" aria-label="region-theme">
|
||||
<div role="region" aria-labelledby="Theme">
|
||||
<p id="Theme" class="decorative-heading">{theme_title}</p>
|
||||
""", unsafe_allow_html=True)
|
||||
|
||||
theme_options = [
|
||||
str(_("sidebar.theme_light")),
|
||||
str(_("sidebar.theme_dark"))
|
||||
]
|
||||
theme = st.radio(
|
||||
str(_("sidebar.theme")),
|
||||
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 :
|
||||
theme_title = str(_("sidebar.theme"))
|
||||
st.markdown(f"""
|
||||
<section role="region" aria-label="region-theme">
|
||||
<div role="region" aria-labelledby="Theme">
|
||||
<p id="Theme" class="decorative-heading">{theme_title}</p>
|
||||
""", unsafe_allow_html=True)
|
||||
|
||||
st.info(str(_("sidebar.theme_instructions_only")))
|
||||
|
||||
st.markdown("""
|
||||
<hr />
|
||||
</div>
|
||||
</nav>""", unsafe_allow_html=True)
|
||||
|
||||
theme = st.session_state.theme_mode
|
||||
|
||||
connexion()
|
||||
|
||||
if st.session_state.get("logged_in", False):
|
||||
bouton_deconnexion()
|
||||
|
||||
# === RERUN SI BESOIN ===
|
||||
if (onglet_choisi and onglet_choisi != st.session_state.onglet) or (theme != st.session_state.theme_mode):
|
||||
if onglet_choisi: # Ne met à jour que si on a cliqué
|
||||
st.session_state.onglet = onglet_choisi
|
||||
st.session_state.theme_mode = theme
|
||||
st.rerun()
|
||||
|
||||
|
||||
def afficher_impact(total_bytes):
|
||||
impact_label = str(_("sidebar.impact"))
|
||||
loading_text = str(_("sidebar.loading"))
|
||||
|
||||
with st.sidebar:
|
||||
components.html(f"""
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Impact Environnemental</title>
|
||||
<style>
|
||||
body,
|
||||
html {{
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial,
|
||||
sans-serif;
|
||||
}}
|
||||
/* Div réseau pour impact CO₂ */
|
||||
#network-usage {{
|
||||
display: block;
|
||||
font-size: small;
|
||||
margin-top: 1rem;
|
||||
text-align: center;
|
||||
}}
|
||||
.decorative-heading {{
|
||||
font-size: 1.25rem;
|
||||
font-weight: bold;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #145a1a;
|
||||
text-align: center;
|
||||
}}
|
||||
span {{
|
||||
text-align: center;
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<hr />
|
||||
<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>
|
||||
document.addEventListener("DOMContentLoaded", async function() {{
|
||||
try {{
|
||||
const module = await import("/assets/impact_co2.js");
|
||||
module.calculerImpactCO2({total_bytes});
|
||||
}} catch (error) {{
|
||||
console.error("Erreur module impact_co2.js", error);
|
||||
}}
|
||||
}});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
""")
|
||||
st.markdown("""
|
||||
<div style="text-align: center;">
|
||||
<img src="https://app.greenweb.org/api/v3/greencheckimage/fabnum-dev.peccini.fr?nocache=true" alt="This website runs on green hosting - verified by thegreenwebfoundation.org" width="200px" height="95px">
|
||||
</div>
|
||||
""", unsafe_allow_html=True)
|
||||
32
config.py
Normal file
32
config.py
Normal file
@ -0,0 +1,32 @@
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv(".env")
|
||||
load_dotenv(".env.local", override=True)
|
||||
|
||||
GITEA_URL = os.getenv("GITEA_URL", "https://fabnum-git.peccini.fr/api/v1")
|
||||
GITEA_TOKEN = os.getenv("GITEA_TOKEN", "")
|
||||
ORGANISATION = os.getenv("ORGANISATION", "fabnum")
|
||||
DEPOT_FICHES = os.getenv("DEPOT_FICHES", "fiches")
|
||||
DEPOT_CODE = os.getenv("DEPOT_CODE", "code")
|
||||
ENV = os.getenv("ENV")
|
||||
ENV_CODE = os.getenv("ENV_CODE")
|
||||
DOT_FILE = os.getenv("DOT_FILE")
|
||||
INSTRUCTIONS = os.getenv("INSTRUCTIONS", "Instructions.md")
|
||||
|
||||
FICHE_IHH = os.getenv("FICHE_IHH")
|
||||
FICHE_ICS = os.getenv("FICHE_ICS")
|
||||
FICHE_IVC = os.getenv("FICHE_IVC")
|
||||
FICHE_ISG = os.getenv("FICHE_ISG")
|
||||
|
||||
# Optionnel : vérification + fallback
|
||||
for key, value in [("FICHE_IHH", FICHE_IHH), ("FICHE_ICS", FICHE_ICS), ("FICHE_IVC", FICHE_IVC), ("FICHE_ISG", FICHE_ISG)]:
|
||||
if not value:
|
||||
raise EnvironmentError(f"Variable d'environnement '{key}' non définie.")
|
||||
|
||||
FICHES_CRITICITE = {
|
||||
"IHH": FICHE_IHH,
|
||||
"IVC": FICHE_IVC,
|
||||
"ICS": FICHE_ICS,
|
||||
"ISG": FICHE_ISG
|
||||
}
|
||||
@ -1,7 +1,17 @@
|
||||
streamlit
|
||||
networkx
|
||||
pygraphviz
|
||||
pandas
|
||||
plotly
|
||||
requests
|
||||
kaleido>=0.2.1
|
||||
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
|
||||
|
||||
16022
schema.txt
Normal file
16022
schema.txt
Normal file
File diff suppressed because one or more lines are too long
371
tickets_fiche.py
371
tickets_fiche.py
@ -1,371 +0,0 @@
|
||||
import streamlit as st
|
||||
from datetime import datetime
|
||||
from dateutil import parser
|
||||
from collections import defaultdict
|
||||
import os
|
||||
import csv
|
||||
import requests
|
||||
import base64
|
||||
import re
|
||||
import json
|
||||
import html
|
||||
|
||||
# Configuration Gitea
|
||||
GITEA_URL = os.getenv("GITEA_URL", "https://fabnum-git.peccini.fr/api/v1")
|
||||
GITEA_TOKEN = os.getenv("GITEA_TOKEN", "")
|
||||
ORGANISATION = os.getenv("ORGANISATION", "fabnum")
|
||||
DEPOT_FICHES = os.getenv("DEPOT_FICHES", "fiches")
|
||||
ENV = os.getenv("ENV")
|
||||
|
||||
def charger_fiches_et_labels():
|
||||
chemin_csv = os.path.join("assets", "fiches_labels.csv")
|
||||
dictionnaire_fiches = {}
|
||||
|
||||
try:
|
||||
with open(chemin_csv, mode="r", encoding="utf-8") as fichier_csv:
|
||||
lecteur = csv.DictReader(fichier_csv)
|
||||
for ligne in lecteur:
|
||||
fiche = ligne.get("Fiche")
|
||||
operations = ligne.get("Label opération")
|
||||
item = ligne.get("Label item")
|
||||
|
||||
if fiche and operations and item:
|
||||
dictionnaire_fiches[fiche.strip()] = {
|
||||
"operations": [op.strip() for op in operations.split("/")],
|
||||
"item": item.strip()
|
||||
}
|
||||
except FileNotFoundError:
|
||||
st.error(f"❌ Le fichier {chemin_csv} est introuvable.")
|
||||
except Exception as e:
|
||||
st.error(f"❌ Erreur lors du chargement des fiches : {str(e)}")
|
||||
|
||||
return dictionnaire_fiches
|
||||
|
||||
def rechercher_tickets_gitea(fiche_selectionnee):
|
||||
headers = {"Authorization": f"token " + GITEA_TOKEN}
|
||||
params = {"state": "open"}
|
||||
url = f"{GITEA_URL}/repos/{ORGANISATION}/{DEPOT_FICHES}/issues"
|
||||
|
||||
try:
|
||||
reponse = requests.get(url, headers=headers, params=params, timeout=10)
|
||||
reponse.raise_for_status()
|
||||
issues = reponse.json()
|
||||
|
||||
correspondances = charger_fiches_et_labels()
|
||||
cible = correspondances.get(fiche_selectionnee)
|
||||
|
||||
if not cible:
|
||||
return []
|
||||
|
||||
labels_cibles = set(cible["operations"] + [cible["item"]])
|
||||
|
||||
tickets_associes = []
|
||||
for issue in issues:
|
||||
if issue.get("ref") != f"refs/heads/{ENV}":
|
||||
continue
|
||||
issue_labels = set()
|
||||
for label in issue.get("labels", []):
|
||||
if isinstance(label, dict) and "name" in label:
|
||||
issue_labels.add(label["name"])
|
||||
|
||||
if labels_cibles.issubset(issue_labels):
|
||||
tickets_associes.append(issue)
|
||||
|
||||
return tickets_associes
|
||||
|
||||
except requests.RequestException as e:
|
||||
st.error(f"Erreur lors de la récupération des tickets : {e}")
|
||||
return []
|
||||
|
||||
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"]:
|
||||
if statut in labels:
|
||||
return statut
|
||||
return "Autres"
|
||||
|
||||
def afficher_tickets_par_fiche(tickets):
|
||||
if not tickets:
|
||||
st.info("Aucun ticket lié à cette fiche.")
|
||||
return
|
||||
|
||||
st.markdown("📝 **Tickets associés à cette fiche**")
|
||||
|
||||
tickets_groupes = defaultdict(list)
|
||||
for ticket in tickets:
|
||||
statut = extraire_statut_par_label(ticket)
|
||||
tickets_groupes[statut].append(ticket)
|
||||
|
||||
st.info(f" ⤇ {len(tickets_groupes["Backlog"])} 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"]
|
||||
|
||||
for statut in ordre_statuts:
|
||||
if tickets_groupes[statut]:
|
||||
with st.expander(f"{statut} ({len(tickets_groupes[statut])})", expanded=(statut == "En cours")):
|
||||
for ticket in tickets_groupes[statut]:
|
||||
afficher_carte_ticket(ticket)
|
||||
|
||||
def recuperer_commentaires_ticket(issue_index):
|
||||
headers = {
|
||||
"Authorization": f"token {GITEA_TOKEN}"
|
||||
}
|
||||
url = f"{GITEA_URL}/repos/{ORGANISATION}/{DEPOT_FICHES}/issues/{issue_index}/comments"
|
||||
try:
|
||||
response = requests.get(url, headers=headers, timeout=10)
|
||||
response.raise_for_status()
|
||||
commentaires = response.json()
|
||||
return commentaires
|
||||
except Exception as e:
|
||||
st.error(f"Erreur lors de la récupération des commentaires pour le ticket {issue_index} : {e}")
|
||||
return []
|
||||
|
||||
def afficher_carte_ticket(ticket):
|
||||
titre = ticket.get("title", "Sans titre")
|
||||
url = ticket.get("html_url", "")
|
||||
user = ticket.get("user", {}).get("login", "inconnu")
|
||||
created = ticket.get("created_at", "")
|
||||
updated = ticket.get("updated_at", "")
|
||||
body = ticket.get("body", "")
|
||||
labels = [l["name"] for l in ticket.get("labels", []) if "name" in l]
|
||||
|
||||
sujet = ""
|
||||
match = re.search(r"## Sujet de la proposition\s+(.+?)(\n|$)", body, re.DOTALL)
|
||||
if match:
|
||||
sujet = match.group(1).strip()
|
||||
|
||||
if created:
|
||||
try:
|
||||
dt_created = parser.isoparse(created)
|
||||
date_created_str = dt_created.strftime("%d/%m/%Y")
|
||||
except Exception:
|
||||
date_created_str = "Date inconnue"
|
||||
else:
|
||||
date_created_str = "Date inconnue"
|
||||
|
||||
if updated and updated != created:
|
||||
try:
|
||||
dt_updated = parser.isoparse(updated)
|
||||
date_updated_str = dt_updated.strftime("%d/%m/%Y")
|
||||
maj_info = f"(MAJ {date_updated_str})"
|
||||
except Exception:
|
||||
maj_info = ""
|
||||
else:
|
||||
maj_info = ""
|
||||
|
||||
commentaires = recuperer_commentaires_ticket(ticket.get("number"))
|
||||
|
||||
commentaires_html = ''
|
||||
if commentaires:
|
||||
for commentaire in commentaires:
|
||||
auteur = html.escape(commentaire.get('user', {}).get('login', 'inconnu'))
|
||||
contenu = html.escape(commentaire.get('body', ''))
|
||||
date_commentaire = commentaire.get('created_at', '')
|
||||
if date_commentaire:
|
||||
try:
|
||||
dt_comment = parser.isoparse(date_commentaire)
|
||||
date_commentaire_str = dt_comment.strftime("%d/%m/%Y")
|
||||
except Exception:
|
||||
date_commentaire_str = ""
|
||||
else:
|
||||
date_commentaire_str = ""
|
||||
|
||||
commentaires_html += f"""
|
||||
<div style='background-color: #f0f0f0; padding: 0.5rem; border-radius: 8px; margin-bottom: 0.5rem;'>
|
||||
<p style='margin: 0;'><strong>{auteur}</strong> <small>({date_commentaire_str})</small></p>
|
||||
<p style='margin: 0.5rem 0 0 0;'>{contenu}</p>
|
||||
</div>
|
||||
"""
|
||||
else:
|
||||
commentaires_html = '<p style="margin-top: 1rem;">Aucun commentaire.</p>'
|
||||
|
||||
with st.container():
|
||||
st.markdown(f"""
|
||||
<div style='border: 1px solid #ccc; border-radius: 12px; padding: 1rem; margin-bottom: 2rem; box-shadow: 0 1px 3px rgba(0,0,0,0.1);'>
|
||||
<h4 style='margin-bottom: 0.5rem;'>🎫 <a href='{url}' target='_blank'>{titre}</a></h4>
|
||||
<p style='margin: 0.2rem 0;'>Ouvert par <strong>{html.escape(user)}</strong> le {date_created_str} {maj_info}</p>
|
||||
<p style='margin: 0.2rem 0;'>Sujet de la proposition : <strong>{html.escape(sujet)}</strong></p>
|
||||
<p style='margin: 0.2rem 0;'><span>{' • '.join(html.escape(label) for label in labels) if labels else 'aucun'}</span></p>
|
||||
<hr style='margin: 1.5rem 0;'>
|
||||
</div>
|
||||
""", unsafe_allow_html=True)
|
||||
|
||||
st.markdown("**Contenu du ticket :**")
|
||||
st.markdown(body, unsafe_allow_html=False)
|
||||
|
||||
st.markdown("---")
|
||||
st.markdown("**Commentaires :**")
|
||||
st.markdown(commentaires_html, unsafe_allow_html=True)
|
||||
|
||||
def get_labels_existants():
|
||||
headers = {"Authorization": f"token {GITEA_TOKEN}"}
|
||||
url = f"{GITEA_URL}/repos/{ORGANISATION}/{DEPOT_FICHES}/labels"
|
||||
try:
|
||||
response = requests.get(url, headers=headers, timeout=10)
|
||||
response.raise_for_status()
|
||||
labels_data = response.json()
|
||||
return {label['name']: label['id'] for label in labels_data}
|
||||
except Exception as e:
|
||||
st.error(f"Erreur lors de la récupération des labels existants : {e}")
|
||||
return {}
|
||||
|
||||
|
||||
def creer_ticket_gitea(titre, corps, labels):
|
||||
headers = {
|
||||
"Authorization": f"token {GITEA_TOKEN}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
url = f"{GITEA_URL}/repos/{ORGANISATION}/{DEPOT_FICHES}/issues"
|
||||
|
||||
data = {
|
||||
"title": titre,
|
||||
"body": corps,
|
||||
"labels": labels,
|
||||
"ref": f"refs/heads/{ENV}"
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(url, headers=headers, data=json.dumps(data), timeout=10)
|
||||
response.raise_for_status()
|
||||
issue = response.json()
|
||||
issue_url = issue.get("html_url", "")
|
||||
if issue_url:
|
||||
st.success(f"✅ Ticket créé avec succès ! [Voir le ticket]({issue_url})")
|
||||
else:
|
||||
st.success("✅ Ticket créé avec succès !")
|
||||
except Exception as e:
|
||||
st.error(f"❌ Erreur lors de la création du ticket : {e}")
|
||||
|
||||
def charger_modele_ticket():
|
||||
headers = {"Authorization": f"token {GITEA_TOKEN}"}
|
||||
url = f"{GITEA_URL}/repos/{ORGANISATION}/{DEPOT_FICHES}/contents/.gitea/ISSUE_TEMPLATE/Contenu.md"
|
||||
|
||||
try:
|
||||
response = requests.get(url, headers=headers, timeout=10)
|
||||
response.raise_for_status()
|
||||
contenu_base64 = response.json().get("content", "")
|
||||
contenu = base64.b64decode(contenu_base64).decode("utf-8")
|
||||
return contenu
|
||||
except Exception as e:
|
||||
st.error(f"Erreur lors du chargement du modèle de ticket : {e}")
|
||||
return ""
|
||||
|
||||
def gerer_tickets_fiche(fiche_selectionnee):
|
||||
st.markdown("""
|
||||
<hr style='border: 1px solid #ccc; margin: 2rem 0;' />
|
||||
""", unsafe_allow_html=True)
|
||||
st.markdown("### 🧾 Gestion des tickets pour cette fiche")
|
||||
|
||||
tickets = rechercher_tickets_gitea(fiche_selectionnee)
|
||||
afficher_tickets_par_fiche(tickets)
|
||||
formulaire_creation_ticket_dynamique(fiche_selectionnee)
|
||||
|
||||
# Modification de formulaire_creation_ticket_dynamique pour ajouter Annuler
|
||||
def formulaire_creation_ticket_dynamique(fiche_selectionnee):
|
||||
with st.expander("➕ Créer un nouveau ticket lié à cette fiche", expanded=False):
|
||||
contenu_modele = charger_modele_ticket()
|
||||
|
||||
if not contenu_modele:
|
||||
st.error("Impossible de charger le modèle de ticket.")
|
||||
return
|
||||
|
||||
sections = {}
|
||||
lignes = contenu_modele.splitlines()
|
||||
titre_courant = None
|
||||
contenu_section = []
|
||||
|
||||
for ligne in lignes:
|
||||
if ligne.startswith("## ") and titre_courant:
|
||||
sections[titre_courant] = "\n".join(contenu_section).strip()
|
||||
titre_courant = ligne[3:].strip()
|
||||
contenu_section = []
|
||||
elif ligne.startswith("## "):
|
||||
titre_courant = ligne[3:].strip()
|
||||
contenu_section = []
|
||||
elif titre_courant:
|
||||
contenu_section.append(ligne)
|
||||
|
||||
if titre_courant and contenu_section:
|
||||
sections[titre_courant] = "\n".join(contenu_section).strip()
|
||||
|
||||
reponses = {}
|
||||
labels = []
|
||||
selected_operations = []
|
||||
|
||||
correspondances = charger_fiches_et_labels()
|
||||
cible = correspondances.get(fiche_selectionnee)
|
||||
if cible:
|
||||
if len(cible["operations"]) == 1:
|
||||
labels.append(cible["operations"][0])
|
||||
elif len(cible["operations"]) > 1:
|
||||
selected_operations = st.multiselect("Labels opération à associer", cible["operations"], default=cible["operations"])
|
||||
|
||||
for section, aide in sections.items():
|
||||
if "Type de contribution" in section:
|
||||
options = re.findall(r"- \[.\] (.+)", aide)
|
||||
clean_options = []
|
||||
for opt in options:
|
||||
base = opt.split(":")[0].strip()
|
||||
if base not in clean_options:
|
||||
clean_options.append(base)
|
||||
if "Autre" not in clean_options:
|
||||
clean_options.append("Autre")
|
||||
|
||||
type_contribution = st.radio("Type de contribution", clean_options)
|
||||
if type_contribution == "Autre":
|
||||
autre = st.text_input("Précisez le type de contribution")
|
||||
reponses[section] = autre
|
||||
else:
|
||||
reponses[section] = type_contribution
|
||||
|
||||
elif "Fiche concernée" in section:
|
||||
base_url = f"https://fabnum-git.peccini.fr/FabNum/Fiches/src/branch/{ENV}/Documents/"
|
||||
url_fiche = f"{base_url}{fiche_selectionnee.replace(' ', '%20')}"
|
||||
reponses[section] = url_fiche
|
||||
st.text_input("Fiche concernée", value=url_fiche, disabled=True)
|
||||
|
||||
elif "Sujet de la proposition" in section:
|
||||
reponses[section] = st.text_input(section, help=aide)
|
||||
|
||||
else:
|
||||
reponses[section] = st.text_area(section, help=aide)
|
||||
|
||||
col1, col2 = st.columns(2)
|
||||
|
||||
with col1:
|
||||
if st.button("Prévisualiser le ticket"):
|
||||
st.session_state.previsualiser = True
|
||||
|
||||
with col2:
|
||||
if st.button("Annuler"):
|
||||
st.session_state.previsualiser = False
|
||||
st.rerun()
|
||||
|
||||
if st.session_state.get("previsualiser", False):
|
||||
st.subheader("Prévisualisation du ticket")
|
||||
for section, texte in reponses.items():
|
||||
st.markdown(f"#### {section}")
|
||||
st.markdown(texte)
|
||||
|
||||
if st.button("Confirmer la création du ticket"):
|
||||
if cible:
|
||||
labels.append(cible["item"])
|
||||
if selected_operations:
|
||||
labels.extend(selected_operations)
|
||||
|
||||
labels = list(set([l.strip() for l in labels if l and l.strip()]))
|
||||
titre_ticket = reponses.get("Sujet de la proposition", "").strip() or "Ticket FabNum"
|
||||
|
||||
labels_existants = get_labels_existants()
|
||||
labels_ids = [labels_existants[l] for l in labels if l in labels_existants]
|
||||
|
||||
if "Backlog" in labels_existants:
|
||||
labels_ids.append(labels_existants["Backlog"])
|
||||
|
||||
corps = ""
|
||||
for section, texte in reponses.items():
|
||||
corps += f"## {section}\n{texte}\n\n"
|
||||
|
||||
creer_ticket_gitea(titre=titre_ticket, corps=corps, labels=labels_ids)
|
||||
|
||||
st.success("Formulaire vidé après création du ticket.")
|
||||
47
utils/README.md
Normal file
47
utils/README.md
Normal file
@ -0,0 +1,47 @@
|
||||
# Module Utils
|
||||
|
||||
Ce module contient des utilitaires essentiels qui supportent le fonctionnement de l'application FabNum. Il fournit des fonctions génériques et des services partagés utilisés par les différents modules de l'application.
|
||||
|
||||
## Structure du module
|
||||
|
||||
Le module utils comprend plusieurs fichiers utilitaires clés :
|
||||
|
||||
- **gitea.py** : Fonctions pour interagir avec l'API Gitea (récupération des fiches, gestion des tickets, etc.)
|
||||
- **graph_utils.py** : Utilitaires pour manipuler, analyser et transformer les graphes de la chaîne de fabrication
|
||||
- **visualisation.py** : Fonctions communes pour la génération de visualisations et de graphiques
|
||||
|
||||
## Fonctionnalités
|
||||
|
||||
### Intégration Gitea (gitea.py)
|
||||
- Récupération des fiches et documents depuis le dépôt Gitea
|
||||
- Gestion de l'authentification et des tokens d'accès
|
||||
- Lecture et écriture des tickets et commentaires
|
||||
- Récupération des données de structure pour les fiches
|
||||
|
||||
### Manipulation de graphes (graph_utils.py)
|
||||
- Chargement et sauvegarde des graphes de dépendance
|
||||
- Calcul des chemins critiques et des niveaux de criticité
|
||||
- Fonctions d'analyse pour identifier les vulnérabilités
|
||||
- Transformation et filtrage des graphes pour différentes analyses
|
||||
|
||||
### Visualisation (visualisation.py)
|
||||
- Fonctions génériques pour générer des visualisations cohérentes
|
||||
- Helpers pour le formatage des données avant visualisation
|
||||
- Fonctions de conversion entre différents formats de données
|
||||
|
||||
## Utilisation
|
||||
|
||||
Ce module est conçu pour être importé par les autres composants de l'application. Exemple d'utilisation :
|
||||
|
||||
```python
|
||||
from utils.gitea import charger_instructions_depuis_gitea
|
||||
from utils.graph_utils import charger_graphe
|
||||
|
||||
# Chargement des instructions
|
||||
instructions = charger_instructions_depuis_gitea(INSTRUCTIONS_PATH)
|
||||
|
||||
# Chargement du graphe principal
|
||||
G_temp, G_temp_ivc, dot_file_path = charger_graphe()
|
||||
```
|
||||
|
||||
Ce module est fondamental pour l'application car il centralise les fonctions réutilisables et réduit la duplication de code entre les différents composants de l'application.
|
||||
109
utils/gitea.py
Normal file
109
utils/gitea.py
Normal file
@ -0,0 +1,109 @@
|
||||
import base64
|
||||
import requests
|
||||
import os
|
||||
import streamlit as st
|
||||
from dateutil import parser
|
||||
from datetime import datetime, timezone
|
||||
import logging
|
||||
|
||||
from config import GITEA_URL, GITEA_TOKEN, ORGANISATION, DEPOT_FICHES, DEPOT_CODE, ENV, ENV_CODE, DOT_FILE
|
||||
|
||||
def lire_fichier_local(nom_fichier):
|
||||
with open(nom_fichier, "r", encoding="utf-8") as f:
|
||||
contenu_md = f.read()
|
||||
return contenu_md
|
||||
|
||||
def charger_instructions_depuis_gitea(nom_fichier="Instructions.md"):
|
||||
headers = {"Authorization": f"token {GITEA_TOKEN}"}
|
||||
url = f"{GITEA_URL}/repos/{ORGANISATION}/{DEPOT_FICHES}/contents/{nom_fichier}?ref={ENV}"
|
||||
try:
|
||||
# Vérifier si une version plus récente existe sur le dépôt
|
||||
remote_last_modified = recuperer_date_dernier_commit(f"{GITEA_URL}/repos/{ORGANISATION}/{DEPOT_FICHES}/commits?path={nom_fichier}&sha={ENV}")
|
||||
local_last_modified = datetime.fromtimestamp(os.path.getmtime(nom_fichier), tz=timezone.utc) if os.path.exists(nom_fichier) else None
|
||||
|
||||
# Si le fichier local n'existe pas ou si la version distante est plus récente
|
||||
if not local_last_modified or (remote_last_modified and remote_last_modified > local_last_modified):
|
||||
response = requests.get(url, headers=headers)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
contenu_md = base64.b64decode(data["content"]).decode("utf-8")
|
||||
# Sauvegarder en local
|
||||
with open(nom_fichier, "w", encoding="utf-8") as f:
|
||||
f.write(contenu_md)
|
||||
return contenu_md
|
||||
else:
|
||||
# Lire depuis le cache local
|
||||
return lire_fichier_local(nom_fichier)
|
||||
except Exception as e:
|
||||
st.error(f"Erreur chargement instructions Gitea : {e}")
|
||||
# Essayer de charger depuis le cache local en cas d'erreur
|
||||
if os.path.exists(nom_fichier):
|
||||
return lire_fichier_local(nom_fichier)
|
||||
return None
|
||||
|
||||
|
||||
def recuperer_date_dernier_commit(url):
|
||||
headers = {"Authorization": f"token {GITEA_TOKEN}"}
|
||||
try:
|
||||
response = requests.get(url, headers=headers, timeout=10)
|
||||
response.raise_for_status()
|
||||
commits = response.json()
|
||||
if commits:
|
||||
return parser.isoparse(commits[0]["commit"]["author"]["date"])
|
||||
except Exception as e:
|
||||
logging.error(f"Erreur récupération commit schema : {e}")
|
||||
return None
|
||||
|
||||
|
||||
def charger_schema_depuis_gitea(fichier_local="schema_temp.txt"):
|
||||
headers = {"Authorization": f"token {GITEA_TOKEN}"}
|
||||
url = f"{GITEA_URL}/repos/{ORGANISATION}/{DEPOT_CODE}/contents/{DOT_FILE}?ref={ENV_CODE}"
|
||||
try:
|
||||
response = requests.get(url, headers=headers, timeout=10)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
remote_last_modified = recuperer_date_dernier_commit(f"{GITEA_URL}/repos/{ORGANISATION}/{DEPOT_CODE}/commits?path={DOT_FILE}&sha={ENV_CODE}")
|
||||
local_last_modified = datetime.fromtimestamp(os.path.getmtime(fichier_local), tz=timezone.utc) if os.path.exists(fichier_local) else None
|
||||
|
||||
if not local_last_modified or (remote_last_modified and remote_last_modified > local_last_modified):
|
||||
dot_text = base64.b64decode(data["content"]).decode("utf-8")
|
||||
with open(fichier_local, "w", encoding="utf-8") as f:
|
||||
f.write(dot_text)
|
||||
|
||||
return "OK"
|
||||
except Exception as e:
|
||||
logging.error(f"Erreur chargement schema Gitea : {e}")
|
||||
return None
|
||||
|
||||
|
||||
def charger_arborescence_fiches():
|
||||
headers = {"Authorization": f"token {GITEA_TOKEN}"}
|
||||
url_base = f"{GITEA_URL}/repos/{ORGANISATION}/{DEPOT_FICHES}/contents/Documents?ref={ENV}"
|
||||
|
||||
try:
|
||||
response = requests.get(url_base, headers=headers)
|
||||
response.raise_for_status()
|
||||
dossiers = response.json()
|
||||
arbo = {}
|
||||
|
||||
for dossier in sorted(dossiers, key=lambda d: d['name'].lower()):
|
||||
if dossier['type'] == 'dir':
|
||||
dossier_name = dossier['name']
|
||||
url_dossier = dossier['url']
|
||||
response_dossier = requests.get(url_dossier, headers=headers)
|
||||
response_dossier.raise_for_status()
|
||||
fichiers = response_dossier.json()
|
||||
fiches = sorted(
|
||||
[
|
||||
{"nom": f["name"], "download_url": f["download_url"]}
|
||||
for f in fichiers if f["name"].endswith(".md")
|
||||
],
|
||||
key=lambda x: x['nom'].lower()
|
||||
)
|
||||
arbo[dossier_name] = fiches
|
||||
|
||||
return arbo
|
||||
except Exception as e:
|
||||
logging.error(f"Erreur chargement fiches : {e}")
|
||||
return {}
|
||||
197
utils/graph_utils.py
Normal file
197
utils/graph_utils.py
Normal file
@ -0,0 +1,197 @@
|
||||
import networkx as nx
|
||||
import pandas as pd
|
||||
import logging
|
||||
import streamlit as st
|
||||
import json
|
||||
from networkx.drawing.nx_agraph import read_dot
|
||||
|
||||
# Configuration Gitea
|
||||
from config import DOT_FILE
|
||||
from utils.gitea import (
|
||||
charger_schema_depuis_gitea
|
||||
)
|
||||
|
||||
|
||||
def extraire_chemins_depuis(G, source):
|
||||
chemins = []
|
||||
stack = [(source, [source])]
|
||||
while stack:
|
||||
(node, path) = stack.pop()
|
||||
voisins = list(G.successors(node))
|
||||
if not voisins:
|
||||
chemins.append(path)
|
||||
else:
|
||||
for voisin in voisins:
|
||||
if voisin not in path:
|
||||
stack.append((voisin, path + [voisin]))
|
||||
return chemins
|
||||
|
||||
|
||||
def extraire_chemins_vers(G, target, niveau_demande):
|
||||
chemins = []
|
||||
reverse_G = G.reverse()
|
||||
niveaux = nx.get_node_attributes(G, "niveau")
|
||||
stack = [(target, [target])]
|
||||
|
||||
while stack:
|
||||
(node, path) = stack.pop()
|
||||
voisins = list(reverse_G.successors(node))
|
||||
if not voisins:
|
||||
chemin_inverse = list(reversed(path))
|
||||
contient_niveau = any(
|
||||
int(niveaux.get(n, -1)) == niveau_demande for n in chemin_inverse
|
||||
)
|
||||
if contient_niveau:
|
||||
chemins.append(chemin_inverse)
|
||||
else:
|
||||
for voisin in voisins:
|
||||
if voisin not in path:
|
||||
stack.append((voisin, path + [voisin]))
|
||||
|
||||
return chemins
|
||||
|
||||
|
||||
def recuperer_donnees(graph, noeuds):
|
||||
donnees = []
|
||||
criticite = {}
|
||||
|
||||
for noeud in noeuds:
|
||||
try:
|
||||
operation, minerai = noeud.split('_', 1)
|
||||
except ValueError:
|
||||
logging.warning(f"Nom de nœud inattendu : {noeud}")
|
||||
continue
|
||||
|
||||
if operation == "Traitement":
|
||||
try:
|
||||
fabrications = list(graph.predecessors(minerai))
|
||||
valeurs = [
|
||||
int(float(graph.get_edge_data(f, minerai)[0].get('criticite', 0)) * 100)
|
||||
for f in fabrications
|
||||
if graph.get_edge_data(f, minerai)
|
||||
]
|
||||
if valeurs:
|
||||
criticite[minerai] = round(sum(valeurs) / len(valeurs))
|
||||
except Exception as e:
|
||||
logging.warning(f"Erreur criticité pour {noeud} : {e}")
|
||||
criticite[minerai] = 50
|
||||
|
||||
for noeud in noeuds:
|
||||
try:
|
||||
operation, minerai = noeud.split('_', 1)
|
||||
ihh_pays = int(graph.nodes[noeud].get('ihh_pays', 0))
|
||||
ihh_acteurs = int(graph.nodes[noeud].get('ihh_acteurs', 0))
|
||||
criticite_val = criticite.get(minerai, 50)
|
||||
criticite_cat = 1 if criticite_val <= 33 else (2 if criticite_val <= 66 else 3)
|
||||
|
||||
donnees.append({
|
||||
'categorie': operation,
|
||||
'nom': minerai,
|
||||
'ihh_pays': ihh_pays,
|
||||
'ihh_acteurs': ihh_acteurs,
|
||||
'criticite_minerai': criticite_val,
|
||||
'criticite_cat': criticite_cat
|
||||
})
|
||||
except Exception as e:
|
||||
logging.error(f"Erreur sur le nœud {noeud} : {e}", exc_info=True)
|
||||
|
||||
return pd.DataFrame(donnees)
|
||||
|
||||
|
||||
def recuperer_donnees_2(graph, noeuds_2):
|
||||
donnees = []
|
||||
for minerai in noeuds_2:
|
||||
try:
|
||||
missing = []
|
||||
if not graph.has_node(minerai):
|
||||
missing.append(minerai)
|
||||
if not graph.has_node(f"Extraction_{minerai}"):
|
||||
missing.append(f"Extraction_{minerai}")
|
||||
if not graph.has_node(f"Reserves_{minerai}"):
|
||||
missing.append(f"Reserves_{minerai}")
|
||||
|
||||
if missing:
|
||||
print(f"⚠️ Nœuds manquants pour {minerai} : {', '.join(missing)} — Ignoré.")
|
||||
continue
|
||||
|
||||
ivc = int(graph.nodes[minerai].get('ivc', 0))
|
||||
ihh_extraction_pays = int(graph.nodes[f"Extraction_{minerai}"].get('ihh_pays', 0))
|
||||
ihh_reserves_pays = int(graph.nodes[f"Reserves_{minerai}"].get('ihh_pays', 0))
|
||||
|
||||
donnees.append({
|
||||
'nom': minerai,
|
||||
'ivc': ivc,
|
||||
'ihh_extraction': ihh_extraction_pays,
|
||||
'ihh_reserves': ihh_reserves_pays
|
||||
})
|
||||
except Exception as e:
|
||||
print(f"Erreur avec le nœud {minerai} : {e}")
|
||||
return donnees
|
||||
|
||||
|
||||
def couleur_noeud(n, niveaux, G):
|
||||
niveau = niveaux.get(n, 99)
|
||||
attrs = G.nodes[n]
|
||||
|
||||
# Niveau 99 : pays géographique avec isg
|
||||
if niveau == 99:
|
||||
isg = int(attrs.get("isg", -1))
|
||||
return (
|
||||
"darkred" if isg >= 60 else
|
||||
"orange" if isg >= 31 else
|
||||
"darkgreen" if isg >= 0 else
|
||||
"gray"
|
||||
)
|
||||
|
||||
# Niveau 11 ou 12 connecté à un pays géographique
|
||||
if niveau in (11, 12, 1011, 1012):
|
||||
for succ in G.successors(n):
|
||||
if niveaux.get(succ) == 99:
|
||||
isg = int(G.nodes[succ].get("isg", -1))
|
||||
return (
|
||||
"darkred" if isg >= 60 else
|
||||
"orange" if isg >= 31 else
|
||||
"darkgreen" if isg >= 0 else
|
||||
"gray"
|
||||
)
|
||||
|
||||
# Logique existante pour IHH / IVC
|
||||
if niveau in (10, 1010) and attrs.get("ihh_pays"):
|
||||
ihh = int(attrs["ihh_pays"])
|
||||
return (
|
||||
"darkgreen" if ihh <= 15 else
|
||||
"orange" if ihh <= 25 else
|
||||
"darkred"
|
||||
)
|
||||
elif niveau == 2 and attrs.get("ivc"):
|
||||
ivc = int(attrs["ivc"])
|
||||
return (
|
||||
"darkgreen" if ivc <= 15 else
|
||||
"orange" if ivc <= 30 else
|
||||
"darkred"
|
||||
)
|
||||
|
||||
return "lightblue"
|
||||
|
||||
def charger_graphe():
|
||||
if "G_temp" not in st.session_state:
|
||||
try:
|
||||
if charger_schema_depuis_gitea(DOT_FILE):
|
||||
st.session_state["G_temp"] = read_dot(DOT_FILE)
|
||||
st.session_state["G_temp_ivc"] = st.session_state["G_temp"].copy()
|
||||
dot_file_path = True
|
||||
else:
|
||||
dot_file_path = False
|
||||
except Exception as e:
|
||||
st.error(f"Erreur de lecture du fichier DOT : {e}")
|
||||
dot_file_path = False
|
||||
else:
|
||||
dot_file_path = True
|
||||
|
||||
if dot_file_path:
|
||||
G_temp = st.session_state["G_temp"]
|
||||
G_temp_ivc = st.session_state["G_temp_ivc"]
|
||||
else:
|
||||
st.error("Impossible de charger le graphe pour cet onglet.")
|
||||
|
||||
return G_temp, G_temp_ivc, dot_file_path
|
||||
84
utils/translations.py
Normal file
84
utils/translations.py
Normal 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):
|
||||
"""
|
||||
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 f"⊗⤇ {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 f"⊗⤇ {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
|
||||
180
utils/visualisation.py
Normal file
180
utils/visualisation.py
Normal file
@ -0,0 +1,180 @@
|
||||
import streamlit as st
|
||||
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 = [
|
||||
str(_("pages.visualisations.categories.assembly")),
|
||||
str(_("pages.visualisations.categories.manufacturing")),
|
||||
str(_("pages.visualisations.categories.processing")),
|
||||
str(_("pages.visualisations.categories.extraction"))
|
||||
]
|
||||
categories = [cat for cat in ordre_personnalise if cat in df['categorie'].unique()]
|
||||
for cat in categories:
|
||||
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)))
|
||||
counts = Counter(coord_pairs)
|
||||
|
||||
offset_x = []
|
||||
offset_y = {}
|
||||
seen = Counter()
|
||||
for pair in coord_pairs:
|
||||
rank = seen[pair]
|
||||
seen[pair] += 1
|
||||
if counts[pair] > 1:
|
||||
angle = rank * 1.5
|
||||
radius = 0.8 + 0.4 * rank
|
||||
offset_x.append(radius * np.cos(angle))
|
||||
offset_y[pair] = radius * np.sin(angle)
|
||||
else:
|
||||
offset_x.append(0)
|
||||
offset_y[pair] = 0
|
||||
|
||||
df_cat['ihh_pays'] += offset_x
|
||||
df_cat['ihh_acteurs'] += [offset_y[p] for p in coord_pairs]
|
||||
df_cat['ihh_pays_text'] = df_cat['ihh_pays'] + 0.5
|
||||
df_cat['ihh_acteurs_text'] = df_cat['ihh_acteurs'] + 0.5
|
||||
|
||||
base = alt.Chart(df_cat).encode(
|
||||
x=alt.X('ihh_pays:Q', title=str(_("pages.visualisations.axis_titles.ihh_countries"))),
|
||||
y=alt.Y('ihh_acteurs:Q', title=str(_("pages.visualisations.axis_titles.ihh_actors"))),
|
||||
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']))
|
||||
)
|
||||
|
||||
points = base.mark_circle(opacity=0.6)
|
||||
lines = alt.Chart(df_cat).mark_rule(strokeWidth=0.5, color='gray').encode(
|
||||
x='ihh_pays:Q', x2='ihh_pays_text:Q',
|
||||
y='ihh_acteurs:Q', y2='ihh_acteurs_text:Q'
|
||||
)
|
||||
|
||||
labels = alt.Chart(df_cat).mark_text(
|
||||
align='left', dx=3, dy=-3, fontSize=8, font='Arial', angle=335
|
||||
).encode(
|
||||
x='ihh_pays_text:Q',
|
||||
y='ihh_acteurs_text:Q',
|
||||
text='nom:N'
|
||||
)
|
||||
|
||||
hline_15 = alt.Chart(df_cat).mark_rule(strokeDash=[2,2], color='green').encode(y=alt.datum(15))
|
||||
hline_25 = alt.Chart(df_cat).mark_rule(strokeDash=[2,2], color='red').encode(y=alt.datum(25))
|
||||
vline_15 = alt.Chart(df_cat).mark_rule(strokeDash=[2,2], color='green').encode(x=alt.datum(15))
|
||||
vline_25 = alt.Chart(df_cat).mark_rule(strokeDash=[2,2], color='red').encode(x=alt.datum(25))
|
||||
|
||||
chart = (points + lines + labels + hline_15 + hline_25 + vline_15 + vline_25).properties(
|
||||
width=500,
|
||||
height=400,
|
||||
title=str(_("pages.visualisations.chart_titles.concentration_criticality")).format(str(cat))
|
||||
).interactive()
|
||||
|
||||
st.altair_chart(chart, use_container_width=True)
|
||||
|
||||
|
||||
def creer_graphes(donnees):
|
||||
if not donnees:
|
||||
st.warning(str(_("pages.visualisations.no_data")))
|
||||
return
|
||||
|
||||
try:
|
||||
df = pd.DataFrame(donnees)
|
||||
df['ivc_cat'] = df['ivc'].apply(lambda x: 1 if x <= 15 else (2 if x <= 30 else 3))
|
||||
|
||||
from collections import Counter
|
||||
coord_pairs = list(zip(df['ihh_extraction'].round(1), df['ihh_reserves'].round(1)))
|
||||
counts = Counter(coord_pairs)
|
||||
|
||||
offset_x, offset_y = [], {}
|
||||
seen = Counter()
|
||||
for pair in coord_pairs:
|
||||
rank = seen[pair]
|
||||
seen[pair] += 1
|
||||
if counts[pair] > 1:
|
||||
angle = rank * 1.5
|
||||
radius = 0.8 + 0.4 * rank
|
||||
offset_x.append(radius * np.cos(angle))
|
||||
offset_y[pair] = radius * np.sin(angle)
|
||||
else:
|
||||
offset_x.append(0)
|
||||
offset_y[pair] = 0
|
||||
|
||||
df['ihh_extraction'] += offset_x
|
||||
df['ihh_reserves'] += [offset_y[p] for p in coord_pairs]
|
||||
df['ihh_extraction_text'] = df['ihh_extraction'] + 0.5
|
||||
df['ihh_reserves_text'] = df['ihh_reserves'] + 0.5
|
||||
|
||||
base = alt.Chart(df).encode(
|
||||
x=alt.X('ihh_extraction:Q', title=str(_("pages.visualisations.axis_titles.ihh_extraction"))),
|
||||
y=alt.Y('ihh_reserves:Q', title=str(_("pages.visualisations.axis_titles.ihh_reserves"))),
|
||||
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']
|
||||
)
|
||||
|
||||
points = base.mark_circle(opacity=0.6)
|
||||
lines = alt.Chart(df).mark_rule(strokeWidth=0.5, color='gray').encode(
|
||||
x='ihh_extraction:Q', x2='ihh_extraction_text:Q',
|
||||
y='ihh_reserves:Q', y2='ihh_reserves_text:Q'
|
||||
)
|
||||
|
||||
labels = alt.Chart(df).mark_text(
|
||||
align='left', dx=10, dy=-10, fontSize=10, font='Arial', angle=335
|
||||
).encode(
|
||||
x='ihh_extraction_text:Q',
|
||||
y='ihh_reserves_text:Q',
|
||||
text='nom:N'
|
||||
)
|
||||
|
||||
hline_15 = alt.Chart(df).mark_rule(strokeDash=[2,2], color='green').encode(y=alt.datum(15))
|
||||
hline_25 = alt.Chart(df).mark_rule(strokeDash=[2,2], color='red').encode(y=alt.datum(25))
|
||||
vline_15 = alt.Chart(df).mark_rule(strokeDash=[2,2], color='green').encode(x=alt.datum(15))
|
||||
vline_25 = alt.Chart(df).mark_rule(strokeDash=[2,2], color='red').encode(x=alt.datum(25))
|
||||
|
||||
chart = (points + lines + labels + hline_15 + hline_25 + vline_15 + vline_25).properties(
|
||||
width=600,
|
||||
height=500,
|
||||
title=str(_("pages.visualisations.chart_titles.concentration_resources"))
|
||||
).interactive()
|
||||
|
||||
st.altair_chart(chart, use_container_width=True)
|
||||
|
||||
except Exception as e:
|
||||
st.error(f"{str(_('errors.graph_creation_error'))} {e}")
|
||||
|
||||
|
||||
def lancer_visualisation_ihh_criticite(graph):
|
||||
try:
|
||||
import networkx as nx
|
||||
from utils.graph_utils import recuperer_donnees
|
||||
|
||||
niveaux = nx.get_node_attributes(graph, "niveau")
|
||||
noeuds = [n for n, v in niveaux.items() if v == "10" and "Reserves" not in n]
|
||||
noeuds.sort()
|
||||
|
||||
df = recuperer_donnees(graph, noeuds)
|
||||
if df.empty:
|
||||
st.warning(str(_("pages.visualisations.no_data")))
|
||||
else:
|
||||
afficher_graphique_altair(df)
|
||||
except Exception as e:
|
||||
st.error(f"{str(_('errors.ihh_criticality_error'))} {e}")
|
||||
|
||||
|
||||
def lancer_visualisation_ihh_ivc(graph):
|
||||
try:
|
||||
from utils.graph_utils import recuperer_donnees_2
|
||||
noeuds_niveau_2 = [
|
||||
n for n, data in graph.nodes(data=True)
|
||||
if data.get("niveau") == "2" and "ivc" in data
|
||||
]
|
||||
if not noeuds_niveau_2:
|
||||
return
|
||||
data = recuperer_donnees_2(graph, noeuds_niveau_2)
|
||||
creer_graphes(data)
|
||||
except Exception as e:
|
||||
st.error(f"{str(_('errors.ihh_ivc_error'))} {e}")
|
||||
Loading…
x
Reference in New Issue
Block a user