analist commited on
Commit
4bc6908
·
verified ·
1 Parent(s): df01d7b

Update streamlit_app.py

Browse files
Files changed (1) hide show
  1. streamlit_app.py +447 -156
streamlit_app.py CHANGED
@@ -1,13 +1,15 @@
1
- import io
2
  import os
3
  from datetime import datetime, date
4
  from typing import Dict, List, Optional, Tuple
 
 
 
5
 
6
  import pandas as pd
7
  import plotly.express as px
8
  import streamlit as st
9
 
10
-
11
  # -----------------------------
12
  # App Configuration
13
  # -----------------------------
@@ -18,7 +20,6 @@ st.set_page_config(
18
  initial_sidebar_state="expanded",
19
  )
20
 
21
-
22
  # -----------------------------
23
  # Utilities
24
  # -----------------------------
@@ -66,6 +67,7 @@ def find_column(df: pd.DataFrame, candidates: List[str]) -> Optional[str]:
66
  return norm_to_col[n]
67
  return None
68
 
 
69
  def infer_pandas_types(df: pd.DataFrame) -> Dict[str, str]:
70
  """Return a mapping of column -> inferred logical type: 'categorical' | 'numeric' | 'date' | 'text'."""
71
  type_map: Dict[str, str] = {}
@@ -166,11 +168,94 @@ def chart_card(title: str, fig):
166
 
167
 
168
  def inject_base_css():
169
- with open(os.path.join("", "styles.css"), "r", encoding="utf-8") as f:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
170
  css = f.read()
171
  st.markdown(f"<style>{css}</style>", unsafe_allow_html=True)
172
 
173
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
174
  def set_theme_variables(mode: str):
175
  # Adjust CSS variables for light/dark for cards and text; Plotly handled via template
