analist commited on
Commit
1e13ecb
·
verified ·
1 Parent(s): 3302bbc

Update src/streamlit_app.py

Browse files
Files changed (1) hide show
  1. src/streamlit_app.py +1023 -37
src/streamlit_app.py CHANGED
@@ -1,40 +1,1026 @@
1
- import altair as alt
2
- import numpy as np
 
 
 
 
 
 
3
  import pandas as pd
 
4
  import streamlit as st
5
 
6
- """
7
- # Welcome to Streamlit!
8
-
9
- Edit `/streamlit_app.py` to customize this app to your heart's desire :heart:.
10
- If you have any questions, checkout our [documentation](https://docs.streamlit.io) and [community
11
- forums](https://discuss.streamlit.io).
12
-
13
- In the meantime, below is an example of what you can do with just a few lines of code:
14
- """
15
-
16
- num_points = st.slider("Number of points in spiral", 1, 10000, 1100)
17
- num_turns = st.slider("Number of turns in spiral", 1, 300, 31)
18
-
19
- indices = np.linspace(0, 1, num_points)
20
- theta = 2 * np.pi * num_turns * indices
21
- radius = indices
22
-
23
- x = radius * np.cos(theta)
24
- y = radius * np.sin(theta)
25
-
26
- df = pd.DataFrame({
27
- "x": x,
28
- "y": y,
29
- "idx": indices,
30
- "rand": np.random.randn(num_points),
31
- })
32
-
33
- st.altair_chart(alt.Chart(df, height=700, width=700)
34
- .mark_point(filled=True)
35
- .encode(
36
- x=alt.X("x", axis=None),
37
- y=alt.Y("y", axis=None),
38
- color=alt.Color("idx", legend=None, scale=alt.Scale()),
39
- size=alt.Size("rand", legend=None, scale=alt.Scale(range=[1, 150])),
40
- ))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ # -----------------------------
16
+ st.set_page_config(
17
+ page_title="Tableau de bord des inscriptions",
18
+ page_icon="🧭",
19
+ layout="wide",
20
+ initial_sidebar_state="expanded",
21
+ )
22
+
23
+ # -----------------------------
24
+ # Utilities
25
+ # -----------------------------
26
+ def try_parse_datetime(series: pd.Series) -> pd.Series:
27
+ """Attempt to parse a pandas Series as datetimes, returning original on failure."""
28
+ if pd.api.types.is_datetime64_any_dtype(series):
29
+ return series
30
+ try:
31
+ parsed = pd.to_datetime(series, errors="coerce")
32
+ if parsed.notna().sum() >= max(3, int(0.2 * len(parsed))):
33
+ return parsed
34
+ except Exception:
35
+ pass
36
+ return series
37
+
38
+
39
+ def make_unique_columns(columns: List[str]) -> List[str]:
40
+ """Ensure column names are unique by appending suffixes (2), (3), ..."""
41
+ seen: Dict[str, int] = {}
42
+ unique_cols: List[str] = []
43
+ for name in columns:
44
+ base = str(name)
45
+ if base not in seen:
46
+ seen[base] = 1
47
+ unique_cols.append(base)
48
+ else:
49
+ seen[base] += 1
50
+ unique_cols.append(f"{base} ({seen[base]})")
51
+ return unique_cols
52
+
53
+
54
+ def normalize_label(text: str) -> str:
55
+ t = str(text).lower().strip()
56
+ t = t.replace("\u00a0", " ").replace(" ", " ")
57
+ t = " ".join(t.split())
58
+ return t
59
+
60
+
61
+ def find_column(df: pd.DataFrame, candidates: List[str]) -> Optional[str]:
62
+ """Return the first matching column by normalized name from candidates."""
63
+ norm_to_col = {normalize_label(c): c for c in df.columns}
64
+ for cand in candidates:
65
+ n = normalize_label(cand)
66
+ if n in norm_to_col:
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] = {}
74
+ for col in df.columns:
75
+ s = df[col]
76
+ if pd.api.types.is_datetime64_any_dtype(s):
77
+ type_map[col] = "date"
78
+ elif pd.api.types.is_bool_dtype(s):
79
+ type_map[col] = "categorical"
80
+ elif pd.api.types.is_numeric_dtype(s):
81
+ type_map[col] = "numeric"
82
+ else:
83
+ # try parse datetime heuristic
84
+ parsed = try_parse_datetime(s)
85
+ if pd.api.types.is_datetime64_any_dtype(parsed):
86
+ type_map[col] = "date"
87
+ else:
88
+ # if low cardinality, treat as categorical
89
+ nunique = s.astype(str).nunique(dropna=True)
90
+ type_map[col] = "categorical" if nunique <= max(50, len(s) * 0.05) else "text"
91
+ return type_map
92
+
93
+
94
+ def dynamic_filters(df: pd.DataFrame, type_map: Dict[str, str]) -> pd.DataFrame:
95
+ """Render dynamic filters for all columns and return the filtered DataFrame."""
96
+ filtered = df.copy()
97
+ st.sidebar.markdown("### 🔎 Filtres dynamiques")
98
+ for col in filtered.columns:
99
+ logical = type_map.get(col, "text")
100
+ if logical == "numeric" and pd.api.types.is_numeric_dtype(filtered[col]):
101
+ series_num = pd.to_numeric(filtered[col], errors="coerce")
102
+ valid = series_num.dropna()
103
+ if valid.empty:
104
+ st.sidebar.caption(f"{col}: aucune valeur numérique exploitable")
105
+ continue
106
+ min_v = float(valid.min())
107
+ max_v = float(valid.max())
108
+ if min_v == max_v:
109
+ st.sidebar.caption(f"{col}: valeur unique {min_v}")
110
+ # Filtrage inutile car une seule valeur
111
+ continue
112
+ vmin, vmax = st.sidebar.slider(f"{col} (min-max)", min_value=min_v, max_value=max_v, value=(min_v, max_v))
113
+ filtered = filtered[(series_num >= vmin) & (series_num <= vmax)]
114
+ elif logical == "date":
115
+ parsed = try_parse_datetime(filtered[col])
116
+ if pd.api.types.is_datetime64_any_dtype(parsed):
117
+ dmin = parsed.min()
118
+ dmax = parsed.max()
119
+ start_end = st.sidebar.date_input(f"{col} (période)", value=(dmin.date() if pd.notna(dmin) else date.today(), dmax.date() if pd.notna(dmax) else date.today()))
120
+ if isinstance(start_end, tuple) and len(start_end) == 2:
121
+ start, end = start_end
122
+ mask = (parsed.dt.date >= start) & (parsed.dt.date <= end)
123
+ filtered = filtered[mask]
124
+ else:
125
+ # categorical or text -> multiselect of unique values (with limit)
126
+ uniques = filtered[col].dropna().astype(str).unique().tolist()
127
+ uniques = sorted(uniques)[:200]
128
+ selected = st.sidebar.multiselect(f"{col}", options=uniques, default=[])
129
+ if selected:
130
+ filtered = filtered[filtered[col].astype(str).isin(selected)]
131
+ return filtered
132
+
133
+
134
+ def apply_search(df: pd.DataFrame, query: str) -> pd.DataFrame:
135
+ if not query:
136
+ return df
137
+ q = query.strip().lower()
138
+ mask = pd.Series(False, index=df.index)
139
+ for col in df.columns:
140
+ col_values = df[col].astype(str).str.lower()
141
+ mask = mask | col_values.str.contains(q, na=False)
142
+ return df[mask]
143
+
144
+
145
+ def to_excel_bytes(df: pd.DataFrame) -> bytes:
146
+ buffer = io.BytesIO()
147
+ with pd.ExcelWriter(buffer, engine="xlsxwriter") as writer:
148
+ df.to_excel(writer, index=False, sheet_name="inscriptions")
149
+ return buffer.getvalue()
150
+
151
+
152
+ def kpi_card(label: str, value: str):
153
+ st.markdown(
154
+ f"""
155
+ <div class="card kpi">
156
+ <div class="card-label">{label}</div>
157
+ <div class="card-value">{value}</div>
158
+ </div>
159
+ """,
160
+ unsafe_allow_html=True,
161
+ )
162
+
163
+
164
+ def chart_card(title: str, fig):
165
+ st.markdown(f"<div class=\"card\"><div class=\"card-title\">{title}</div>", unsafe_allow_html=True)
166
+ st.plotly_chart(fig, use_container_width=True, theme=None)
167
+ st.markdown("</div>", unsafe_allow_html=True)
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 = {
262
+ "light": {
263
+ "--bg": "#f7f9fc",
264
+ "--card": "#ffffff",
265
+ "--text": "#0f172a",
266
+ "--muted": "#64748b",
267
+ "--primary": "#0ea5e9",
268
+ "--accent": "#10b981",
269
+ "--border": "#e5e7eb",
270
+ },
271
+ "dark": {
272
+ "--bg": "#0b1220",
273
+ "--card": "#111827",
274
+ "--text": "#e5e7eb",
275
+ "--muted": "#94a3b8",
276
+ "--primary": "#38bdf8",
277
+ "--accent": "#34d399",
278
+ "--border": "#1f2937",
279
+ },
280
+ }
281
+ colors = palette.get(mode, palette["light"])
282
+ styles = ":root{" + ";".join([f"{k}:{v}" for k, v in colors.items()]) + "}"
283
+ st.markdown(f"<style>{styles}</style>", unsafe_allow_html=True)
284
+
285
+
286
+ def get_plotly_template(mode: str) -> str:
287
+ return "plotly_dark" if mode == "dark" else "plotly_white"
288
+
289
+
290
+ # -----------------------------
291
+ # Sidebar: Logo, Upload, Theme, Column mapping
292
+ # -----------------------------
293
+ def sidebar_controls() -> Tuple[Optional[pd.DataFrame], Dict[str, str], str, Dict[str, str], List[str]]:
294
+ st.sidebar.markdown("## ⚙️ Contrôles")
295
+
296
+ # Theme
297
+ mode = st.sidebar.radio("Thème", options=["clair", "sombre"], horizontal=True, index=0)
298
+ theme_mode = "dark" if mode == "sombre" else "light"
299
+ set_theme_variables(theme_mode)
300
+
301
+ # Logo (optional)
302
+ logo_path = os.path.join("assets", "logo.png")
303
+ if os.path.exists(logo_path):
304
+ st.sidebar.image(logo_path, use_column_width=True)
305
+
306
+ uploaded = st.sidebar.file_uploader("Importer un fichier Excel (.xlsx)", type=["xlsx"])
307
+
308
+ df: Optional[pd.DataFrame] = None
309
+ if uploaded is not None:
310
+ try:
311
+ # Read first sheet by default
312
+ df = pd.read_excel(uploaded, sheet_name=0)
313
+ # Strip column names
314
+ df.columns = [str(c).strip() for c in df.columns]
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] = {}
331
+ unique_keys: List[str] = []
332
+ if df is not None and not df.empty:
333
+ st.sidebar.markdown("---")
334
+ st.sidebar.markdown("### 🧹 Nettoyage & types")
335
+ # Global cleaning options
336
+ trim_spaces = st.sidebar.checkbox("Supprimer les espaces autour du texte", value=True)
337
+ lower_case = st.sidebar.checkbox("Mettre le texte en minuscules", value=False)
338
+ drop_dupes = st.sidebar.checkbox("Supprimer les doublons", value=False)
339
+ dedup_subset_cols: List[str] = []
340
+ dedup_keep_choice = "first"
341
+ if drop_dupes:
342
+ dedup_subset_cols = st.sidebar.multiselect(
343
+ "Colonnes à considérer (vide = toutes)", options=list(df.columns), help="Sélectionnez les colonnes sur lesquelles détecter les doublons."
344
+ )
345
+ dedup_keep_choice = st.sidebar.selectbox(
346
+ "Conserver",
347
+ options=["first", "last", "none"],
348
+ index=0,
349
+ help="Quelle occurrence conserver pour chaque doublon détecté",
350
+ )
351
+ fillna_blank = st.sidebar.checkbox("Remplacer NaN texte par vide", value=True)
352
+
353
+ # Remove selected columns
354
+ drop_columns = st.sidebar.multiselect(
355
+ "Enlever des colonnes",
356
+ options=list(df.columns),
357
+ default=[],
358
+ help="Supprimer des champs du jeu de données avant l'analyse",
359
+ key="clean_drop_cols",
360
+ )
361
+ if drop_columns:
362
+ df.drop(columns=drop_columns, inplace=True, errors="ignore")
363
+
364
+ # Infer and allow override per column
365
+ inferred = infer_pandas_types(df)
366
+ for col in df.columns:
367
+ logical_types[col] = st.sidebar.selectbox(
368
+ f"Type pour {col}", options=["categorical", "numeric", "date", "text"], index=["categorical", "numeric", "date", "text"].index(inferred.get(col, "text"))
369
+ )
370
+ # Optional coercion
371
+ if logical_types[col] in ("numeric", "date"):
372
+ coercions[col] = logical_types[col]
373
+
374
+ # Apply cleaning
375
+ for col in df.columns:
376
+ if df[col].dtype == object:
377
+ if trim_spaces:
378
+ df[col] = df[col].astype(str).str.strip()
379
+ if lower_case:
380
+ df[col] = df[col].astype(str).str.lower()
381
+ if fillna_blank:
382
+ df[col] = df[col].replace({pd.NA: "", None: ""})
383
+ # Coerce types
384
+ if coercions.get(col) == "numeric":
385
+ df[col] = pd.to_numeric(df[col], errors="coerce")
386
+ elif coercions.get(col) == "date":
387
+ df[col] = try_parse_datetime(df[col])
388
+
389
+ if drop_dupes:
390
+ keep_arg = None if dedup_keep_choice == "none" else dedup_keep_choice
391
+ df.drop_duplicates(subset=(dedup_subset_cols if dedup_subset_cols else None), keep=keep_arg, inplace=True)
392
+
393
+ # Unique person keys
394
+ st.sidebar.markdown("---")
395
+ st.sidebar.markdown("### 👤 Personne unique")
396
+ # Heuristic suggestions
397
+ hints = ["email", "e-mail", "mail", "id", "identifiant", "cin", "passport", "matricule", "phone", "téléphone", "telephone", "tel"]
398
+ suggested = [c for c in df.columns if any(h in c.lower() for h in hints)]
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">
421
+ <div class="card-title">Bienvenue 👋</div>
422
+ <p>Importez un fichier <b>.xlsx</b> contenant vos inscriptions pour commencer l'analyse.</p>
423
+ <ul>
424
+ <li>Assurez-vous que les colonnes principales (pays, formation, statut, date) sont présentes.</li>
425
+ <li>Vous pourrez mapper les colonnes dans la barre latérale.</li>
426
+ </ul>
427
+ </div>
428
+ """,
429
+ unsafe_allow_html=True,
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")
448
+ if person_filter:
449
+ try:
450
+ filtered_df = filtered_df.drop_duplicates(subset=unique_keys, keep=keep_strategy)
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]
460
+ total_missing = int(filtered_df.isna().sum().sum())
461
+ approx_dupes = int(filtered_df.duplicated().sum())
462
+
463
+ c1, c2, c3, c4 = st.columns(4)
464
+ with c1:
465
+ kpi_card("Lignes", f"{total_count:,}")
466
+ with c2:
467
+ kpi_card("Colonnes", f"{total_columns:,}")
468
+ with c3:
469
+ kpi_card("Valeurs manquantes", f"{total_missing:,}")
470
+ with c4:
471
+ kpi_card("Doublons (approx)", f"{approx_dupes:,}")
472
+
473
+ # Unique persons KPI (based on selected keys)
474
+ if unique_keys:
475
+ try:
476
+ uniq = (
477
+ filtered_df.dropna(subset=unique_keys)[unique_keys]
478
+ .astype(str)
479
+ .drop_duplicates()
480
+ .shape[0]
481
+ )
482
+ except Exception:
483
+ uniq = 0
484
+ c5, _ = st.columns([1, 3])
485
+ with c5:
486
+ kpi_card("Personnes uniques", f"{uniq:,}")
487
+
488
+ # Charts row 1: Program distribution, Country distribution
489
+ st.markdown("<div class=\"card\"><div class=\"card-title\">Répartitions clés</div>", unsafe_allow_html=True)
490
+ ctrl1, ctrl2, ctrl3 = st.columns([1,1,2])
491
+ with ctrl1:
492
+ topn = st.slider("Top N", min_value=3, max_value=50, value=10, step=1)
493
+ with ctrl2:
494
+ sort_dir = st.selectbox("Tri", options=["desc", "asc"], index=0)
495
+ with ctrl3:
496
+ st.caption("Appliqué aux graphiques de répartition ci-dessous")
497
+ charts_row_1 = st.columns(2)
498
+ # Choose any categorical column for distribution 1
499
+ cat_cols_all = [c for c in filtered_df.columns if type_map.get(c) in ("categorical", "text")]
500
+ if cat_cols_all and not filtered_df.empty:
501
+ dim1 = st.selectbox("Dimension 1 (répartition)", options=cat_cols_all, key="rep_dim1")
502
+ program_counts = (
503
+ filtered_df.groupby(dim1).size().reset_index(name="count").sort_values("count", ascending=(sort_dir=="asc"))
504
+ .head(topn)
505
+ )
506
+ fig_prog = px.bar(
507
+ program_counts,
508
+ x=dim1,
509
+ y="count",
510
+ template=plotly_template,
511
+ color_continuous_scale="Blues",
512
+ )
513
+ fig_prog.update_layout(margin=dict(l=10, r=10, t=10, b=10))
514
+ with charts_row_1[0]:
515
+ chart_card("Répartition (dimension 1)", fig_prog)
516
+
517
+ if cat_cols_all and not filtered_df.empty:
518
+ dim2 = st.selectbox("Dimension 2 (répartition)", options=[c for c in cat_cols_all], index=min(1, len(cat_cols_all)-1), key="rep_dim2")
519
+ country_counts = (
520
+ filtered_df.groupby(dim2).size().reset_index(name="count").sort_values("count", ascending=(sort_dir=="asc"))
521
+ .head(topn)
522
+ )
523
+ fig_country = px.pie(
524
+ country_counts,
525
+ names=dim2,
526
+ values="count",
527
+ template=plotly_template,
528
+ hole=0.35,
529
+ )
530
+ fig_country.update_layout(margin=dict(l=10, r=10, t=10, b=10))
531
+ with charts_row_1[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")
539
+ status_counts = (
540
+ filtered_df.groupby(dim3).size().reset_index(name="count").sort_values("count", ascending=False)
541
+ )
542
+ fig_status = px.bar(
543
+ status_counts,
544
+ x=dim3,
545
+ y="count",
546
+ template=plotly_template,
547
+ color=dim3,
548
+ )
549
+ fig_status.update_layout(showlegend=False, margin=dict(l=10, r=10, t=10, b=10))
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])
600
+ with ac1:
601
+ dim_col = st.selectbox("Dimension", options=cat_cols)
602
+ with ac2:
603
+ chart_type = st.selectbox("Type de graphique", options=["Barres", "Camembert"], index=0)
604
+ with ac3:
605
+ topn_dim = st.slider("Top N (dimension)", 3, 50, 10)
606
+
607
+ agg = filtered_df.groupby(dim_col).size().reset_index(name="count").sort_values("count", ascending=False).head(topn_dim)
608
+ if chart_type == "Barres":
609
+ fig = px.bar(agg, x=dim_col, y="count", template=plotly_template)
610
+ else:
611
+ fig = px.pie(agg, names=dim_col, values="count", template=plotly_template, hole=0.35)
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 = [
724
+ "Barres",
725
+ "Barres empilées",
726
+ "Lignes",
727
+ "Aires",
728
+ "Camembert",
729
+ "Histogramme",
730
+ "Nuage de points",
731
+ "Boîte (Box)",
732
+ "Violon",
733
+ ]
734
+ cA, cB, cC = st.columns([1.2, 1, 1])
735
+ with cA:
736
+ chosen_chart = st.selectbox("Type de graphique", options=chart_types, key="ub_chart_type")
737
+ with cB:
738
+ agg_choice = st.selectbox("Agrégat", options=["count", "sum", "mean", "median", "min", "max"], index=0, key="ub_agg")
739
+ with cC:
740
+ topn_builder = st.number_input("Top N (optionnel)", min_value=0, value=0, step=1, help="0 pour désactiver")
741
+
742
+ all_cols = list(filtered_df.columns)
743
+ num_cols = [c for c in all_cols if pd.api.types.is_numeric_dtype(filtered_df[c])]
744
+ date_cols_any = [c for c in all_cols if pd.api.types.is_datetime64_any_dtype(try_parse_datetime(filtered_df[c]))]
745
+ cat_cols_any = [c for c in all_cols if c not in num_cols]
746
+
747
+ def aggregate_df(df_src: pd.DataFrame, x_col: Optional[str], y_col: Optional[str], color_col: Optional[str]) -> pd.DataFrame:
748
+ if agg_choice == "count":
749
+ if x_col is not None and y_col is None:
750
+ return df_src.groupby([x_col, color_col] if color_col else [x_col]).size().reset_index(name="value")
751
+ elif x_col is None and y_col is not None:
752
+ return df_src.groupby([y_col, color_col] if color_col else [y_col]).size().reset_index(name="value")
753
+ elif x_col is not None and y_col is not None:
754
+ return df_src.groupby([x_col, y_col]).size().reset_index(name="value")
755
+ else:
756
+ return pd.DataFrame({"value": [len(df_src)]})
757
+ else:
758
+ agg_func = agg_choice
759
+ measure = y_col if (y_col in num_cols) else (x_col if (x_col in num_cols) else (num_cols[0] if num_cols else None))
760
+ if measure is None:
761
+ return df_src.groupby([x_col, color_col] if color_col else [x_col]).size().reset_index(name="value") if x_col else pd.DataFrame({"value": [len(df_src)]})
762
+ group_keys = [k for k in [x_col, color_col] if k]
763
+ out = df_src.groupby(group_keys, dropna=False)[measure].agg(agg_func).reset_index(name="value")
764
+ return out
765
+
766
+ if chosen_chart in ("Barres", "Barres empilées"):
767
+ x = st.selectbox("Axe X (cat/date)", options=cat_cols_any, key="ub_bar_x")
768
+ color = st.selectbox("Couleur (optionnel)", options=[None] + cat_cols_any, key="ub_bar_color")
769
+ measure = st.selectbox("Mesure (numérique ou count)", options=["(count)"] + num_cols, key="ub_bar_measure")
770
+ data = aggregate_df(filtered_df, x, None if measure == "(count)" else measure, color)
771
+ if topn_builder and topn_builder > 0 and x in data.columns:
772
+ data = data.sort_values("value", ascending=False).groupby(x).head(1).head(int(topn_builder))
773
+ if chosen_chart == "Barres":
774
+ fig = px.bar(data, x=x, y="value", color=color, template=plotly_template, barmode="group")
775
+ else:
776
+ fig = px.bar(data, x=x, y="value", color=color, template=plotly_template, barmode="relative")
777
+ st.plotly_chart(fig, use_container_width=True, theme=None)
778
+ elif chosen_chart in ("Lignes", "Aires"):
779
+ x = st.selectbox("Axe X (date recommandé)", options=date_cols_any or cat_cols_any, key="ub_line_x")
780
+ color = st.selectbox("Couleur (optionnel)", options=[None] + cat_cols_any, key="ub_line_color")
781
+ measure = st.selectbox("Mesure (numérique ou count)", options=["(count)"] + num_cols, key="ub_line_measure")
782
+ data = aggregate_df(filtered_df, x, None if measure == "(count)" else measure, color)
783
+ if chosen_chart == "Lignes":
784
+ fig = px.line(data, x=x, y="value", color=color, template=plotly_template)
785
+ else:
786
+ fig = px.area(data, x=x, y="value", color=color, template=plotly_template)
787
+ st.plotly_chart(fig, use_container_width=True, theme=None)
788
+ elif chosen_chart == "Camembert":
789
+ names = st.selectbox("Noms (catégorie)", options=cat_cols_any, key="ub_pie_names")
790
+ measure = st.selectbox("Mesure (numérique ou count)", options=["(count)"] + num_cols, key="ub_pie_measure")
791
+ if measure == "(count)":
792
+ data = filtered_df.groupby(names).size().reset_index(name="value")
793
+ else:
794
+ data = filtered_df.groupby(names)[measure].sum().reset_index(name="value")
795
+ fig = px.pie(data, names=names, values="value", template=plotly_template, hole=0.35)
796
+ st.plotly_chart(fig, use_container_width=True, theme=None)
797
+ elif chosen_chart == "Histogramme":
798
+ x = st.selectbox("Colonne numérique", options=num_cols, key="ub_hist_x")
799
+ bins = st.slider("Nb de bacs (bins)", 5, 100, 30)
800
+ fig = px.histogram(filtered_df, x=x, nbins=bins, template=plotly_template)
801
+ st.plotly_chart(fig, use_container_width=True, theme=None)
802
+ elif chosen_chart == "Nuage de points":
803
+ x = st.selectbox("X (numérique)", options=num_cols, key="ub_scatter_x")
804
+ y = st.selectbox("Y (numérique)", options=[c for c in num_cols if c != x], key="ub_scatter_y")
805
+ color = st.selectbox("Couleur (optionnel)", options=[None] + cat_cols_any, key="ub_scatter_color")
806
+ fig = px.scatter(filtered_df, x=x, y=y, color=color, template=plotly_template)
807
+ st.plotly_chart(fig, use_container_width=True, theme=None)
808
+ elif chosen_chart == "Boîte (Box)":
809
+ y = st.selectbox("Y (numérique)", options=num_cols, key="ub_box_y")
810
+ x = st.selectbox("X (catégorie optionnel)", options=[None] + cat_cols_any, key="ub_box_x")
811
+ fig = px.box(filtered_df, x=x, y=y, template=plotly_template)
812
+ st.plotly_chart(fig, use_container_width=True, theme=None)
813
+ elif chosen_chart == "Violon":
814
+ y = st.selectbox("Y (numérique)", options=num_cols, key="ub_violin_y")
815
+ x = st.selectbox("X (catégorie optionnel)", options=[None] + cat_cols_any, key="ub_violin_x")
816
+ fig = px.violin(filtered_df, x=x, y=y, template=plotly_template, box=True, points="outliers")
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()