176
  palette = {
@@ -230,8 +315,16 @@ def sidebar_controls() -> Tuple[Optional[pd.DataFrame], Dict[str, str], str, Dic
230
  # Ensure unique column names
231
  if pd.Index(df.columns).has_duplicates:
232
  df.columns = make_unique_columns(list(df.columns))
 
 
 
 
233
  except Exception as e:
234
  st.sidebar.error(f"Erreur de lecture du fichier: {e}")
 
 
 
 
235
 
236
  logical_types: Dict[str, str] = {}
237
  coercions: Dict[str, str] = {}
@@ -306,31 +399,22 @@ def sidebar_controls() -> Tuple[Optional[pd.DataFrame], Dict[str, str], str, Dic
306
  unique_keys = st.sidebar.multiselect(
307
  "Champs d'unicité (sélection multiple)", options=list(df.columns), default=suggested, help="Sélectionnez les champs qui identifient de façon unique une personne."
308
  )
 
 
 
 
 
309
 
310
  return df, logical_types, theme_mode, coercions, unique_keys
311
 
312
 
313
  # -----------------------------
314
- # Main App
315
  # -----------------------------
316
- def main():
317
- inject_base_css()
318
-
319
- # Header
320
- col_logo, col_title, col_right = st.columns([1, 3, 1])
321
- with col_logo:
322
- logo_path = os.path.join("assets", "logo.png")
323
- if os.path.exists(logo_path):
324
- st.image(logo_path, width=72)
325
- with col_title:
326
- st.markdown("<h1 style='text-align:center; margin-top: 0;'>Tableau de bord des inscriptions</h1>", unsafe_allow_html=True)
327
- with col_right:
328
- st.write("")
329
-
330
- df, type_map, theme_mode, _, unique_keys = sidebar_controls()
331
- plotly_template = get_plotly_template(theme_mode)
332
-
333
- if df is None or df.empty:
334
  st.markdown(
335
  """
336
  <div class="card">
@@ -346,14 +430,18 @@ def main():
346
  )
347
  return
348
 
 
 
 
 
 
 
349
  # Filters (dynamic for all columns)
350
  st.sidebar.markdown("---")
351
  filtered_df = dynamic_filters(df, type_map)
352
 
353
  # Optional unique-person filtering using selected keys
354
  st.sidebar.markdown("### 👤 Filtrer par personne unique")
355
- if 'unique_keys' not in locals():
356
- unique_keys = []
357
  if unique_keys:
358
  person_filter = st.sidebar.checkbox("Activer le filtre d'unicité (drop_duplicates)", value=False, key="unique_filter_toggle")
359
  keep_strategy = st.sidebar.selectbox("Conserver", options=["first", "last"], index=0, key="unique_filter_keep")
@@ -363,6 +451,9 @@ def main():
363
  except Exception:
364
  st.sidebar.warning("Impossible d'appliquer le filtre d'unicité. Vérifiez les champs choisis.")
365
 
 
 
 
366
  # KPIs
367
  total_count = len(filtered_df)
368
  total_columns = filtered_df.shape[1]
@@ -441,7 +532,7 @@ def main():
441
  chart_card("Répartition (dimension 2)", fig_country)
442
  st.markdown("</div>", unsafe_allow_html=True)
443
 
444
- # Charts row 2: Status distribution, Time series
445
  charts_row_2 = st.columns(2)
446
  if cat_cols_all and not filtered_df.empty:
447
  dim3 = st.selectbox("Dimension 3", options=cat_cols_all, key="rep_dim3")
@@ -459,15 +550,50 @@ def main():
459
  with charts_row_2[0]:
460
  chart_card("Répartition (dimension 3)", fig_status)
461
 
462
- # date_cols = [c for c in filtered_df.columns if type_map.get(c) == "date"]
463
-
 
 
464
 
465
- # Charts row 3: Numeric histogram (user-selectable)
466
- # numeric_cols = [c for c in filtered_df.columns if pd.api.types.is_numeric_dtype(filtered_df[c])]
467
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
468
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
469
  # Ad-hoc analysis builder
470
- st.markdown("<div class=\"card\"><div class=\"card-title\">Zone danalyse</div>", unsafe_allow_html=True)
471
  cat_cols = [c for c in filtered_df.columns if type_map.get(c) in ("categorical", "text")]
472
  if cat_cols:
473
  ac1, ac2, ac3 = st.columns([2,1,1])
@@ -486,45 +612,112 @@ def main():
486
  st.plotly_chart(fig, use_container_width=True, theme=None)
487
  st.markdown("</div>", unsafe_allow_html=True)
488
 
489
-
490
  # Drilldown option (simple): filtrer sur une dimension/valeur
 
491
  dd_cols = cat_cols
492
  dd1, dd2 = st.columns([1,2])
493
  with dd1:
494
  dd_dim = st.selectbox("Drilldown - dimension", options=[None] + dd_cols)
 
 
495
  if dd_dim:
496
  values = [x for x in filtered_df[dd_dim].dropna().astype(str).unique()]
497
  with dd2:
498
  dd_val = st.selectbox("Valeur", options=[None] + values)
499
  if dd_val:
500
- filtered_df = filtered_df[filtered_df[dd_dim].astype(str) == dd_val]
501
-
502
- search_query = st.text_input("Recherche globale")
503
- df_searched = apply_search(filtered_df, search_query)
504
  st.dataframe(df_searched, use_container_width=True, hide_index=True)
 
505
 
506
- # Downloads
507
- csv_bytes = df_searched.to_csv(index=False).encode("utf-8-sig")
508
- xlsx_bytes = to_excel_bytes(df_searched)
509
- dc1, dc2 = st.columns(2)
510
- with dc1:
511
- st.download_button(
512
- "Télécharger CSV",
513
- data=csv_bytes,
514
- file_name="inscriptions_filtrees.csv",
515
- mime="text/csv",
516
- use_container_width=True,
517
- )
518
- with dc2:
519
- st.download_button(
520
- "Télécharger Excel",
521
- data=xlsx_bytes,
522
- file_name="inscriptions_filtrees.xlsx",
523
- mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
524
- use_container_width=True,
525
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
526
  st.markdown("</div>", unsafe_allow_html=True)
527
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
528
  # Universal Chart Builder
529
  st.markdown("<div class=\"card\"><div class=\"card-title\">Constructeur de graphiques</div>", unsafe_allow_html=True)
530
  chart_types = [
@@ -624,112 +817,210 @@ def main():
624
  st.plotly_chart(fig, use_container_width=True, theme=None)
625
  st.markdown("</div>", unsafe_allow_html=True)
626
 
627
- # Decision Maker View (field-aware, optional)
628
- st.markdown("<div class=\"card\"><div class=\"card-title\">Vue Décideur (si champs disponibles)</div>", unsafe_allow_html=True)
629
- # Candidate fields based on provided list
630
- col_email = find_column(filtered_df, ["Email"]) or find_column(filtered_df, ["E-mail"])
631
- col_gender = find_column(filtered_df, ["Genre", "Autre genre (Veuillez préciser) : "])
632
- col_nat = find_column(filtered_df, ["Nationalité"])
633
- col_country = find_column(filtered_df, ["Pays de résidence"]) or find_column(filtered_df, ["D’où préférez-vous participer à l'événement ?"])
634
- col_role = find_column(filtered_df, ["Votre profession / statut", "Autre profession (veuillez préciser)"])
635
- col_aff = find_column(filtered_df, ["Affiliation", "Autre affiliation (Veuillez préciser) : "])
636
- col_particip = find_column(filtered_df, ["Avez-vous déjà participé à un événement Indaba X Togo ?"])
637
- col_mode_formation = find_column(filtered_df, ["Comment voulez-vous participer aux formations ?"])
638
- col_what_do = find_column(filtered_df, ["Que voulez-vous faire ?"])
639
- col_skills = {
640
- "Python": find_column(filtered_df, ["Quel est votre niveau en [Python]", "Quel est votre niveau en [Python]"]),
641
- "Numpy": find_column(filtered_df, ["Quel est votre niveau en [Numpy]", "Quel est votre niveau en [Numpy]"]),
642
- "Pandas": find_column(filtered_df, ["Quel est votre niveau en [Pandas]", "Quel est votre niveau en [Pandas]"]),
643
- "Scikit Learn": find_column(filtered_df, ["Quel est votre niveau en [Scikit Learn]", "Quel est votre niveau en [Scikit Learn]"]),
644
- "Pytorch": find_column(filtered_df, ["Quel est votre niveau en [Pytorch]", "Quel est votre niveau en [Pytorch]"]),
645
- "Deep Learning": find_column(filtered_df, ["Quel est votre niveau en [Deep Learning]", "Quel est votre niveau en [Deep Learning]"]),
646
- }
647
 
648
- # KPIs for decision maker
649
- kcols = st.columns(4)
650
- with kcols[0]:
651
- kpi_card("Inscriptions", f"{len(filtered_df):,}")
652
- with kcols[1]:
653
- if col_email:
654
- uniq_people = filtered_df[col_email].astype(str).str.strip().str.lower().dropna().nunique()
655
- kpi_card("Personnes uniques (email)", f"{uniq_people:,}")
656
- else:
657
- kpi_card("Personnes uniques", "-")
658
- with kcols[2]:
659
- if col_country and col_country in filtered_df.columns:
660
- kpi_card("Pays (distincts)", f"{filtered_df[col_country].astype(str).nunique():,}")
661
- else:
662
- kpi_card("Pays (distincts)", "-")
663
- with kcols[3]:
664
- if col_role and col_role in filtered_df.columns:
665
- kpi_card("Profils (distincts)", f"{filtered_df[col_role].astype(str).nunique():,}")
666
- else:
667
- kpi_card("Profils (distincts)", "-")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
668
 
669
- # Row 1 charts: Gender, Country
670
- dm1 = st.columns(2)
671
- if col_gender and col_gender in filtered_df.columns and not filtered_df.empty:
672
- gcounts = filtered_df.groupby(col_gender).size().reset_index(name="count").sort_values("count", ascending=False)
673
- fig_g = px.pie(gcounts, names=col_gender, values="count", template=get_plotly_template(theme_mode), hole=0.35)
674
- with dm1[0]:
675
- chart_card("Répartition par genre", fig_g)
676
- if col_country and col_country in filtered_df.columns and not filtered_df.empty:
677
- ccounts = filtered_df.groupby(col_country).size().reset_index(name="count").sort_values("count", ascending=False).head(15)
678
- fig_c = px.bar(ccounts, x=col_country, y="count", template=get_plotly_template(theme_mode))
679
- with dm1[1]:
680
- chart_card("Top 15 pays de résidence", fig_c)
681
 
682
- # Row 2: Participation history and roles
683
- dm2 = st.columns(2)
684
- if col_particip and col_particip in filtered_df.columns and not filtered_df.empty:
685
- pcounts = filtered_df.groupby(col_particip).size().reset_index(name="count")
686
- fig_p = px.bar(pcounts, x=col_particip, y="count", template=get_plotly_template(theme_mode))
687
- with dm2[0]:
688
- chart_card("A déjà participé ?", fig_p)
689
- if col_role and col_role in filtered_df.columns and not filtered_df.empty:
690
- rcounts = filtered_df.groupby(col_role).size().reset_index(name="count").sort_values("count", ascending=False).head(15)
691
- fig_r = px.bar(rcounts, x=col_role, y="count", template=get_plotly_template(theme_mode))
692
- with dm2[1]:
693
- chart_card("Professions / Statuts (Top 15)", fig_r)
 
 
 
 
 
694
 
695
- # Row 2b: Formations participation mode and intentions
696
- dm2b = st.columns(2)
697
- if col_mode_formation and col_mode_formation in filtered_df.columns and not filtered_df.empty:
698
- mcounts = (
699
- filtered_df.groupby(col_mode_formation).size().reset_index(name="count").sort_values("count", ascending=False)
700
- )
701
- fig_m = px.bar(mcounts, x=col_mode_formation, y="count", template=get_plotly_template(theme_mode))
702
- with dm2b[0]:
703
- chart_card("Mode de participation aux formations", fig_m)
704
- if col_what_do and col_what_do in filtered_df.columns and not filtered_df.empty:
705
- wcounts = (
706
- filtered_df.groupby(col_what_do).size().reset_index(name="count").sort_values("count", ascending=False).head(15)
707
- )
708
- fig_w = px.bar(wcounts, x=col_what_do, y="count", template=get_plotly_template(theme_mode))
709
- with dm2b[1]:
710
- chart_card("Intentions: Que voulez-vous faire ? (Top 15)", fig_w)
711
-
712
- # Row 3: Skills radar-like bars
713
- skill_pairs = [(name, col) for name, col in col_skills.items() if col]
714
- if skill_pairs:
715
- sm = []
716
- for name, col in skill_pairs:
717
- # Map text levels to ordered scale if needed
718
- s = filtered_df[col].astype(str).str.strip().str.lower()
719
- order = ["débutant", "intermédiaire", "avancé", "expert"]
720
- s = s.where(s.isin(order), s)
721
- d = s.value_counts().reindex(order).fillna(0).rename_axis("niveau").reset_index(name="count")
722
- d["skill"] = name
723
- sm.append(d)
724
- if sm:
725
- skill_df = pd.concat(sm, ignore_index=True)
726
- fig_skill = px.bar(skill_df, x="skill", y="count", color="niveau", barmode="group", template=get_plotly_template(theme_mode))
727
- chart_card("Niveaux par compétence", fig_skill)
728
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
729
  st.markdown("</div>", unsafe_allow_html=True)
730
 
731
 
732
- if __name__ == "__main__":
733
- main()
 
 
 
734
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
735
 
 
 
 
 
1
+ import io
2
  import os
3
  from datetime import datetime, date
4
  from typing import Dict, List, Optional, Tuple
5
+ import smtplib
6
+ import ssl
7
+ from email.message import EmailMessage
8
 
9
  import pandas as pd
10
  import plotly.express as px
11
  import streamlit as st
12
 
 
13
  # -----------------------------
14
  # App Configuration
15
  # -----------------------------
 
20
  initial_sidebar_state="expanded",
21
  )
22
 
 
23
  # -----------------------------
24
  # Utilities
25
  # -----------------------------
 
67
  return norm_to_col[n]
68
  return None
69
 
70
+
71
  def infer_pandas_types(df: pd.DataFrame) -> Dict[str, str]:
72
  """Return a mapping of column -> inferred logical type: 'categorical' | 'numeric' | 'date' | 'text'."""
73
  type_map: Dict[str, str] = {}
 
168
 
169
 
170
  def inject_base_css():
171
+ # Créer le dossier assets s'il n'existe pas
172
+ if not os.path.exists("assets"):
173
+ os.makedirs("assets")
174
+
175
+ # Créer le fichier CSS s'il n'existe pas
176
+ css_file = os.path.join("assets", "styles.css")
177
+ if not os.path.exists(css_file):
178
+ with open(css_file, "w", encoding="utf-8") as f:
179
+ f.write("""
180
+ .card {
181
+ background-color: var(--card);
182
+ border-radius: 0.5rem;
183
+ padding: 1rem;
184
+ margin-bottom: 1rem;
185
+ box-shadow: 0 1px 3px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.24);
186
+ }
187
+ .card-title {
188
+ font-weight: bold;
189
+ font-size: 1.2rem;
190
+ margin-bottom: 0.5rem;
191
+ color: var(--primary);
192
+ }
193
+ .kpi {
194
+ text-align: center;
195
+ padding: 1rem;
196
+ }
197
+ .card-label {
198
+ font-size: 1rem;
199
+ color: var(--muted);
200
+ }
201
+ .card-value {
202
+ font-size: 2rem;
203
+ font-weight: bold;
204
+ color: var(--primary);
205
+ }
206
+ """)
207
+
208
+ # Lire et injecter le CSS
209
+ with open(css_file, "r", encoding="utf-8") as f:
210
  css = f.read()
211
  st.markdown(f"<style>{css}</style>", unsafe_allow_html=True)
212
 
213
 
214
+ def safe_format_template(template: str, row: Dict[str, object]) -> str:
215
+ class SafeDict(dict):
216
+ def __missing__(self, key):
217
+ return ""
218
+
219
+ flat = {str(k): ("" if v is None else str(v)) for k, v in row.items()}
220
+ try:
221
+ return template.format_map(SafeDict(flat))
222
+ except Exception:
223
+ return template
224
+
225
+
226
+ def send_email_smtp(
227
+ smtp_host: str,
228
+ smtp_port: int,
229
+ sender_email: str,
230
+ sender_password: str,
231
+ use_tls: bool,
232
+ to_email: str,
233
+ subject: str,
234
+ body_text: str,
235
+ reply_to: Optional[str] = None,
236
+ ) -> None:
237
+ message = EmailMessage()
238
+ message["From"] = sender_email
239
+ message["To"] = to_email
240
+ message["Subject"] = subject
241
+ if reply_to:
242
+ message["Reply-To"] = reply_to
243
+ message.set_content(body_text)
244
+
245
+ if use_tls:
246
+ context = ssl.create_default_context()
247
+ with smtplib.SMTP(smtp_host, smtp_port) as server:
248
+ server.starttls(context=context)
249
+ if sender_password:
250
+ server.login(sender_email, sender_password)
251
+ server.send_message(message)
252
+ else:
253
+ with smtplib.SMTP_SSL(smtp_host, smtp_port) as server:
254
+ if sender_password:
255
+ server.login(sender_email, sender_password)
256
+ server.send_message(message)
257
+
258
+
259
  def set_theme_variables(mode: str):
260
  # Adjust CSS variables for light/dark for cards and text; Plotly handled via template
261
  palette = {
 
315
  # Ensure unique column names
316
  if pd.Index(df.columns).has_duplicates:
317
  df.columns = make_unique_columns(list(df.columns))
318
+
319
+ # Stocker dans session state pour les autres onglets
320
+ st.session_state['df'] = df
321
+ st.session_state['filtered_df'] = df.copy()
322
  except Exception as e:
323
  st.sidebar.error(f"Erreur de lecture du fichier: {e}")
324
+ else:
325
+ # Récupérer les données du session state si disponible
326
+ if 'df' in st.session_state:
327
+ df = st.session_state['df']
328
 
329
  logical_types: Dict[str, str] = {}
330
  coercions: Dict[str, str] = {}
 
399
  unique_keys = st.sidebar.multiselect(
400
  "Champs d'unicité (sélection multiple)", options=list(df.columns), default=suggested, help="Sélectionnez les champs qui identifient de façon unique une personne."
401
  )
402
+
403
+ # Stocker les types et clés dans session state
404
+ st.session_state['logical_types'] = logical_types
405
+ st.session_state['unique_keys'] = unique_keys
406
+ st.session_state['filtered_df'] = df.copy()
407
 
408
  return df, logical_types, theme_mode, coercions, unique_keys
409
 
410
 
411
  # -----------------------------
412
+ # Page: Tableau de bord
413
  # -----------------------------
414
+ def page_tableau_de_bord():
415
+ st.markdown("<h2>📊 Tableau de bord</h2>", unsafe_allow_html=True)
416
+
417
+ if 'df' not in st.session_state or st.session_state['df'] is None:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
418
  st.markdown(
419
  """
420
  <div class="card">
 
430
  )
431
  return
432
 
433
+ df = st.session_state['df']
434
+ type_map = st.session_state.get('logical_types', {})
435
+ unique_keys = st.session_state.get('unique_keys', [])
436
+ theme_mode = "dark" if st.session_state.get('theme_mode') == "dark" else "light"
437
+ plotly_template = get_plotly_template(theme_mode)
438
+
439
  # Filters (dynamic for all columns)
440
  st.sidebar.markdown("---")
441
  filtered_df = dynamic_filters(df, type_map)
442
 
443
  # Optional unique-person filtering using selected keys
444
  st.sidebar.markdown("### 👤 Filtrer par personne unique")
 
 
445
  if unique_keys:
446
  person_filter = st.sidebar.checkbox("Activer le filtre d'unicité (drop_duplicates)", value=False, key="unique_filter_toggle")
447
  keep_strategy = st.sidebar.selectbox("Conserver", options=["first", "last"], index=0, key="unique_filter_keep")
 
451
  except Exception:
452
  st.sidebar.warning("Impossible d'appliquer le filtre d'unicité. Vérifiez les champs choisis.")
453
 
454
+ # Mettre à jour le dataframe filtré dans session state
455
+ st.session_state['filtered_df'] = filtered_df
456
+
457
  # KPIs
458
  total_count = len(filtered_df)
459
  total_columns = filtered_df.shape[1]
 
532
  chart_card("Répartition (dimension 2)", fig_country)
533
  st.markdown("</div>", unsafe_allow_html=True)
534
 
535
+ # Charts row 2: Status distribution
536
  charts_row_2 = st.columns(2)
537
  if cat_cols_all and not filtered_df.empty:
538
  dim3 = st.selectbox("Dimension 3", options=cat_cols_all, key="rep_dim3")
 
550
  with charts_row_2[0]:
551
  chart_card("Répartition (dimension 3)", fig_status)
552
 
553
+ # Affichage des données
554
+ search_query = st.text_input("Recherche globale", key="search_dashboard")
555
+ df_searched = apply_search(filtered_df, search_query)
556
+ st.dataframe(df_searched, use_container_width=True, hide_index=True)
557
 
558
+ # Downloads
559
+ csv_bytes = df_searched.to_csv(index=False).encode("utf-8-sig")
560
+ xlsx_bytes = to_excel_bytes(df_searched)
561
+ dc1, dc2 = st.columns(2)
562
+ with dc1:
563
+ st.download_button(
564
+ "Télécharger CSV",
565
+ data=csv_bytes,
566
+ file_name="inscriptions_filtrees.csv",
567
+ mime="text/csv",
568
+ use_container_width=True,
569
+ )
570
+ with dc2:
571
+ st.download_button(
572
+ "Télécharger Excel",
573
+ data=xlsx_bytes,
574
+ file_name="inscriptions_filtrees.xlsx",
575
+ mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
576
+ use_container_width=True,
577
+ )
578
 
579
+
580
+ # -----------------------------
581
+ # Page: Zone d'analyse
582
+ # -----------------------------
583
+ def page_analyses():
584
+ st.markdown("<h2>📋 Analyses avancées</h2>", unsafe_allow_html=True)
585
+
586
+ if 'filtered_df' not in st.session_state or st.session_state['filtered_df'] is None:
587
+ st.warning("Veuillez d'abord importer et configurer des données dans l'onglet Tableau de bord.")
588
+ return
589
+
590
+ filtered_df = st.session_state['filtered_df']
591
+ type_map = st.session_state.get('logical_types', {})
592
+ theme_mode = "dark" if st.session_state.get('theme_mode') == "dark" else "light"
593
+ plotly_template = get_plotly_template(theme_mode)
594
+
595
  # Ad-hoc analysis builder
596
+ st.markdown("<div class=\"card\"><div class=\"card-title\">Zone d'analyse</div>", unsafe_allow_html=True)
597
  cat_cols = [c for c in filtered_df.columns if type_map.get(c) in ("categorical", "text")]
598
  if cat_cols:
599
  ac1, ac2, ac3 = st.columns([2,1,1])
 
612
  st.plotly_chart(fig, use_container_width=True, theme=None)
613
  st.markdown("</div>", unsafe_allow_html=True)
614
 
 
615
  # Drilldown option (simple): filtrer sur une dimension/valeur
616
+ st.markdown("<div class=\"card\"><div class=\"card-title\">Drilldown</div>", unsafe_allow_html=True)
617
  dd_cols = cat_cols
618
  dd1, dd2 = st.columns([1,2])
619
  with dd1:
620
  dd_dim = st.selectbox("Drilldown - dimension", options=[None] + dd_cols)
621
+
622
+ drill_df = filtered_df.copy()
623
  if dd_dim:
624
  values = [x for x in filtered_df[dd_dim].dropna().astype(str).unique()]
625
  with dd2:
626
  dd_val = st.selectbox("Valeur", options=[None] + values)
627
  if dd_val:
628
+ drill_df = filtered_df[filtered_df[dd_dim].astype(str) == dd_val]
629
+
630
+ search_query = st.text_input("Recherche globale", key="search_analysis")
631
+ df_searched = apply_search(drill_df, search_query)
632
  st.dataframe(df_searched, use_container_width=True, hide_index=True)
633
+ st.markdown("</div>", unsafe_allow_html=True)
634
 
635
+ # Decision Maker View (field-aware, optional)
636
+ st.markdown("<div class=\"card\"><div class=\"card-title\">Vue Décideur (si champs disponibles)</div>", unsafe_allow_html=True)
637
+ # Candidate fields based on provided list
638
+ col_email = find_column(filtered_df, ["Email"]) or find_column(filtered_df, ["E-mail"])
639
+ col_gender = find_column(filtered_df, ["Genre", "Autre genre (Veuillez préciser) : "])
640
+ col_nat = find_column(filtered_df, ["Nationalité"])
641
+ col_country = find_column(filtered_df, ["Pays de résidence"]) or find_column(filtered_df, ["D'où préférez-vous participer à l'événement ?"])
642
+ col_role = find_column(filtered_df, ["Votre profession / statut", "Autre profession (veuillez préciser)"])
643
+ col_aff = find_column(filtered_df, ["Affiliation", "Autre affiliation (Veuillez préciser) : "])
644
+ col_particip = find_column(filtered_df, ["Avez-vous déjà participé à un événement Indaba X Togo ?"])
645
+ col_mode_formation = find_column(filtered_df, ["Comment voulez-vous participer aux formations ?"])
646
+ col_what_do = find_column(filtered_df, ["Que voulez-vous faire ?"])
647
+ col_skills = {
648
+ "Python": find_column(filtered_df, ["Quel est votre niveau en [Python]", "Quel est votre niveau en [Python]"]),
649
+ "Numpy": find_column(filtered_df, ["Quel est votre niveau en [Numpy]", "Quel est votre niveau en [Numpy]"]),
650
+ "Pandas": find_column(filtered_df, ["Quel est votre niveau en [Pandas]", "Quel est votre niveau en [Pandas]"]),
651
+ "Scikit Learn": find_column(filtered_df, ["Quel est votre niveau en [Scikit Learn]", "Quel est votre niveau en [Scikit Learn]"]),
652
+ "Pytorch": find_column(filtered_df, ["Quel est votre niveau en [Pytorch]", "Quel est votre niveau en [Pytorch]"]),
653
+ "Deep Learning": find_column(filtered_df, ["Quel est votre niveau en [Deep Learning]", "Quel est votre niveau en [Deep Learning]"]),
654
+ }
655
+
656
+ # KPIs for decision maker
657
+ kcols = st.columns(4)
658
+ with kcols[0]:
659
+ kpi_card("Inscriptions", f"{len(filtered_df):,}")
660
+ with kcols[1]:
661
+ if col_email:
662
+ uniq_people = filtered_df[col_email].astype(str).str.strip().str.lower().dropna().nunique()
663
+ kpi_card("Personnes uniques (email)", f"{uniq_people:,}")
664
+ else:
665
+ kpi_card("Personnes uniques", "-")
666
+ with kcols[2]:
667
+ if col_country and col_country in filtered_df.columns:
668
+ kpi_card("Pays (distincts)", f"{filtered_df[col_country].astype(str).nunique():,}")
669
+ else:
670
+ kpi_card("Pays (distincts)", "-")
671
+ with kcols[3]:
672
+ if col_role and col_role in filtered_df.columns:
673
+ kpi_card("Profils (distincts)", f"{filtered_df[col_role].astype(str).nunique():,}")
674
+ else:
675
+ kpi_card("Profils (distincts)", "-")
676
+
677
+ # Row 1 charts: Gender, Country
678
+ dm1 = st.columns(2)
679
+ if col_gender and col_gender in filtered_df.columns and not filtered_df.empty:
680
+ gcounts = filtered_df.groupby(col_gender).size().reset_index(name="count").sort_values("count", ascending=False)
681
+ fig_g = px.pie(gcounts, names=col_gender, values="count", template=get_plotly_template(theme_mode), hole=0.35)
682
+ with dm1[0]:
683
+ chart_card("Répartition par genre", fig_g)
684
+ if col_country and col_country in filtered_df.columns and not filtered_df.empty:
685
+ ccounts = filtered_df.groupby(col_country).size().reset_index(name="count").sort_values("count", ascending=False).head(15)
686
+ fig_c = px.bar(ccounts, x=col_country, y="count", template=get_plotly_template(theme_mode))
687
+ with dm1[1]:
688
+ chart_card("Top 15 pays de résidence", fig_c)
689
+
690
+ # Row 2: Participation history and roles
691
+ dm2 = st.columns(2)
692
+ if col_particip and col_particip in filtered_df.columns and not filtered_df.empty:
693
+ pcounts = filtered_df.groupby(col_particip).size().reset_index(name="count")
694
+ fig_p = px.bar(pcounts, x=col_particip, y="count", template=get_plotly_template(theme_mode))
695
+ with dm2[0]:
696
+ chart_card("A déjà participé ?", fig_p)
697
+ if col_role and col_role in filtered_df.columns and not filtered_df.empty:
698
+ rcounts = filtered_df.groupby(col_role).size().reset_index(name="count").sort_values("count", ascending=False).head(15)
699
+ fig_r = px.bar(rcounts, x=col_role, y="count", template=get_plotly_template(theme_mode))
700
+ with dm2[1]:
701
+ chart_card("Professions / Statuts (Top 15)", fig_r)
702
+
703
  st.markdown("</div>", unsafe_allow_html=True)
704
 
705
+
706
+ # -----------------------------
707
+ # Page: Constructeur de graphiques
708
+ # -----------------------------
709
+ def page_constructeur_graphiques():
710
+ st.markdown("<h2>📈 Constructeur de graphiques</h2>", unsafe_allow_html=True)
711
+
712
+ if 'filtered_df' not in st.session_state or st.session_state['filtered_df'] is None:
713
+ st.warning("Veuillez d'abord importer et configurer des données dans l'onglet Tableau de bord.")
714
+ return
715
+
716
+ filtered_df = st.session_state['filtered_df']
717
+ type_map = st.session_state.get('logical_types', {})
718
+ theme_mode = "dark" if st.session_state.get('theme_mode') == "dark" else "light"
719
+ plotly_template = get_plotly_template(theme_mode)
720
+
721
  # Universal Chart Builder
722
  st.markdown("<div class=\"card\"><div class=\"card-title\">Constructeur de graphiques</div>", unsafe_allow_html=True)
723
  chart_types = [
 
817
  st.plotly_chart(fig, use_container_width=True, theme=None)
818
  st.markdown("</div>", unsafe_allow_html=True)
819
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
820
 
821
+ # -----------------------------
822
+ # Page: Envoi d'emails
823
+ # -----------------------------
824
+ def page_emails():
825
+ st.markdown("<h2>✉️ Envoi d'emails</h2>", unsafe_allow_html=True)
826
+
827
+ if 'filtered_df' not in st.session_state or st.session_state['filtered_df'] is None:
828
+ st.warning("Veuillez d'abord importer et configurer des données dans l'onglet Tableau de bord.")
829
+ return
830
+
831
+ filtered_df = st.session_state['filtered_df']
832
+
833
+ # Email Sender Section
834
+ st.markdown("<div class=\"card\"><div class=\"card-title\">✉️ Envoi d'emails (CSV ou données filtrées)</div>", unsafe_allow_html=True)
835
+ ecols1 = st.columns([1, 1])
836
+ with ecols1[0]:
837
+ st.caption("Source des destinataires")
838
+ use_current = st.radio(
839
+ "Choisir la source",
840
+ options=["Données filtrées actuelles", "Importer un CSV/XLSX"],
841
+ horizontal=False,
842
+ index=0,
843
+ key="email_source_choice",
844
+ )
845
+ with ecols1[1]:
846
+ st.caption("Fichier (si import)")
847
+ upload_mail = st.file_uploader("Importer un fichier", type=["csv", "xlsx"], key="email_upload_file")
848
+
849
+ recipients_df: Optional[pd.DataFrame] = None
850
+ if use_current == "Données filtrées actuelles":
851
+ recipients_df = filtered_df.copy()
852
+ else:
853
+ if upload_mail is not None:
854
+ try:
855
+ if upload_mail.name.lower().endswith(".csv"):
856
+ recipients_df = pd.read_csv(upload_mail)
857
+ else:
858
+ recipients_df = pd.read_excel(upload_mail)
859
+ recipients_df.columns = [str(c).strip() for c in recipients_df.columns]
860
+ except Exception as e:
861
+ st.error(f"Erreur de lecture du fichier: {e}")
862
+
863
+ if recipients_df is None or recipients_df.empty:
864
+ st.info("Importez un fichier ou utilisez les données filtrées pour continuer.")
865
+ st.markdown("</div>", unsafe_allow_html=True)
866
+ return
867
 
868
+ # Mapping email column
869
+ email_col_guess = find_column(recipients_df, ["email", "e-mail", "mail"]) or ("Email" if "Email" in recipients_df.columns else None)
870
+ email_col = st.selectbox(
871
+ "Colonne email",
872
+ options=list(recipients_df.columns),
873
+ index=(list(recipients_df.columns).index(email_col_guess) if email_col_guess in recipients_df.columns else 0),
874
+ help="Sélectionnez la colonne contenant les adresses email",
875
+ key="email_col_select",
876
+ )
 
 
 
877
 
878
+ # SMTP settings
879
+ st.markdown("<div class=\"card\" style=\"margin-top: 0.75rem;\"><div class=\"card-title\">Paramètres SMTP</div>", unsafe_allow_html=True)
880
+ s1, s2, s3, s4 = st.columns([1.2, 0.8, 1, 1])
881
+ with s1:
882
+ smtp_host = st.text_input("Hôte SMTP", value=os.environ.get("SMTP_HOST", "smtp.gmail.com"))
883
+ with s2:
884
+ smtp_port = st.number_input("Port", min_value=1, max_value=65535, value=int(os.environ.get("SMTP_PORT", 587)))
885
+ with s3:
886
+ use_tls = st.selectbox("Sécurité", options=["STARTTLS", "SSL"], index=0) == "STARTTLS"
887
+ with s4:
888
+ reply_to = st.text_input("Reply-To (optionnel)", value=os.environ.get("SMTP_REPLY_TO", ""))
889
+ s5, s6 = st.columns([1, 1])
890
+ with s5:
891
+ sender_email = st.text_input("Adresse expéditrice", value=os.environ.get("SMTP_SENDER", ""))
892
+ with s6:
893
+ sender_password = st.text_input("Mot de passe/clé appli", type="password", value=os.environ.get("SMTP_PASSWORD", ""))
894
+ st.markdown("</div>", unsafe_allow_html=True)
895
 
896
+ # Composition
897
+ st.markdown("<div class=\"card\" style=\"margin-top: 0.75rem;\"><div class=\"card-title\">Composer le message</div>", unsafe_allow_html=True)
898
+ placeholders = ", ".join([f"{{{c}}}" for c in recipients_df.columns])
899
+ subj = st.text_input("Objet", placeholder="Objet de l'email. Vous pouvez utiliser des variables comme {Nom}")
900
+ body = st.text_area(
901
+ "Corps (texte)",
902
+ height=180,
903
+ placeholder="Bonjour {Prenom} {Nom},\n\nVotre statut: {Statut}\n...",
904
+ help=f"Variables disponibles: {placeholders}",
905
+ )
906
+ st.caption("Astuce: utilisez {NomColonne} pour insérer des champs du CSV.")
907
+
908
+ # Preview first recipient
909
+ pv1, pv2 = st.columns([1, 1])
910
+ with pv1:
911
+ st.subheader("Aperçu des données (5)")
912
+ st.dataframe(recipients_df.head(5), use_container_width=True, hide_index=True)
913
+ with pv2:
914
+ st.subheader("Aperçu email (1er destinataire)")
915
+ try:
916
+ if not recipients_df.empty:
917
+ row0 = recipients_df.iloc[0].to_dict()
918
+ st.write("À:", recipients_df[email_col].iloc[0])
919
+ st.write("Objet:", safe_format_template(subj, row0))
920
+ st.code(safe_format_template(body, row0))
921
+ except Exception:
922
+ st.caption("Impossible de générer l'aperçu.")
923
+ st.markdown("</div>", unsafe_allow_html=True)
 
 
 
 
 
924
 
925
+ # Sending controls
926
+ st.markdown("<div class=\"card\" style=\"margin-top: 0.75rem;\"><div class=\"card-title\">Envoi</div>", unsafe_allow_html=True)
927
+ c_left, c_mid, c_right = st.columns([1, 1, 1])
928
+ with c_left:
929
+ limit_send = st.number_input("Limiter (0 = tout)", min_value=0, value=0, help="Pour tester, limiter le nombre d'emails envoyés")
930
+ with c_mid:
931
+ start_at = st.number_input("Début à l'index", min_value=0, value=0)
932
+ with c_right:
933
+ confirm = st.checkbox("Je confirme vouloir envoyer ces emails", value=False)
934
+
935
+ do_send = st.button("Envoyer", type="primary", use_container_width=True, disabled=not confirm)
936
+
937
+ if do_send:
938
+ if not sender_email or not smtp_host or not subj or not body:
939
+ st.error("Veuillez remplir l'hôte SMTP, l'adresse expéditrice, l'objet et le corps.")
940
+ else:
941
+ total = len(recipients_df)
942
+ indices = list(range(start_at, total))
943
+ if limit_send and limit_send > 0:
944
+ indices = indices[: int(limit_send)]
945
+ progress = st.progress(0)
946
+ sent_ok = 0
947
+ log_container = st.container()
948
+ for idx_i, i in enumerate(indices, start=1):
949
+ try:
950
+ row = recipients_df.iloc[i]
951
+ to_addr = str(row[email_col]).strip()
952
+ if not to_addr or "@" not in to_addr:
953
+ raise ValueError("Adresse email invalide")
954
+ row_dict = row.to_dict()
955
+ subject_i = safe_format_template(subj, row_dict)
956
+ body_i = safe_format_template(body, row_dict)
957
+ send_email_smtp(
958
+ smtp_host=smtp_host,
959
+ smtp_port=int(smtp_port),
960
+ sender_email=sender_email,
961
+ sender_password=sender_password,
962
+ use_tls=use_tls,
963
+ to_email=to_addr,
964
+ subject=subject_i,
965
+ body_text=body_i,
966
+ reply_to=(reply_to or None),
967
+ )
968
+ sent_ok += 1
969
+ log_container.success(f"Envoyé à {to_addr}")
970
+ except Exception as e:
971
+ log_container.error(f"Échec pour index {i}: {e}")
972
+ progress.progress(int(idx_i * 100 / max(1, len(indices))))
973
+ st.info(f"Terminé. Succès: {sent_ok}/{len(indices)}")
974
  st.markdown("</div>", unsafe_allow_html=True)
975
 
976
 
977
+ # -----------------------------
978
+ # Main App
979
+ # -----------------------------
980
+ def main():
981
+ inject_base_css()
982
 
983
+ # Header
984
+ col_logo, col_title, col_right = st.columns([1, 3, 1])
985
+ with col_logo:
986
+ logo_path = os.path.join("assets", "logo.png")
987
+ if os.path.exists(logo_path):
988
+ st.image(logo_path, width=72)
989
+ with col_title:
990
+ st.markdown("<h1 style='text-align:center; margin-top: 0;'>Tableau de bord des inscriptions</h1>", unsafe_allow_html=True)
991
+ with col_right:
992
+ st.write("")
993
+
994
+ # Charger les contrôles de la barre latérale
995
+ # (ces contrôles sont partagés entre tous les onglets)
996
+ df, type_map, theme_mode, _, unique_keys = sidebar_controls()
997
+
998
+ # Stocker les types dans session_state pour les autres onglets
999
+ if df is not None:
1000
+ st.session_state['logical_types'] = type_map
1001
+ st.session_state['unique_keys'] = unique_keys
1002
+ st.session_state['theme_mode'] = theme_mode
1003
+
1004
+ # Onglets de l'application
1005
+ tab1, tab2, tab3, tab4 = st.tabs([
1006
+ "📊 Tableau de bord",
1007
+ "📋 Analyses avancées",
1008
+ "📈 Constructeur graphiques",
1009
+ "✉️ Envoi emails"
1010
+ ])
1011
+
1012
+ with tab1:
1013
+ page_tableau_de_bord()
1014
+
1015
+ with tab2:
1016
+ page_analyses()
1017
+
1018
+ with tab3:
1019
+ page_constructeur_graphiques()
1020
+
1021
+ with tab4:
1022
+ page_emails()
1023
 
1024
+
1025
+ if __name__ == "__main__":
1026
+ main()