Spaces:
Sleeping
Sleeping
Gouzi Mohaled
commited on
Commit
·
fe4792e
1
Parent(s):
8446485
Ajoute des fichiers et sous-dossiers supplémentaires
Browse files- .dockerignore +5 -0
- .streamlit/config.toml +2 -0
- .vscode/extensions.json +5 -0
- .vscode/settings.json +4 -0
- admin_page.py +875 -0
- assets/logo.jfif +0 -0
- assets/logo.png +0 -0
- assets/logo.svg +29 -0
- assets/logo2.svg +29 -0
- docker-compose.yml +119 -0
- pages/Verify_email.py +86 -0
- pages/profile.py +260 -0
- pages/reset_password.py +67 -0
- utils/admin.py +216 -0
- utils/async_flowise.py +198 -0
- utils/auth_cache.py +277 -0
- utils/cache_manager.py +67 -0
- utils/circuit_breaker.py +74 -0
- utils/email_utils.py +100 -0
- utils/ip_utils.py +19 -0
- utils/logging_utils.py +14 -0
- utils/notification_handler.py +84 -0
- utils/password_reset.py +159 -0
- utils/rate_limiter.py +139 -0
- utils/theme_utils.py +558 -0
.dockerignore
ADDED
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
__pycache__
|
2 |
+
*.pyc
|
3 |
+
.env
|
4 |
+
venv/
|
5 |
+
.git/
|
.streamlit/config.toml
ADDED
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
1 |
+
[client]
|
2 |
+
showSidebarNavigation = false
|
.vscode/extensions.json
ADDED
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
{
|
2 |
+
"recommendations": [
|
3 |
+
"kodu-ai.claude-dev-experimental"
|
4 |
+
]
|
5 |
+
}
|
.vscode/settings.json
ADDED
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
1 |
+
{
|
2 |
+
"arena.codePrivacySettings": "Private",
|
3 |
+
"arena.maxOutputLines": 50
|
4 |
+
}
|
admin_page.py
ADDED
@@ -0,0 +1,875 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import streamlit as st
|
2 |
+
from typing import Dict, List, Optional, Tuple, Any, Callable, Awaitable
|
3 |
+
import pandas as pd
|
4 |
+
from datetime import datetime
|
5 |
+
import pytz
|
6 |
+
import re
|
7 |
+
import json
|
8 |
+
import asyncio
|
9 |
+
import os
|
10 |
+
from dotenv import load_dotenv
|
11 |
+
from utils.admin import (
|
12 |
+
get_all_users,
|
13 |
+
toggle_user_status,
|
14 |
+
change_user_role,
|
15 |
+
get_user_stats,
|
16 |
+
update_user_info,
|
17 |
+
get_user_permissions,
|
18 |
+
update_user_permissions,
|
19 |
+
supabase
|
20 |
+
)
|
21 |
+
from utils.theme_utils import apply_tooltip_and_button_styles
|
22 |
+
from utils.async_flowise import AsyncFlowiseClient
|
23 |
+
|
24 |
+
class FlowiseStreamingClient:
|
25 |
+
def __init__(self):
|
26 |
+
"""
|
27 |
+
Initialise le client de streaming pour les assistants Flowise
|
28 |
+
"""
|
29 |
+
self.flowise_client = AsyncFlowiseClient()
|
30 |
+
self.assistant_types = list(self.flowise_client.api_urls.keys())
|
31 |
+
|
32 |
+
async def get_service_health(self) -> dict:
|
33 |
+
"""
|
34 |
+
Vérifie la santé des services Flowise
|
35 |
+
Returns:
|
36 |
+
dict: Un dictionnaire avec le statut de chaque assistant
|
37 |
+
"""
|
38 |
+
return await self.flowise_client.check_health()
|
39 |
+
|
40 |
+
async def stream_assistant_response(
|
41 |
+
self,
|
42 |
+
question: str,
|
43 |
+
assistant_type: str
|
44 |
+
) -> dict:
|
45 |
+
"""
|
46 |
+
Génère une réponse en streaming d'un assistant Flowise
|
47 |
+
Args:
|
48 |
+
question (str): La question à poser
|
49 |
+
assistant_type (str): Le type d'assistant à utiliser
|
50 |
+
Returns:
|
51 |
+
dict: Réponse finale de l'assistant
|
52 |
+
"""
|
53 |
+
# Vérifier si l'assistant type est valide
|
54 |
+
if assistant_type not in self.assistant_types:
|
55 |
+
return {"error": f"Type d'assistant non valide : {assistant_type}"}
|
56 |
+
full_response = ""
|
57 |
+
error = None
|
58 |
+
try:
|
59 |
+
response_stream = self.flowise_client.query_assistant_stream(question, assistant_type)
|
60 |
+
async for chunk in response_stream:
|
61 |
+
try:
|
62 |
+
chunk_data = json.loads(chunk)
|
63 |
+
if 'error' in chunk_data:
|
64 |
+
error = chunk_data['error']
|
65 |
+
break
|
66 |
+
if 'data' in chunk_data:
|
67 |
+
# Extraire la partie de la réponse qui est nouvelle
|
68 |
+
new_part = chunk_data['data'][len(full_response):]
|
69 |
+
full_response = chunk_data['data']
|
70 |
+
# Ajouter le nouveau morceau de réponse à l'historique du chat
|
71 |
+
add_message("assistant", new_part)
|
72 |
+
st.rerun() # Mettre à jour l'interface utilisateur
|
73 |
+
except json.JSONDecodeError:
|
74 |
+
error = "Erreur de décodage du chunk de réponse"
|
75 |
+
break
|
76 |
+
except Exception as e:
|
77 |
+
error = str(e)
|
78 |
+
# Fermer le client si nécessaire
|
79 |
+
await self.flowise_client.close()
|
80 |
+
# Retourner la réponse complète ou l'erreur
|
81 |
+
if error:
|
82 |
+
return {"error": error}
|
83 |
+
return {"answer": full_response}
|
84 |
+
|
85 |
+
def get_available_assistants(self) -> List[str]:
|
86 |
+
"""
|
87 |
+
Récupère la liste des assistants disponibles
|
88 |
+
Returns:
|
89 |
+
List[str]: Liste des types d'assistants
|
90 |
+
"""
|
91 |
+
return self.assistant_types
|
92 |
+
|
93 |
+
# Fonction d'exemple pour afficher le streaming dans la console
|
94 |
+
async def console_stream_callback(chunk: str):
|
95 |
+
"""
|
96 |
+
Callback par défaut pour afficher le streaming dans la console
|
97 |
+
Args:
|
98 |
+
chunk (str): Le nouveau morceau de réponse
|
99 |
+
"""
|
100 |
+
print(chunk, end='', flush=True)
|
101 |
+
|
102 |
+
def render_user_permissions(user_id: int):
|
103 |
+
"""
|
104 |
+
Affiche et gère les permissions d'accès aux assistants pour un utilisateur.
|
105 |
+
"""
|
106 |
+
current_permissions = get_user_permissions(user_id)
|
107 |
+
col1, col2 = st.columns(2)
|
108 |
+
|
109 |
+
with col1:
|
110 |
+
st.write("##### Colibri Vitalité ❣️")
|
111 |
+
is_sante_authorized = current_permissions.get('insuranceSANTE', False)
|
112 |
+
new_sante_state = st.toggle(
|
113 |
+
"Autoriser l'accès",
|
114 |
+
value=is_sante_authorized,
|
115 |
+
key=f"perm_{user_id}_sante"
|
116 |
+
)
|
117 |
+
if new_sante_state != is_sante_authorized:
|
118 |
+
success, msg = update_user_permissions(user_id, 'insuranceSANTE', new_sante_state)
|
119 |
+
if success:
|
120 |
+
st.success("✅ Accès mis à jour")
|
121 |
+
else:
|
122 |
+
st.error(f"❌ Erreur: {msg}")
|
123 |
+
|
124 |
+
st.write("##### Colibri Carburant 🚘")
|
125 |
+
is_car_authorized = current_permissions.get('insuranceCAR', False)
|
126 |
+
new_car_state = st.toggle(
|
127 |
+
"Autoriser l'accès",
|
128 |
+
value=is_car_authorized,
|
129 |
+
key=f"perm_{user_id}_car"
|
130 |
+
)
|
131 |
+
if new_car_state != is_car_authorized:
|
132 |
+
success, msg = update_user_permissions(user_id, 'insuranceCAR', new_car_state)
|
133 |
+
if success:
|
134 |
+
st.success("✅ Accès mis à jour")
|
135 |
+
else:
|
136 |
+
st.error(f"❌ Erreur: {msg}")
|
137 |
+
|
138 |
+
st.write("##### Colibri Batisseur 🏠")
|
139 |
+
is_btp_authorized = current_permissions.get('insuranceBTP', False)
|
140 |
+
new_btp_state = st.toggle(
|
141 |
+
"Autoriser l'accès",
|
142 |
+
value=is_btp_authorized,
|
143 |
+
key=f"perm_{user_id}_btp"
|
144 |
+
)
|
145 |
+
if new_btp_state != is_btp_authorized:
|
146 |
+
success, msg = update_user_permissions(user_id, 'insuranceBTP', new_btp_state)
|
147 |
+
if success:
|
148 |
+
st.success("✅ Accès mis à jour")
|
149 |
+
else:
|
150 |
+
st.error(f"❌ Erreur: {msg}")
|
151 |
+
|
152 |
+
with col2:
|
153 |
+
st.write("##### Colibri Equipage 🪪")
|
154 |
+
is_RH_authorized = current_permissions.get('RH', False)
|
155 |
+
new_RH_state = st.toggle(
|
156 |
+
"Autoriser l'accès",
|
157 |
+
value=is_RH_authorized,
|
158 |
+
key=f"perm_{user_id}_RH"
|
159 |
+
)
|
160 |
+
if new_RH_state != is_RH_authorized:
|
161 |
+
success, msg = update_user_permissions(user_id, 'RH', new_RH_state)
|
162 |
+
if success:
|
163 |
+
st.success("✅ Accès mis à jour")
|
164 |
+
else:
|
165 |
+
st.error(f"❌ Erreur: {msg}")
|
166 |
+
|
167 |
+
st.write("##### Colibri Tatillon 👨🏻✈️")
|
168 |
+
is_Pilotage_authorized = current_permissions.get('Pilotage', False)
|
169 |
+
new_Pilotage_state = st.toggle(
|
170 |
+
"Autoriser l'accès",
|
171 |
+
value=is_Pilotage_authorized,
|
172 |
+
help="Cet assistant permet de consulter et modifier les données de la base de données. Il est réservé aux administrateurs.",
|
173 |
+
key=f"perm_{user_id}_Pilotage"
|
174 |
+
)
|
175 |
+
if new_Pilotage_state != is_Pilotage_authorized:
|
176 |
+
success, msg = update_user_permissions(user_id, 'Pilotage', new_Pilotage_state)
|
177 |
+
if success:
|
178 |
+
st.success("✅ Accès mis à jour")
|
179 |
+
else:
|
180 |
+
st.error(f"❌ Erreur: {msg}")
|
181 |
+
|
182 |
+
def init_chat_history():
|
183 |
+
"""Initialise l'historique du chat s'il n'existe pas"""
|
184 |
+
if "chat_history" not in st.session_state:
|
185 |
+
st.session_state.chat_history = []
|
186 |
+
if "is_processing" not in st.session_state:
|
187 |
+
st.session_state.is_processing = False
|
188 |
+
if "message_feedback" not in st.session_state:
|
189 |
+
st.session_state.message_feedback = {}
|
190 |
+
|
191 |
+
def clean_message_content(content: str) -> str:
|
192 |
+
"""Nettoie le contenu du message des métadonnées JSON"""
|
193 |
+
try:
|
194 |
+
# Si le contenu est au format JSON
|
195 |
+
if content.strip().startswith('{'):
|
196 |
+
data = json.loads(content)
|
197 |
+
# Si c'est un message utilisateur, extraire la requête
|
198 |
+
if 'next_inputs' in data and 'query' in data['next_inputs']:
|
199 |
+
return data['next_inputs']['query']
|
200 |
+
# Si c'est une réponse de l'assistant
|
201 |
+
if 'data' in data:
|
202 |
+
return data['data']
|
203 |
+
return content
|
204 |
+
except:
|
205 |
+
return content
|
206 |
+
|
207 |
+
def add_message(role: str, content: str):
|
208 |
+
"""Ajoute un message à l'historique du chat"""
|
209 |
+
message_id = str(len(st.session_state.chat_history))
|
210 |
+
cleaned_content = clean_message_content(content)
|
211 |
+
st.session_state.chat_history.append({
|
212 |
+
"role": role,
|
213 |
+
"content": cleaned_content,
|
214 |
+
"message_id": message_id
|
215 |
+
})
|
216 |
+
|
217 |
+
def update_message_feedback(message_id: str, feedback: str):
|
218 |
+
"""Met à jour le feedback d'un message"""
|
219 |
+
st.session_state.message_feedback[message_id] = feedback
|
220 |
+
|
221 |
+
def display_chat():
|
222 |
+
"""Affiche l'historique du chat avec un style cohérent"""
|
223 |
+
for message in st.session_state.chat_history:
|
224 |
+
with st.chat_message(message["role"]):
|
225 |
+
st.markdown(message["content"])
|
226 |
+
|
227 |
+
if message["role"] == "assistant":
|
228 |
+
message_id = message.get("message_id", "")
|
229 |
+
col1, col2, col3 = st.columns([1, 1, 10])
|
230 |
+
|
231 |
+
with col1:
|
232 |
+
if st.button("👍", key=f"like_{message_id}"):
|
233 |
+
update_message_feedback(message_id, "positive")
|
234 |
+
st.rerun()
|
235 |
+
|
236 |
+
with col2:
|
237 |
+
if st.button("👎", key=f"dislike_{message_id}"):
|
238 |
+
update_message_feedback(message_id, "negative")
|
239 |
+
st.rerun()
|
240 |
+
|
241 |
+
with col3:
|
242 |
+
if message_id in st.session_state.message_feedback:
|
243 |
+
feedback = st.session_state.message_feedback[message_id]
|
244 |
+
st.write(f"Feedback: {'Positif' if feedback == 'positive' else 'Négatif'}")
|
245 |
+
|
246 |
+
async def render_admin_dashboard():
|
247 |
+
"""Affiche le tableau de bord administrateur"""
|
248 |
+
if 'user' not in st.session_state or st.session_state['user'].get('role') != 'admin':
|
249 |
+
st.error("Accès non autorisé. Cette page est réservée aux administrateurs.")
|
250 |
+
return
|
251 |
+
|
252 |
+
current_theme = st.session_state.get('user_theme', 'Clair')
|
253 |
+
apply_tooltip_and_button_styles(current_theme)
|
254 |
+
|
255 |
+
if st.button("← Retour à l'interface principale"):
|
256 |
+
st.session_state['show_admin'] = False
|
257 |
+
st.rerun()
|
258 |
+
|
259 |
+
st.title("Dashboard Administrateur")
|
260 |
+
|
261 |
+
# Création des onglets avec le nouvel onglet de streaming
|
262 |
+
tab_users, tab_restrictions, tab_chatbot, tab_chatreact, testfooter, = st.tabs([
|
263 |
+
"👥 Gestion Utilisateurs",
|
264 |
+
"🔒 Restrictions d'accès",
|
265 |
+
"💬 Chatbot",
|
266 |
+
"📡 Chat React",
|
267 |
+
" 🧪 Test Footer"
|
268 |
+
])
|
269 |
+
|
270 |
+
# Onglet Gestion Utilisateurs
|
271 |
+
with tab_users:
|
272 |
+
stats = get_user_stats()
|
273 |
+
col1, col2, col3 = st.columns(3)
|
274 |
+
with col1:
|
275 |
+
st.metric("Total Utilisateurs", stats["total_users"])
|
276 |
+
with col2:
|
277 |
+
st.metric("Utilisateurs Actifs", stats["active_users"])
|
278 |
+
with col3:
|
279 |
+
st.metric("Administrateurs", stats["admin_users"])
|
280 |
+
|
281 |
+
st.subheader("Gestion des Utilisateurs")
|
282 |
+
search_term = st.text_input("Rechercher un utilisateur par email ou nom:", "")
|
283 |
+
users = get_all_users()
|
284 |
+
if users:
|
285 |
+
df = pd.DataFrame(users)
|
286 |
+
if 'created_at' in df.columns:
|
287 |
+
df['created_at'] = pd.to_datetime(df['created_at']).dt.strftime('%Y-%m-%d %H:%M')
|
288 |
+
if 'last_login' in df.columns:
|
289 |
+
df['last_login'] = pd.to_datetime(df['last_login']).dt.strftime('%Y-%m-%d %H:%M')
|
290 |
+
display_columns = ['id', 'email', 'nom', 'prenom', 'role', 'is_active', 'created_at', 'last_login']
|
291 |
+
|
292 |
+
if search_term:
|
293 |
+
df = df[
|
294 |
+
df['email'].str.contains(search_term, case=False) |
|
295 |
+
df['nom'].str.contains(search_term, case=False) |
|
296 |
+
df['prenom'].str.contains(search_term, case=False)
|
297 |
+
]
|
298 |
+
|
299 |
+
st.dataframe(df[display_columns], use_container_width=True)
|
300 |
+
|
301 |
+
selected_user_email = st.selectbox(
|
302 |
+
"Sélectionner un utilisateur",
|
303 |
+
options=df['email'].tolist()
|
304 |
+
)
|
305 |
+
|
306 |
+
if selected_user_email:
|
307 |
+
selected_user = df[df['email'] == selected_user_email].iloc[0]
|
308 |
+
user_id = selected_user['id']
|
309 |
+
|
310 |
+
col1, col2 = st.columns(2)
|
311 |
+
|
312 |
+
with col1:
|
313 |
+
with st.expander("Paramètres du compte", expanded=True):
|
314 |
+
new_status = st.toggle(
|
315 |
+
"Compte actif",
|
316 |
+
value=selected_user['is_active'],
|
317 |
+
key=f"toggle_{user_id}"
|
318 |
+
)
|
319 |
+
if new_status != selected_user['is_active']:
|
320 |
+
success, msg = toggle_user_status(user_id, new_status)
|
321 |
+
if success:
|
322 |
+
st.success(msg)
|
323 |
+
else:
|
324 |
+
st.error(msg)
|
325 |
+
|
326 |
+
new_role = st.selectbox(
|
327 |
+
"Rôle utilisateur",
|
328 |
+
options=['user', 'admin'],
|
329 |
+
index=0 if selected_user['role'] == 'user' else 1,
|
330 |
+
key=f"role_{user_id}"
|
331 |
+
)
|
332 |
+
if new_role != selected_user['role']:
|
333 |
+
success, msg = change_user_role(user_id, new_role)
|
334 |
+
if success:
|
335 |
+
st.success(msg)
|
336 |
+
else:
|
337 |
+
st.error(msg)
|
338 |
+
|
339 |
+
with st.expander("Informations détaillées", expanded=False):
|
340 |
+
col3, col4 = st.columns(2)
|
341 |
+
with col3:
|
342 |
+
new_nom = st.text_input(
|
343 |
+
"Nom",
|
344 |
+
value=selected_user['nom'],
|
345 |
+
key=f"nom_{user_id}"
|
346 |
+
)
|
347 |
+
new_email = st.text_input(
|
348 |
+
"Email",
|
349 |
+
value=selected_user['email'],
|
350 |
+
key=f"email_{user_id}"
|
351 |
+
)
|
352 |
+
st.text_input(
|
353 |
+
"Date de création",
|
354 |
+
value=selected_user['created_at'],
|
355 |
+
disabled=True
|
356 |
+
)
|
357 |
+
with col4:
|
358 |
+
new_prenom = st.text_input(
|
359 |
+
"Prénom",
|
360 |
+
value=selected_user['prenom'],
|
361 |
+
key=f"prenom_{user_id}"
|
362 |
+
)
|
363 |
+
st.text_input(
|
364 |
+
"Rôle",
|
365 |
+
value=selected_user['role'],
|
366 |
+
disabled=True
|
367 |
+
)
|
368 |
+
st.text_input(
|
369 |
+
"Dernière connexion",
|
370 |
+
value=selected_user.get('last_login', 'Jamais'),
|
371 |
+
disabled=True
|
372 |
+
)
|
373 |
+
|
374 |
+
st.write("##### Informations professionnelles")
|
375 |
+
professional_info = selected_user.get('professional_info', {})
|
376 |
+
if isinstance(professional_info, str):
|
377 |
+
try:
|
378 |
+
professional_info = json.loads(professional_info)
|
379 |
+
except:
|
380 |
+
professional_info = {}
|
381 |
+
|
382 |
+
col5, col6 = st.columns(2)
|
383 |
+
with col5:
|
384 |
+
new_profession = st.text_input(
|
385 |
+
"Profession",
|
386 |
+
value=professional_info.get('profession', ''),
|
387 |
+
key=f"prof_{user_id}"
|
388 |
+
)
|
389 |
+
with col6:
|
390 |
+
new_entreprise = st.text_input(
|
391 |
+
"Entreprise",
|
392 |
+
value=professional_info.get('entreprise', ''),
|
393 |
+
key=f"entr_{user_id}"
|
394 |
+
)
|
395 |
+
|
396 |
+
if st.button("Mettre à jour les informations", key=f"update_{user_id}"):
|
397 |
+
if not re.match(r"[^@]+@[^@]+\.[^@]+", new_email):
|
398 |
+
st.error("Format d'email invalide")
|
399 |
+
return
|
400 |
+
|
401 |
+
if new_email != selected_user['email']:
|
402 |
+
existing_email = supabase.table("users").select("id").eq("email", new_email).neq("id", user_id).execute()
|
403 |
+
if existing_email.data:
|
404 |
+
st.error("Cet email est déjà utilisé par un autre utilisateur")
|
405 |
+
return
|
406 |
+
|
407 |
+
updated_info = {
|
408 |
+
"nom": new_nom,
|
409 |
+
"prenom": new_prenom,
|
410 |
+
"email": new_email,
|
411 |
+
"professional_info": {
|
412 |
+
"profession": new_profession,
|
413 |
+
"entreprise": new_entreprise
|
414 |
+
}
|
415 |
+
}
|
416 |
+
|
417 |
+
success, msg = update_user_info(user_id, updated_info)
|
418 |
+
if success:
|
419 |
+
st.success("Informations mises à jour avec succès")
|
420 |
+
st.rerun()
|
421 |
+
else:
|
422 |
+
st.error(f"Erreur lors de la mise à jour : {msg}")
|
423 |
+
|
424 |
+
with col2:
|
425 |
+
with st.expander("Gestion des accès aux assistants", expanded=False):
|
426 |
+
render_user_permissions(user_id)
|
427 |
+
|
428 |
+
else:
|
429 |
+
st.warning("Aucun utilisateur trouvé.")
|
430 |
+
|
431 |
+
# Onglet Restrictions d'accès
|
432 |
+
with tab_restrictions:
|
433 |
+
st.header("Restrictions d'accès")
|
434 |
+
|
435 |
+
current_restrictions = supabase.table("access_restrictions").select("*").eq("is_active", True).execute()
|
436 |
+
restrictions = current_restrictions.data[0] if current_restrictions.data else {}
|
437 |
+
|
438 |
+
with st.form("access_restrictions_form"):
|
439 |
+
st.subheader("🌐 Domaines email autorisés")
|
440 |
+
st.info("Définissez les domaines email autorisés pour l'inscription. Un domaine par ligne.")
|
441 |
+
domains_text = st.text_area(
|
442 |
+
"Domaines autorisés",
|
443 |
+
value="\n".join(restrictions.get('allowed_email_domains', [])),
|
444 |
+
help="Exemple: entreprise.com \nautreentreprise.fr",
|
445 |
+
placeholder="Entrez les domaines (un par ligne)"
|
446 |
+
)
|
447 |
+
|
448 |
+
st.subheader("🌐 Adresses IP autorisées")
|
449 |
+
st.info("Définissez les adresses IP autorisées pour l'inscription. Une adresse par ligne.")
|
450 |
+
ip_ranges_text = st.text_area(
|
451 |
+
"Adresses IP autorisées",
|
452 |
+
value="\n".join(restrictions.get('allowed_ip_ranges', [])),
|
453 |
+
help="Exemple: 192.168.1.1 \n10.0.0.1",
|
454 |
+
placeholder="Entrez les adresses IP (une par ligne)"
|
455 |
+
)
|
456 |
+
|
457 |
+
col1, col2, spacer1, col4 = st.columns([4,4,6,3])
|
458 |
+
with col1:
|
459 |
+
is_email_active = st.toggle(
|
460 |
+
"Activer les restrictions d'email",
|
461 |
+
value=restrictions.get('is_email_active', False),
|
462 |
+
help="Activer/Désactiver les restrictions d'accès par email"
|
463 |
+
)
|
464 |
+
|
465 |
+
with col2:
|
466 |
+
is_ip_active = st.toggle(
|
467 |
+
"Activer les restrictions d'IP",
|
468 |
+
value=restrictions.get('is_ip_active', False),
|
469 |
+
help="Activer/Désactiver les restrictions d'accès par IP"
|
470 |
+
)
|
471 |
+
|
472 |
+
with col4:
|
473 |
+
st.markdown("###")
|
474 |
+
submit_button = st.form_submit_button("💾 Enregistrer les restrictions")
|
475 |
+
|
476 |
+
if submit_button:
|
477 |
+
try:
|
478 |
+
domains = [
|
479 |
+
d.strip().lower()
|
480 |
+
for d in domains_text.split('\n')
|
481 |
+
if d.strip() and '.' in d.strip()
|
482 |
+
]
|
483 |
+
|
484 |
+
ip_ranges = [
|
485 |
+
ip.strip()
|
486 |
+
for ip in ip_ranges_text.split('\n')
|
487 |
+
if ip.strip()
|
488 |
+
]
|
489 |
+
|
490 |
+
update_data = {
|
491 |
+
"allowed_email_domains": domains,
|
492 |
+
"allowed_ip_ranges": ip_ranges,
|
493 |
+
"is_email_active": is_email_active,
|
494 |
+
"is_ip_active": is_ip_active,
|
495 |
+
"updated_at": datetime.now(pytz.UTC).isoformat()
|
496 |
+
}
|
497 |
+
|
498 |
+
if current_restrictions.data:
|
499 |
+
supabase.table("access_restrictions").update(
|
500 |
+
update_data
|
501 |
+
).eq("id", current_restrictions.data[0]['id']).execute()
|
502 |
+
else:
|
503 |
+
supabase.table("access_restrictions").insert(update_data).execute()
|
504 |
+
|
505 |
+
st.success("✅ Restrictions mises à jour avec succès!")
|
506 |
+
|
507 |
+
if is_email_active and domains:
|
508 |
+
st.info(f"Domaines autorisés : {', '.join(domains)}")
|
509 |
+
elif is_email_active and not domains:
|
510 |
+
st.warning("⚠️ Aucun domaine spécifié - l'inscription sera bloquée pour tous les domaines")
|
511 |
+
else:
|
512 |
+
st.info("ℹ️ Les restrictions d'email sont désactivées - tous les domaines sont autorisés")
|
513 |
+
|
514 |
+
if is_ip_active and ip_ranges:
|
515 |
+
st.info(f"Adresses IP autorisées : {', '.join(ip_ranges)}")
|
516 |
+
elif is_ip_active and not ip_ranges:
|
517 |
+
st.warning("⚠️ Aucune adresse IP spécifiée - l'inscription sera bloquée pour toutes les adresses IP")
|
518 |
+
else:
|
519 |
+
st.info("ℹ️ Les restrictions d'IP sont désactivées - toutes les adresses IP sont autorisées")
|
520 |
+
|
521 |
+
except Exception as e:
|
522 |
+
st.error(f"❌ Erreur lors de la mise à jour des restrictions : {str(e)}")
|
523 |
+
|
524 |
+
if restrictions.get('is_email_active') or restrictions.get('is_ip_active'):
|
525 |
+
st.subheader("📊 Statistiques des restrictions")
|
526 |
+
try:
|
527 |
+
users = supabase.table("users").select("email").execute()
|
528 |
+
if users.data:
|
529 |
+
domains_count = {}
|
530 |
+
for user in users.data:
|
531 |
+
domain = user['email'].split('@')[1].lower()
|
532 |
+
domains_count[domain] = domains_count.get(domain, 0) + 1
|
533 |
+
|
534 |
+
st.markdown("##### Répartition des utilisateurs par domaine")
|
535 |
+
for domain, count in domains_count.items():
|
536 |
+
status = "✅" if not restrictions.get('allowed_email_domains') or domain in restrictions.get('allowed_email_domains', []) else "❌"
|
537 |
+
st.text(f"{status} {domain}: {count} utilisateur(s)")
|
538 |
+
except Exception as e:
|
539 |
+
st.error(f"Erreur lors du calcul des statistiques : {str(e)}")
|
540 |
+
|
541 |
+
|
542 |
+
with tab_chatbot:
|
543 |
+
st.subheader("💬 Chatbot")
|
544 |
+
|
545 |
+
def add_flowise_chat():
|
546 |
+
assistant_type = st.selectbox(
|
547 |
+
"Sélectionner l'assistant",
|
548 |
+
options=['SANTE', 'CAR', 'BTP', 'RH', 'PILOTAGE'],
|
549 |
+
format_func=lambda x: {
|
550 |
+
'SANTE': 'Colibri Vitalité ',
|
551 |
+
'CAR': 'Colibri Carburant ',
|
552 |
+
'BTP': 'Colibri Batisseur ',
|
553 |
+
'RH': 'Colibri Equipage ',
|
554 |
+
'PILOTAGE': 'Colibri Tatillon '
|
555 |
+
}[x],
|
556 |
+
key="chatbot_assistant_select"
|
557 |
+
)
|
558 |
+
|
559 |
+
# Définir le titre du chatbot en fonction de l'assistant sélectionné
|
560 |
+
assistant_titles = {
|
561 |
+
'SANTE': 'Colibri Vitalité',
|
562 |
+
'CAR': 'Colibri Carburant',
|
563 |
+
'BTP': 'Colibri Batisseur',
|
564 |
+
'RH': 'Colibri Equipage',
|
565 |
+
'PILOTAGE': 'Colibri Tatillon'
|
566 |
+
}
|
567 |
+
chatbot_title = assistant_titles.get(assistant_type, 'Flowise Bot')
|
568 |
+
|
569 |
+
# Récupérer l'URL complète de l'API depuis la variable d'environnement
|
570 |
+
api_url_var = f"FLOWISE_API_URL_{assistant_type}"
|
571 |
+
api_url = os.getenv(api_url_var)
|
572 |
+
if not api_url:
|
573 |
+
st.error(f"URL de l'API non trouvée pour l'assistant {assistant_type}")
|
574 |
+
return
|
575 |
+
|
576 |
+
# Extraction de l'URL de base et du chatflowid
|
577 |
+
apiHost = '/'.join(api_url.split('/')[:3]) # Supprimer /prediction/ID
|
578 |
+
# Remplacer "flowise" par "localhost" dans apiHost
|
579 |
+
apiHost = apiHost.replace("flowise", "localhost")
|
580 |
+
chatflowid = api_url.split('/')[-1] # Récupérer l'ID
|
581 |
+
|
582 |
+
# Configuration du chatbot en HTML
|
583 |
+
st.components.v1.html(f'''
|
584 |
+
<script type="module">
|
585 |
+
import Chatbot from 'https://cdn.jsdelivr.net/npm/flowise-embed/dist/web.js';
|
586 |
+
Chatbot.init({{
|
587 |
+
chatflowid: '{chatflowid}',
|
588 |
+
apiHost: '{apiHost}',
|
589 |
+
chatflowConfig: {{
|
590 |
+
// topK: 2
|
591 |
+
}},
|
592 |
+
observersConfig: {{
|
593 |
+
// (optional) Allows you to execute code in parent based upon signal observations within the chatbot.
|
594 |
+
// The userinput field submitted to bot ("" when reset by bot)
|
595 |
+
observeUserInput: (userInput) => {{
|
596 |
+
console.log({{ userInput }});
|
597 |
+
}},
|
598 |
+
// The bot message stack has changed
|
599 |
+
observeMessages: (messages) => {{
|
600 |
+
console.log({{ messages }});
|
601 |
+
}},
|
602 |
+
// The bot loading signal changed
|
603 |
+
observeLoading: (loading) => {{
|
604 |
+
console.log({{ loading }});
|
605 |
+
}},
|
606 |
+
}},
|
607 |
+
theme: {{
|
608 |
+
button: {{
|
609 |
+
backgroundColor: '#3B81F6',
|
610 |
+
right: 20,
|
611 |
+
bottom: 20,
|
612 |
+
size: 48,
|
613 |
+
// small | medium | large | number
|
614 |
+
dragAndDrop: true,
|
615 |
+
iconColor: 'white',
|
616 |
+
customIconSrc: 'https://raw.githubusercontent.com/walkxcode/dashboard-icons/main/svg/google-messages.svg',
|
617 |
+
autoWindowOpen: {{
|
618 |
+
autoOpen: true,
|
619 |
+
//parameter to control automatic window opening
|
620 |
+
openDelay: 2,
|
621 |
+
// Optional parameter for delay time in seconds
|
622 |
+
autoOpenOnMobile: false,
|
623 |
+
//parameter to control automatic window opening in mobile
|
624 |
+
}},
|
625 |
+
}},
|
626 |
+
tooltip: {{
|
627 |
+
showTooltip: true,
|
628 |
+
tooltipMessage: 'Bonjour !',
|
629 |
+
tooltipBackgroundColor: 'black',
|
630 |
+
tooltipTextColor: 'white',
|
631 |
+
tooltipFontSize: 16,
|
632 |
+
}},
|
633 |
+
disclaimer: {{
|
634 |
+
title: 'Avertissement',
|
635 |
+
message: 'By using this chatbot, you agree to the <a target="_blank" href="https://ecg-pereire-assurances.com/mentions-legales/">Terms & Condition</a>',
|
636 |
+
}},
|
637 |
+
chatWindow: {{
|
638 |
+
showTitle: true,
|
639 |
+
showAgentMessages: true,
|
640 |
+
title: '{chatbot_title}',
|
641 |
+
titleAvatarSrc: 'https://raw.githubusercontent.com/walkxcode/dashboard-icons/main/svg/google-messages.svg',
|
642 |
+
welcomeMessage: 'Hello! This is custom welcome message',
|
643 |
+
errorMessage: 'This is a custom error message',
|
644 |
+
backgroundColor: '#ffffff',
|
645 |
+
backgroundImage: 'enter image path or link',
|
646 |
+
// If set, this will overlap the background color of the chat window.
|
647 |
+
height: 700,
|
648 |
+
width: 400,
|
649 |
+
fontSize: 16,
|
650 |
+
starterPrompts: ['What is a bot?', 'Who are you?'],
|
651 |
+
// It overrides the starter prompts set by the chat flow passed
|
652 |
+
starterPromptFontSize: 15,
|
653 |
+
clearChatOnReload: false,
|
654 |
+
// If set to true, the chat will be cleared when the page reloads
|
655 |
+
sourceDocsTitle: 'Sources:',
|
656 |
+
renderHTML: true,
|
657 |
+
botMessage: {{
|
658 |
+
backgroundColor: '#f7f8ff',
|
659 |
+
textColor: '#303235',
|
660 |
+
showAvatar: true,
|
661 |
+
avatarSrc: 'https://raw.githubusercontent.com/zahidkhawaja/langchain-chat-nextjs/main/public/parroticon.png',
|
662 |
+
}},
|
663 |
+
userMessage: {{
|
664 |
+
backgroundColor: '#3B81F6',
|
665 |
+
textColor: '#ffffff',
|
666 |
+
showAvatar: true,
|
667 |
+
avatarSrc: 'https://raw.githubusercontent.com/zahidkhawaja/langchain-chat-nextjs/main/public/usericon.png',
|
668 |
+
}},
|
669 |
+
textInput: {{
|
670 |
+
placeholder: 'Type your question',
|
671 |
+
backgroundColor: '#ffffff',
|
672 |
+
textColor: '#303235',
|
673 |
+
sendButtonColor: '#3B81F6',
|
674 |
+
//maxChars: 50,
|
675 |
+
maxCharsWarningMessage: 'You exceeded the characters limit. Please input less than 50 characters.',
|
676 |
+
autoFocus: true,
|
677 |
+
// If not used, autofocus is disabled on mobile and enabled on desktop. true enables it on both, false disables it on both.
|
678 |
+
sendMessageSound: true,
|
679 |
+
// sendSoundLocation: "send_message.mp3",
|
680 |
+
// If this is not used, the default sound effect will be played if sendSoundMessage is true.
|
681 |
+
receiveMessageSound: true,
|
682 |
+
// receiveSoundLocation: "receive_message.mp3",
|
683 |
+
// If this is not used, the default sound effect will be played if receiveSoundMessage is true.
|
684 |
+
}},
|
685 |
+
feedback: {{
|
686 |
+
color: '#303235',
|
687 |
+
}},
|
688 |
+
dateTimeToggle: {{
|
689 |
+
date: true,
|
690 |
+
time: true,
|
691 |
+
}},
|
692 |
+
footer: {{
|
693 |
+
textColor: '#303235',
|
694 |
+
text: 'Powered by',
|
695 |
+
company: 'Colibri',
|
696 |
+
companyLink: 'https://ecg-pereire-assurances.com',
|
697 |
+
}},
|
698 |
+
}},
|
699 |
+
}},
|
700 |
+
}});
|
701 |
+
</script>
|
702 |
+
''', height=800)
|
703 |
+
add_flowise_chat()
|
704 |
+
|
705 |
+
|
706 |
+
with tab_chatreact:
|
707 |
+
def add_flowise_chatreact():
|
708 |
+
assistant_type = st.selectbox(
|
709 |
+
"Sélectionner l'assistant",
|
710 |
+
options=['SANTE', 'CAR', 'BTP', 'RH', 'PILOTAGE'],
|
711 |
+
format_func=lambda x: {
|
712 |
+
'SANTE': 'Colibri Vitalité ❣️',
|
713 |
+
'CAR': 'Colibri Carburant 🚘',
|
714 |
+
'BTP': 'Colibri Batisseur 🏠',
|
715 |
+
'RH': 'Colibri Equipage 🪪',
|
716 |
+
'PILOTAGE': 'Colibri Tatillon 👨🏻✈️'
|
717 |
+
}[x],
|
718 |
+
key="chatbot_full_assistant_select"
|
719 |
+
)
|
720 |
+
# Définir le titre du chatbot en fonction de l'assistant sélectionné
|
721 |
+
assistant_titles = {
|
722 |
+
'SANTE': 'Colibri Vitalité',
|
723 |
+
'CAR': 'Colibri Carburant',
|
724 |
+
'BTP': 'Colibri Batisseur',
|
725 |
+
'RH': 'Colibri Equipage',
|
726 |
+
'PILOTAGE': 'Colibri Tatillon'
|
727 |
+
}
|
728 |
+
chatbot_title = assistant_titles.get(assistant_type, 'Flowise Bot')
|
729 |
+
# Récupérer l'URL complète de l'API depuis la variable d'environnement
|
730 |
+
api_url_var = f"FLOWISE_API_URL_{assistant_type}"
|
731 |
+
api_url = os.getenv(api_url_var)
|
732 |
+
if not api_url:
|
733 |
+
st.error(f"URL de l'API non trouvée pour l'assistant {assistant_type}")
|
734 |
+
return
|
735 |
+
# Extraction de l'URL de base et du chatflowid
|
736 |
+
apiHost = '/'.join(api_url.split('/')[:3]) # Supprimer /prediction/ID
|
737 |
+
# Remplacer "flowise" par "localhost" dans apiHost
|
738 |
+
apiHost = apiHost.replace("flowise", "localhost")
|
739 |
+
chatflowid = api_url.split('/')[-1] # Récupérer l'ID
|
740 |
+
|
741 |
+
# Configuration du chatbot en HTML
|
742 |
+
st.components.v1.html(f'''
|
743 |
+
<flowise-fullchatbot></flowise-fullchatbot>
|
744 |
+
<body style="margin: 0">
|
745 |
+
<script type="module">
|
746 |
+
import Chatbot from "https://cdn.jsdelivr.net/npm/flowise-embed/dist/web.js";
|
747 |
+
Chatbot.initFull({{
|
748 |
+
chatflowid: "{chatflowid}",
|
749 |
+
apiHost: "{apiHost}",
|
750 |
+
theme: {{
|
751 |
+
chatWindow: {{
|
752 |
+
showTitle: true,
|
753 |
+
title: "{chatbot_title}",
|
754 |
+
titleAvatarSrc: 'https://raw.githubusercontent.com/walkxcode/dashboard-icons/main/svg/google-messages.svg',
|
755 |
+
showAgentMessages: true,
|
756 |
+
welcomeMessage: 'Hello! This is custom welcome message',
|
757 |
+
errorMessage: 'This is a custom error message',
|
758 |
+
backgroundColor: "#ffffff",
|
759 |
+
backgroundImage: '',
|
760 |
+
fontSize: 16,
|
761 |
+
starterPromptFontSize: 15,
|
762 |
+
clearChatOnReload: false,
|
763 |
+
botMessage: {{
|
764 |
+
backgroundColor: "#f7f8ff",
|
765 |
+
textColor: "#303235",
|
766 |
+
showAvatar: true,
|
767 |
+
avatarSrc: "https://raw.githubusercontent.com/zahidkhawaja/langchain-chat-nextjs/main/public/parroticon.png",
|
768 |
+
}},
|
769 |
+
userMessage: {{
|
770 |
+
backgroundColor: "#3B81F6",
|
771 |
+
textColor: "#ffffff",
|
772 |
+
showAvatar: true,
|
773 |
+
avatarSrc: "https://raw.githubusercontent.com/zahidkhawaja/langchain-chat-nextjs/main/public/usericon.png",
|
774 |
+
}},
|
775 |
+
textInput: {{
|
776 |
+
placeholder: 'Tapez votre question ici...',
|
777 |
+
backgroundColor: '#ffffff',
|
778 |
+
textColor: '#303235',
|
779 |
+
sendButtonColor: '#3B81F6',
|
780 |
+
autoFocus: true,
|
781 |
+
sendMessageSound: true,
|
782 |
+
receiveMessageSound: true,
|
783 |
+
}},
|
784 |
+
feedback: {{
|
785 |
+
color: '#303235',
|
786 |
+
}},
|
787 |
+
footer: {{
|
788 |
+
textColor: '#303235',
|
789 |
+
text: 'Powered by',
|
790 |
+
company: 'Colibri',
|
791 |
+
companyLink: 'https://ecg-pereire-assurances.com/',
|
792 |
+
}}
|
793 |
+
}}
|
794 |
+
}}
|
795 |
+
}});
|
796 |
+
</script>
|
797 |
+
''', height=800)
|
798 |
+
add_flowise_chatreact()
|
799 |
+
|
800 |
+
|
801 |
+
with testfooter:
|
802 |
+
st.components.v1.html('''
|
803 |
+
<flowise-fullchatbot></flowise-fullchatbot>
|
804 |
+
<body style="margin: 0">
|
805 |
+
<script type="module">
|
806 |
+
import Chatbot from "https://cdn.jsdelivr.net/npm/flowise-embed/dist/web.js";
|
807 |
+
Chatbot.initFull({
|
808 |
+
chatflowid: "c7772c60-e798-4d02-809f-654170560e91",
|
809 |
+
apiHost: "http://localhost:3000",
|
810 |
+
theme: {
|
811 |
+
chatWindow: {
|
812 |
+
showTitle: true,
|
813 |
+
title: 'Flowise Bot',
|
814 |
+
titleAvatarSrc: 'https://raw.githubusercontent.com/walkxcode/dashboard-icons/main/svg/google-messages.svg',
|
815 |
+
showAgentMessages: true,
|
816 |
+
welcomeMessage: 'Hello! This is custom welcome message',
|
817 |
+
errorMessage: 'This is a custom error message',
|
818 |
+
backgroundColor: "#ffffff",
|
819 |
+
backgroundImage: '',
|
820 |
+
//height: 700,
|
821 |
+
//width: 400,
|
822 |
+
fontSize: 16,
|
823 |
+
starterPromptFontSize: 15,
|
824 |
+
clearChatOnReload: false,
|
825 |
+
botMessage: {
|
826 |
+
backgroundColor: "#f7f8ff",
|
827 |
+
textColor: "#303235",
|
828 |
+
showAvatar: true,
|
829 |
+
avatarSrc: "https://raw.githubusercontent.com/zahidkhawaja/langchain-chat-nextjs/main/public/parroticon.png",
|
830 |
+
},
|
831 |
+
userMessage: {
|
832 |
+
backgroundColor: "#3B81F6",
|
833 |
+
textColor: "#ffffff",
|
834 |
+
showAvatar: true,
|
835 |
+
avatarSrc: "https://raw.githubusercontent.com/zahidkhawaja/langchain-chat-nextjs/main/public/usericon.png",
|
836 |
+
},
|
837 |
+
textInput: {
|
838 |
+
placeholder: 'Tapez votre question ici...',
|
839 |
+
backgroundColor: '#ffffff',
|
840 |
+
textColor: '#303235',
|
841 |
+
sendButtonColor: '#3B81F6',
|
842 |
+
//maxChars: 50,
|
843 |
+
//maxCharsWarningMessage: 'You exceeded the characters limit. Please input less than 50 characters.',
|
844 |
+
autoFocus: true,
|
845 |
+
sendMessageSound: true,
|
846 |
+
receiveMessageSound: true,
|
847 |
+
},
|
848 |
+
feedback: {
|
849 |
+
color: '#303235',
|
850 |
+
},
|
851 |
+
footer: {
|
852 |
+
textColor: '#303235',
|
853 |
+
text: 'Powered by',
|
854 |
+
company: 'Clibri',
|
855 |
+
companyLink: 'https://ecg-pereire-assurances.com/',
|
856 |
+
}
|
857 |
+
}
|
858 |
+
}
|
859 |
+
});
|
860 |
+
</script>
|
861 |
+
''', height=800)
|
862 |
+
|
863 |
+
|
864 |
+
|
865 |
+
|
866 |
+
|
867 |
+
|
868 |
+
|
869 |
+
|
870 |
+
|
871 |
+
|
872 |
+
|
873 |
+
# Exécution de la fonction principale
|
874 |
+
if __name__ == "__main__":
|
875 |
+
asyncio.run(render_admin_dashboard())
|
assets/logo.jfif
ADDED
Binary file (8.13 kB). View file
|
|
assets/logo.png
ADDED
![]() |
assets/logo.svg
ADDED
|
assets/logo2.svg
ADDED
|
docker-compose.yml
ADDED
@@ -0,0 +1,119 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
services:
|
2 |
+
streamlit-app:
|
3 |
+
build: .
|
4 |
+
ports:
|
5 |
+
- "8501:8501"
|
6 |
+
environment:
|
7 |
+
- SUPABASE_URL=${SUPABASE_URL}
|
8 |
+
- SUPABASE_KEY=${SUPABASE_KEY}
|
9 |
+
- JWT_SECRET=${JWT_SECRET}
|
10 |
+
- FLOWISE_API_URL_SANTE=${FLOWISE_API_URL_SANTE}
|
11 |
+
- FLOWISE_API_URL_CAR=${FLOWISE_API_URL_CAR}
|
12 |
+
- FLOWISE_API_URL_BTP=${FLOWISE_API_URL_BTP}
|
13 |
+
- FLOWISE_API_URL_RH=${FLOWISE_API_URL_RH}
|
14 |
+
- FLOWISE_API_URL_PILOTAGE=${FLOWISE_API_URL_PILOTAGE}
|
15 |
+
- PYTHONUNBUFFERED=1 # Pour un meilleur logging
|
16 |
+
# partie concernant le reset du mot de passe
|
17 |
+
- EMAIL_HOST=${EMAIL_HOST}
|
18 |
+
- EMAIL_PORT=${EMAIL_PORT}
|
19 |
+
- EMAIL_HOST_USER=${EMAIL_HOST_USER}
|
20 |
+
- EMAIL_HOST_USER_PASSWORD=${EMAIL_HOST_USER_PASSWORD}
|
21 |
+
- DEFAULT_FROM_EMAIL=${DEFAULT_FROM_EMAIL}
|
22 |
+
- APP_URL=${APP_URL}
|
23 |
+
# Ajout de la configuration Redis
|
24 |
+
- REDIS_HOST=${REDIS_HOST}
|
25 |
+
- REDIS_PORT=${REDIS_PORT}
|
26 |
+
- QDRANT_URL=${QDRANT_URL}
|
27 |
+
extra_hosts:
|
28 |
+
- "host.docker.internal:host-gateway"
|
29 |
+
volumes:
|
30 |
+
- ./app.py:/app/app.py # Pour faciliter les modifications sans rebuilds
|
31 |
+
depends_on:
|
32 |
+
- redis
|
33 |
+
- flowise
|
34 |
+
- qdrant
|
35 |
+
networks:
|
36 |
+
- redis-network
|
37 |
+
- flowise-network
|
38 |
+
- qdrant-network
|
39 |
+
restart: always
|
40 |
+
|
41 |
+
redis:
|
42 |
+
image: redis:alpine
|
43 |
+
ports:
|
44 |
+
- "6379:6379"
|
45 |
+
volumes:
|
46 |
+
- redis_data:/data
|
47 |
+
networks:
|
48 |
+
- redis-network
|
49 |
+
restart: always
|
50 |
+
|
51 |
+
redisinsight:
|
52 |
+
image: redislabs/redisinsight:1.14.0
|
53 |
+
ports:
|
54 |
+
- "8001:8001"
|
55 |
+
volumes:
|
56 |
+
- redisinsight_data:/db
|
57 |
+
depends_on:
|
58 |
+
- redis
|
59 |
+
networks:
|
60 |
+
- redis-network
|
61 |
+
restart: always
|
62 |
+
pull_policy: always
|
63 |
+
|
64 |
+
flowise:
|
65 |
+
image: flowiseai/flowise:latest
|
66 |
+
ports:
|
67 |
+
- "3000:3000"
|
68 |
+
environment:
|
69 |
+
- PORT=3000
|
70 |
+
# backup dans dans postrgrs supabase
|
71 |
+
#- DATABASE_TYPE=${DATABASE_TYPE}
|
72 |
+
#- DATABASE_HOST=${DATABASE_HOST}
|
73 |
+
#- DATABASE_PORT=${DATABASE_PORT}
|
74 |
+
#- DATABASE_NAME=${DATABASE_NAME}
|
75 |
+
#- DATABASE_USER=${DATABASE_USER}
|
76 |
+
#- DATABASE_PASSWORD=${DATABASE_PASSWORD}
|
77 |
+
#- PGSSLMODE=${PGSSLMODE}
|
78 |
+
# autres variables
|
79 |
+
- DATABASE_PATH=/root/.flowise
|
80 |
+
- APIKEY_PATH=/root/.flowise
|
81 |
+
- SECRETKEY_PATH=/root/.flowise
|
82 |
+
- LOG_PATH=/root/.flowise/logs
|
83 |
+
- BLOB_STORAGE_PATH=/root/.flowise/storage
|
84 |
+
- ENCRYPTION_KEY_PATH=/root/.flowise/encryption.key
|
85 |
+
- FLOWISE_USERNAME=${FLOWISE_USERNAME}
|
86 |
+
- FLOWISE_PASSWORD=${FLOWISE_PASSWORD}
|
87 |
+
#- FLOWISE_AUTH_TOKEN=${FLOWISE_AUTH_TOKEN} # pas nécessaire pour l'instant
|
88 |
+
|
89 |
+
volumes:
|
90 |
+
- flowise_data:/root/.flowise
|
91 |
+
networks:
|
92 |
+
- flowise-network
|
93 |
+
restart: always
|
94 |
+
pull_policy: always
|
95 |
+
|
96 |
+
qdrant:
|
97 |
+
image: qdrant/qdrant:latest
|
98 |
+
ports:
|
99 |
+
- "6333:6333"
|
100 |
+
volumes:
|
101 |
+
- qdrant_data:/qdrant/storage
|
102 |
+
networks:
|
103 |
+
- flowise-network
|
104 |
+
restart: always
|
105 |
+
pull_policy: always
|
106 |
+
|
107 |
+
volumes:
|
108 |
+
redis_data:
|
109 |
+
redisinsight_data:
|
110 |
+
flowise_data:
|
111 |
+
qdrant_data:
|
112 |
+
|
113 |
+
networks:
|
114 |
+
redis-network:
|
115 |
+
name: redis-network
|
116 |
+
flowise-network:
|
117 |
+
name: flowise-network
|
118 |
+
qdrant-network:
|
119 |
+
name: qdrant-network
|
pages/Verify_email.py
ADDED
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import streamlit as st
|
2 |
+
import sys
|
3 |
+
import os
|
4 |
+
from datetime import datetime
|
5 |
+
import pytz
|
6 |
+
import logging # Ajout de l'import logging
|
7 |
+
from utils.admin import supabase
|
8 |
+
|
9 |
+
# Configuration du logging
|
10 |
+
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
11 |
+
|
12 |
+
# IMPORTANT : set_page_config doit être la première commande Streamlit
|
13 |
+
st.set_page_config(
|
14 |
+
page_title="Vérification de l'email",
|
15 |
+
page_icon="✉️",
|
16 |
+
layout="centered"
|
17 |
+
)
|
18 |
+
|
19 |
+
# Cacher les éléments de navigation
|
20 |
+
hide_streamlit_style = """
|
21 |
+
<style>
|
22 |
+
#MainMenu {visibility: hidden;}
|
23 |
+
header {visibility: hidden;}
|
24 |
+
.css-1544g2n {display: none;}
|
25 |
+
section[data-testid="stSidebar"] {display: none;}
|
26 |
+
</style>
|
27 |
+
"""
|
28 |
+
st.markdown(hide_streamlit_style, unsafe_allow_html=True)
|
29 |
+
|
30 |
+
def verify_email_token(token: str) -> tuple[bool, str]:
|
31 |
+
"""Vérifie le token et active le compte"""
|
32 |
+
try:
|
33 |
+
# Vérifier le token
|
34 |
+
verification = supabase.table("email_verification").select("*").eq(
|
35 |
+
"verification_token", token
|
36 |
+
).eq("is_verified", False).execute()
|
37 |
+
|
38 |
+
if not verification.data:
|
39 |
+
return False, "Token de vérification invalide ou déjà utilisé."
|
40 |
+
|
41 |
+
verification_data = verification.data[0]
|
42 |
+
expires_at = datetime.fromisoformat(verification_data['expires_at'].replace('Z', '+00:00'))
|
43 |
+
|
44 |
+
if expires_at < datetime.now(pytz.UTC):
|
45 |
+
return False, "Le lien de vérification a expiré."
|
46 |
+
|
47 |
+
# Activer l'utilisateur
|
48 |
+
supabase.table("users").update({
|
49 |
+
"is_active": True
|
50 |
+
}).eq("id", verification_data['user_id']).execute()
|
51 |
+
|
52 |
+
# Marquer comme vérifié
|
53 |
+
supabase.table("email_verification").update({
|
54 |
+
"is_verified": True
|
55 |
+
}).eq("id", verification_data['id']).execute()
|
56 |
+
|
57 |
+
return True, "Email vérifié avec succès!"
|
58 |
+
|
59 |
+
except Exception as e:
|
60 |
+
logging.error(f"Erreur lors de la vérification : {str(e)}")
|
61 |
+
return False, f"Erreur lors de la vérification : {str(e)}"
|
62 |
+
|
63 |
+
# Titre de la page
|
64 |
+
st.title("✉️ Vérification de l'email")
|
65 |
+
|
66 |
+
# Debug: Afficher le token reçu dans les logs
|
67 |
+
token = st.query_params.get("token", None)
|
68 |
+
logging.info(f"Token reçu : {token if token else 'Aucun token'}")
|
69 |
+
|
70 |
+
if not token:
|
71 |
+
st.error("Token de vérification manquant.")
|
72 |
+
st.stop()
|
73 |
+
|
74 |
+
# Vérifier le token et activer le compte
|
75 |
+
success, message = verify_email_token(token)
|
76 |
+
if success:
|
77 |
+
st.success(message)
|
78 |
+
st.info("Votre compte est maintenant actif. Vous pouvez vous connecter.")
|
79 |
+
|
80 |
+
# Bouton pour aller à la page de connexion
|
81 |
+
if st.button("Aller à la page de connexion"):
|
82 |
+
st.switch_page("app.py")
|
83 |
+
else:
|
84 |
+
st.error(message)
|
85 |
+
if "expiré" in message:
|
86 |
+
st.info("Veuillez demander un nouveau lien de vérification.")
|
pages/profile.py
ADDED
@@ -0,0 +1,260 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import streamlit as st
|
2 |
+
from utils.admin import supabase
|
3 |
+
import bcrypt
|
4 |
+
from utils.notification_handler import notify_security_alert #import du module de notifications (envoie une notification de sécurité en cas de modifications des données de l"utilisateur)
|
5 |
+
|
6 |
+
|
7 |
+
# Configuration de la page
|
8 |
+
st.set_page_config(
|
9 |
+
page_title="Profil Utilisateur",
|
10 |
+
page_icon="👤",
|
11 |
+
layout="centered"
|
12 |
+
)
|
13 |
+
|
14 |
+
st.title("Mon Profil")
|
15 |
+
|
16 |
+
# Vérification de l'authentification
|
17 |
+
if 'user' not in st.session_state:
|
18 |
+
st.error("Vous devez être connecté pour accéder à cette page.")
|
19 |
+
st.stop()
|
20 |
+
|
21 |
+
|
22 |
+
# Récupérer les informations actuelles de l'utilisateur
|
23 |
+
user_id = st.session_state['user']['id']
|
24 |
+
user_info = supabase.table("users").select("*").eq("id", user_id).execute()
|
25 |
+
current_user = user_info.data[0] if user_info.data else None
|
26 |
+
|
27 |
+
# Section Informations Personnelles
|
28 |
+
st.header("📋 Informations Personnelles")
|
29 |
+
with st.form("personal_info_form"):
|
30 |
+
col1, col2 = st.columns(2)
|
31 |
+
with col1:
|
32 |
+
new_nom = st.text_input("Nom", value=current_user.get('nom', ''))
|
33 |
+
with col2:
|
34 |
+
new_prenom = st.text_input("Prénom", value=current_user.get('prenom', ''))
|
35 |
+
|
36 |
+
# Informations professionnelles
|
37 |
+
st.subheader("Informations professionnelles")
|
38 |
+
professional_info = current_user.get('professional_info', {})
|
39 |
+
if isinstance(professional_info, str):
|
40 |
+
import json
|
41 |
+
professional_info = json.loads(professional_info)
|
42 |
+
|
43 |
+
new_profession = st.text_input(
|
44 |
+
"Profession",
|
45 |
+
value=professional_info.get('profession', '')
|
46 |
+
)
|
47 |
+
new_entreprise = st.text_input(
|
48 |
+
"Entreprise",
|
49 |
+
value=professional_info.get('entreprise', '')
|
50 |
+
)
|
51 |
+
|
52 |
+
submit_personal = st.form_submit_button("Mettre à jour mes informations")
|
53 |
+
|
54 |
+
if submit_personal:
|
55 |
+
try:
|
56 |
+
# Préparer les données à mettre à jour
|
57 |
+
update_data = {
|
58 |
+
"nom": new_nom,
|
59 |
+
"prenom": new_prenom,
|
60 |
+
"professional_info": {
|
61 |
+
"profession": new_profession,
|
62 |
+
"entreprise": new_entreprise
|
63 |
+
}
|
64 |
+
}
|
65 |
+
|
66 |
+
# Mettre à jour dans la base de données
|
67 |
+
supabase.table("users").update(update_data).eq("id", user_id).execute()
|
68 |
+
st.success("✅ Informations personnelles mises à jour avec succès!")
|
69 |
+
st.rerun() # Recharger la page pour afficher les nouvelles informations
|
70 |
+
# Notifier l'utilisateur
|
71 |
+
except Exception as e:
|
72 |
+
st.error(f"Une erreur est survenue : {str(e)}")
|
73 |
+
notify_security_alert(user_id, # notifier l'utilisateur du changement de ses impofrmations personnelles
|
74 |
+
"Vos informations personnelles ont été mises à jour."
|
75 |
+
)
|
76 |
+
|
77 |
+
|
78 |
+
# Section Email et Mot de passe
|
79 |
+
st.header("🔐 Email et Mot de passe")
|
80 |
+
with st.form("security_form"):
|
81 |
+
# Email
|
82 |
+
new_email = st.text_input("Email", value=current_user.get('email', ''))
|
83 |
+
|
84 |
+
# Mot de passe
|
85 |
+
st.subheader("Changer le mot de passe")
|
86 |
+
current_password = st.text_input("Mot de passe actuel", type="password")
|
87 |
+
new_password = st.text_input("Nouveau mot de passe", type="password")
|
88 |
+
confirm_password = st.text_input("Confirmer le nouveau mot de passe", type="password")
|
89 |
+
|
90 |
+
submit_security = st.form_submit_button("Mettre à jour les informations de sécurité")
|
91 |
+
|
92 |
+
if submit_security:
|
93 |
+
try:
|
94 |
+
update_data = {}
|
95 |
+
|
96 |
+
# Vérifier si l'email a changé
|
97 |
+
if new_email != current_user.get('email'):
|
98 |
+
# Vérifier si le nouvel email existe déjà
|
99 |
+
existing_email = supabase.table("users").select("id").eq("email", new_email).neq("id", user_id).execute()
|
100 |
+
if existing_email.data:
|
101 |
+
st.error("Cet email est déjà utilisé par un autre utilisateur.")
|
102 |
+
else:
|
103 |
+
update_data["email"] = new_email
|
104 |
+
|
105 |
+
# Vérifier si le mot de passe doit être mis à jour
|
106 |
+
if current_password and new_password and confirm_password:
|
107 |
+
# Vérifier l'ancien mot de passe
|
108 |
+
if not bcrypt.checkpw(current_password.encode(), current_user['password'].encode()):
|
109 |
+
st.error("Le mot de passe actuel est incorrect.")
|
110 |
+
elif new_password != confirm_password:
|
111 |
+
st.error("Les nouveaux mots de passe ne correspondent pas.")
|
112 |
+
elif len(new_password) < 8:
|
113 |
+
st.error("Le nouveau mot de passe doit contenir au moins 8 caractères.")
|
114 |
+
else:
|
115 |
+
# Hasher le nouveau mot de passe
|
116 |
+
hashed_password = bcrypt.hashpw(new_password.encode(), bcrypt.gensalt()).decode()
|
117 |
+
update_data["password"] = hashed_password
|
118 |
+
|
119 |
+
# Effectuer la mise à jour si nécessaire
|
120 |
+
if update_data:
|
121 |
+
supabase.table("users").update(update_data).eq("id", user_id).execute()
|
122 |
+
st.success("✅ Informations de sécurité mises à jour avec succès!")
|
123 |
+
if "email" in update_data:
|
124 |
+
st.warning("⚠️ Votre email a été modifié. Vous devrez vous reconnecter.")
|
125 |
+
st.session_state.clear()
|
126 |
+
st.rerun()
|
127 |
+
else:
|
128 |
+
st.info("Aucune modification à effectuer.")
|
129 |
+
|
130 |
+
except Exception as e:
|
131 |
+
st.error(f"Une erreur est survenue : {str(e)}")
|
132 |
+
|
133 |
+
|
134 |
+
|
135 |
+
# Section Préférences de Notification
|
136 |
+
st.header("🔔 Préférences de Notification")
|
137 |
+
|
138 |
+
# Récupérer les préférences actuelles
|
139 |
+
notification_prefs = supabase.table("notification_preferences").select("*").eq("user_id", user_id).execute()
|
140 |
+
current_prefs = notification_prefs.data[0] if notification_prefs.data else {
|
141 |
+
"email_notifications": True,
|
142 |
+
"security_alerts": True,
|
143 |
+
"assistant_updates": True,
|
144 |
+
"daily_summary": False
|
145 |
+
}
|
146 |
+
|
147 |
+
with st.form("notification_preferences_form"):
|
148 |
+
st.markdown("Choisissez les notifications que vous souhaitez recevoir :")
|
149 |
+
|
150 |
+
email_notif = st.toggle(
|
151 |
+
"Notifications par email",
|
152 |
+
value=current_prefs.get("email_notifications", True),
|
153 |
+
help="Recevoir les notifications par email"
|
154 |
+
)
|
155 |
+
|
156 |
+
security_alerts = st.toggle(
|
157 |
+
"Alertes de sécurité",
|
158 |
+
value=current_prefs.get("security_alerts", True),
|
159 |
+
help="Notifications concernant la sécurité de votre compte"
|
160 |
+
)
|
161 |
+
|
162 |
+
assistant_updates = st.toggle(
|
163 |
+
"Mises à jour des assistants",
|
164 |
+
value=current_prefs.get("assistant_updates", True),
|
165 |
+
help="Notifications sur les nouvelles fonctionnalités des assistants"
|
166 |
+
)
|
167 |
+
|
168 |
+
daily_summary = st.toggle(
|
169 |
+
"Résumé quotidien",
|
170 |
+
value=current_prefs.get("daily_summary", False),
|
171 |
+
help="Recevoir un résumé quotidien de vos interactions avec les assistants"
|
172 |
+
)
|
173 |
+
|
174 |
+
submit_notif = st.form_submit_button("Enregistrer mes préférences")
|
175 |
+
|
176 |
+
if submit_notif:
|
177 |
+
try:
|
178 |
+
# Préparer les données
|
179 |
+
notif_data = {
|
180 |
+
"user_id": user_id,
|
181 |
+
"email_notifications": email_notif,
|
182 |
+
"security_alerts": security_alerts,
|
183 |
+
"assistant_updates": assistant_updates,
|
184 |
+
"daily_summary": daily_summary,
|
185 |
+
"updated_at": "NOW()"
|
186 |
+
}
|
187 |
+
|
188 |
+
# Vérifier si des préférences existent déjà
|
189 |
+
if notification_prefs.data:
|
190 |
+
# Mettre à jour les préférences existantes
|
191 |
+
supabase.table("notification_preferences").update(notif_data).eq("user_id", user_id).execute()
|
192 |
+
else:
|
193 |
+
# Créer de nouvelles préférences
|
194 |
+
notif_data["created_at"] = "NOW()"
|
195 |
+
supabase.table("notification_preferences").insert(notif_data).execute()
|
196 |
+
|
197 |
+
st.success("✅ Préférences de notification mises à jour avec succès!")
|
198 |
+
except Exception as e:
|
199 |
+
st.error(f"Une erreur est survenue : {str(e)}")
|
200 |
+
|
201 |
+
|
202 |
+
|
203 |
+
# Maintenant que nous avons implémenté le système de préférences de notification dans le fichoer actuel, nous ajoutons la fonctionnalité pour envoyer effectivement ces notifications aux utilisateurs.
|
204 |
+
# Nous créyons un nouveau module pour gérer l'envoi des notifications.
|
205 |
+
# Voici les étapes :
|
206 |
+
# Créez un nouveau fichier utils/notification_handler.py qui gérera l'envoi des notifications.
|
207 |
+
|
208 |
+
|
209 |
+
|
210 |
+
# tester les notification (fonctions temporaires à supprimer en prod)
|
211 |
+
st.header("🧪 Test des Notifications")
|
212 |
+
|
213 |
+
test_col1, test_col2 = st.columns(2)
|
214 |
+
|
215 |
+
with test_col1:
|
216 |
+
with st.form("test_security_notification"):
|
217 |
+
st.subheader("Test Alerte de Sécurité")
|
218 |
+
test_security_msg = st.text_input(
|
219 |
+
"Message de test (sécurité)",
|
220 |
+
value="Ceci est un test d'alerte de sécurité"
|
221 |
+
)
|
222 |
+
if st.form_submit_button("Tester Alerte Sécurité"):
|
223 |
+
from utils.notification_handler import notify_security_alert
|
224 |
+
if notify_security_alert(user_id, test_security_msg):
|
225 |
+
st.success("✅ Notification de sécurité envoyée!")
|
226 |
+
else:
|
227 |
+
st.error("❌ Erreur lors de l'envoi de la notification")
|
228 |
+
|
229 |
+
with test_col2:
|
230 |
+
with st.form("test_assistant_notification"):
|
231 |
+
st.subheader("Test Mise à jour Assistant")
|
232 |
+
test_assistant_msg = st.text_input(
|
233 |
+
"Message de test (assistant)",
|
234 |
+
value="Ceci est un test de mise à jour assistant"
|
235 |
+
)
|
236 |
+
if st.form_submit_button("Tester Notification Assistant"):
|
237 |
+
from utils.notification_handler import notify_assistant_update
|
238 |
+
if notify_assistant_update(user_id, test_assistant_msg):
|
239 |
+
st.success("✅ Notification assistant envoyée!")
|
240 |
+
else:
|
241 |
+
st.error("❌ Erreur lors de l'envoi de la notification")
|
242 |
+
|
243 |
+
# Test du résumé quotidien
|
244 |
+
with st.form("test_daily_summary"):
|
245 |
+
st.subheader("Test Résumé Quotidien")
|
246 |
+
test_summary = {
|
247 |
+
"conversations": 5,
|
248 |
+
"questions": 15,
|
249 |
+
"usage_time": 45
|
250 |
+
}
|
251 |
+
|
252 |
+
st.write("Données de test :")
|
253 |
+
st.json(test_summary)
|
254 |
+
|
255 |
+
if st.form_submit_button("Tester Résumé Quotidien"):
|
256 |
+
from utils.notification_handler import send_daily_summary
|
257 |
+
if send_daily_summary(user_id, test_summary):
|
258 |
+
st.success("✅ Résumé quotidien envoyé!")
|
259 |
+
else:
|
260 |
+
st.error("❌ Erreur lors de l'envoi du résumé")
|
pages/reset_password.py
ADDED
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import streamlit as st
|
2 |
+
import sys
|
3 |
+
import os
|
4 |
+
import time
|
5 |
+
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
6 |
+
from utils.password_reset import verify_reset_token, reset_password
|
7 |
+
|
8 |
+
# Configuration de la page pour cacher complètement la sidebar
|
9 |
+
st.set_page_config(
|
10 |
+
page_title="Réinitialisation du mot de passe",
|
11 |
+
page_icon="🔒",
|
12 |
+
layout="centered",
|
13 |
+
initial_sidebar_state="collapsed"
|
14 |
+
)
|
15 |
+
|
16 |
+
# Cacher tous les éléments de la sidebar et le menu hamburger
|
17 |
+
hide_streamlit_style = """
|
18 |
+
<style>
|
19 |
+
#MainMenu {visibility: hidden;}
|
20 |
+
header {visibility: hidden;}
|
21 |
+
.css-1544g2n {display: none;}
|
22 |
+
.css-14xtw13 e8zbici0 {display: none;}
|
23 |
+
section[data-testid="stSidebar"] {display: none;}
|
24 |
+
</style>
|
25 |
+
"""
|
26 |
+
st.markdown(hide_streamlit_style, unsafe_allow_html=True)
|
27 |
+
|
28 |
+
# Titre de la page
|
29 |
+
st.title("Réinitialisation du mot de passe")
|
30 |
+
|
31 |
+
# Récupérer le token depuis l'URL
|
32 |
+
token = st.query_params.get("token", None)
|
33 |
+
|
34 |
+
if not token:
|
35 |
+
st.error("Token de réinitialisation manquant.")
|
36 |
+
st.stop()
|
37 |
+
|
38 |
+
# Vérifier la validité du token
|
39 |
+
valid, user_id = verify_reset_token(token)
|
40 |
+
|
41 |
+
if not valid:
|
42 |
+
st.error("Le lien de réinitialisation est invalide ou a expiré.")
|
43 |
+
time.sleep(3) # Attendre 3 secondes
|
44 |
+
st.switch_page("app.py") # Rediriger vers la page de connexion
|
45 |
+
st.stop()
|
46 |
+
|
47 |
+
# Formulaire de nouveau mot de passe
|
48 |
+
with st.form("reset_password_form"):
|
49 |
+
new_password = st.text_input("Nouveau mot de passe", type="password")
|
50 |
+
confirm_password = st.text_input("Confirmez le mot de passe", type="password")
|
51 |
+
|
52 |
+
submitted = st.form_submit_button("Réinitialiser le mot de passe")
|
53 |
+
|
54 |
+
if submitted:
|
55 |
+
if new_password != confirm_password:
|
56 |
+
st.error("Les mots de passe ne correspondent pas.")
|
57 |
+
elif len(new_password) < 8:
|
58 |
+
st.error("Le mot de passe doit contenir au moins 8 caractères.")
|
59 |
+
else:
|
60 |
+
success, message = reset_password(token, new_password)
|
61 |
+
if success:
|
62 |
+
st.success("Votre mot de passe a été mis à jour avec succès.")
|
63 |
+
st.info("Redirection vers la page de connexion...")
|
64 |
+
time.sleep(3) # Attendre 3 secondes
|
65 |
+
st.switch_page("app.py") # Rediriger vers la page de connexion
|
66 |
+
else:
|
67 |
+
st.error(message)
|
utils/admin.py
ADDED
@@ -0,0 +1,216 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from typing import Dict, List, Optional, Tuple
|
2 |
+
from datetime import datetime
|
3 |
+
import pytz
|
4 |
+
from supabase import Client, create_client
|
5 |
+
import os
|
6 |
+
from dotenv import load_dotenv
|
7 |
+
import logging # gardez l'import de logging même si nous n'utilisons plus directement ses fonctions de log, car nous avons toujours besoin de ses constantes de niveau (level=logging.INFO/ERROR/WARNING)
|
8 |
+
from utils.logging_utils import log_to_file # logging définis et importés depuis le fichier dédié: logging_utils.py
|
9 |
+
|
10 |
+
|
11 |
+
"""
|
12 |
+
le fichier utils/admin.py gère :
|
13 |
+
La gestion des utilisateurs
|
14 |
+
Les permissions des assistants
|
15 |
+
Les statistiques utilisateurs
|
16 |
+
Les mises à jour des informations utilisateur
|
17 |
+
|
18 |
+
Pour tester les modifications après, nous pourrons :
|
19 |
+
Nous connecter en tant qu'administrateur
|
20 |
+
Effectuer des opérations dans le dashboard admin (modifier un utilisateur, changer des permissions, etc.)
|
21 |
+
"""
|
22 |
+
|
23 |
+
|
24 |
+
# Charger les variables d'environnement
|
25 |
+
load_dotenv()
|
26 |
+
supabase: Client = create_client(os.getenv("SUPABASE_URL"), os.getenv("SUPABASE_KEY"))
|
27 |
+
|
28 |
+
|
29 |
+
def get_all_users() -> List[Dict]:
|
30 |
+
"""
|
31 |
+
Récupère la liste de tous les utilisateurs (fonction admin).
|
32 |
+
|
33 |
+
Returns:
|
34 |
+
List[Dict]: Liste des utilisateurs avec leurs informations
|
35 |
+
"""
|
36 |
+
try:
|
37 |
+
response = supabase.table("users").select("*").execute()
|
38 |
+
return response.data if response.data else []
|
39 |
+
except Exception as e:
|
40 |
+
log_to_file(f"Erreur lors de la récupération des utilisateurs : {str(e)}", level=logging.ERROR)
|
41 |
+
return []
|
42 |
+
|
43 |
+
def toggle_user_status(user_id: int, is_active: bool) -> Tuple[bool, str]:
|
44 |
+
"""
|
45 |
+
Active ou désactive un compte utilisateur (fonction admin).
|
46 |
+
|
47 |
+
Args:
|
48 |
+
user_id (int): ID de l'utilisateur
|
49 |
+
is_active (bool): Nouvel état du compte
|
50 |
+
|
51 |
+
Returns:
|
52 |
+
Tuple[bool, str]: (Succès, Message)
|
53 |
+
"""
|
54 |
+
try:
|
55 |
+
supabase.table("users").update({"is_active": is_active}).eq("id", user_id).execute()
|
56 |
+
status = "activé" if is_active else "désactivé"
|
57 |
+
return True, f"Compte utilisateur {status} avec succès"
|
58 |
+
except Exception as e:
|
59 |
+
log_to_file(f"Erreur lors de la modification du statut utilisateur : {str(e)}", level=logging.ERROR)
|
60 |
+
return False, f"Erreur lors de la modification du statut : {str(e)}"
|
61 |
+
|
62 |
+
def change_user_role(user_id: int, new_role: str) -> Tuple[bool, str]:
|
63 |
+
"""
|
64 |
+
Modifie le rôle d'un utilisateur (fonction admin).
|
65 |
+
|
66 |
+
Args:
|
67 |
+
user_id (int): ID de l'utilisateur
|
68 |
+
new_role (str): Nouveau rôle ('admin' ou 'user')
|
69 |
+
|
70 |
+
Returns:
|
71 |
+
Tuple[bool, str]: (Succès, Message)
|
72 |
+
"""
|
73 |
+
if new_role not in ['admin', 'user']:
|
74 |
+
return False, "Rôle invalide"
|
75 |
+
|
76 |
+
try:
|
77 |
+
supabase.table("users").update({"role": new_role}).eq("id", user_id).execute()
|
78 |
+
return True, f"Rôle modifié avec succès en {new_role}"
|
79 |
+
except Exception as e:
|
80 |
+
log_to_file(f"Erreur lors de la modification du rôle : {str(e)}", level=logging.ERROR)
|
81 |
+
return False, f"Erreur lors de la modification du rôle : {str(e)}"
|
82 |
+
|
83 |
+
def get_user_stats() -> Dict:
|
84 |
+
"""
|
85 |
+
Récupère des statistiques sur les utilisateurs (fonction admin).
|
86 |
+
|
87 |
+
Returns:
|
88 |
+
Dict: Statistiques des utilisateurs
|
89 |
+
"""
|
90 |
+
try:
|
91 |
+
total = supabase.table("users").select("count", count="exact").execute()
|
92 |
+
active = supabase.table("users").select("count", count="exact").eq("is_active", True).execute()
|
93 |
+
admins = supabase.table("users").select("count", count="exact").eq("role", "admin").execute()
|
94 |
+
|
95 |
+
return {
|
96 |
+
"total_users": total.count if hasattr(total, 'count') else 0,
|
97 |
+
"active_users": active.count if hasattr(active, 'count') else 0,
|
98 |
+
"admin_users": admins.count if hasattr(admins, 'count') else 0,
|
99 |
+
"last_updated": datetime.now(pytz.UTC).isoformat()
|
100 |
+
}
|
101 |
+
except Exception as e:
|
102 |
+
log_to_file(f"Erreur lors de la récupération des statistiques : {str(e)}", level=logging.ERROR)
|
103 |
+
return {
|
104 |
+
"total_users": 0,
|
105 |
+
"active_users": 0,
|
106 |
+
"admin_users": 0,
|
107 |
+
"error": str(e)
|
108 |
+
}
|
109 |
+
|
110 |
+
def update_user_info(user_id: int, updates: Dict) -> Tuple[bool, str]:
|
111 |
+
"""
|
112 |
+
Met à jour les informations d'un utilisateur (fonction admin).
|
113 |
+
|
114 |
+
Args:
|
115 |
+
user_id (int): ID de l'utilisateur
|
116 |
+
updates (Dict): Dictionnaire contenant les champs à mettre à jour
|
117 |
+
|
118 |
+
Returns:
|
119 |
+
Tuple[bool, str]: (Succès, Message)
|
120 |
+
"""
|
121 |
+
allowed_fields = {'nom', 'prenom', 'email', 'professional_info', 'is_active', 'role'}
|
122 |
+
update_data = {k: v for k, v in updates.items() if k in allowed_fields}
|
123 |
+
|
124 |
+
if not update_data:
|
125 |
+
return False, "Aucun champ valide à mettre à jour"
|
126 |
+
|
127 |
+
try:
|
128 |
+
supabase.table("users").update(update_data).eq("id", user_id).execute()
|
129 |
+
return True, "Informations utilisateur mises à jour avec succès"
|
130 |
+
except Exception as e:
|
131 |
+
log_to_file(f"Erreur lors de la mise à jour des informations utilisateur : {str(e)}", level=logging.ERROR)
|
132 |
+
return False, f"Erreur lors de la mise à jour : {str(e)}"
|
133 |
+
|
134 |
+
def get_user_permissions(user_id: int) -> Dict[str, bool]:
|
135 |
+
"""
|
136 |
+
Récupère les autorisations d'accès aux assistants pour un utilisateur.
|
137 |
+
"""
|
138 |
+
try:
|
139 |
+
response = supabase.table("user_assistant_permissions").select(
|
140 |
+
"assistant_type", "is_authorized"
|
141 |
+
).eq("user_id", user_id).execute()
|
142 |
+
|
143 |
+
# Convertir la réponse en dictionnaire
|
144 |
+
permissions = {
|
145 |
+
'insuranceSANTE': False,
|
146 |
+
'insuranceCAR': False,
|
147 |
+
'insuranceBTP': False,
|
148 |
+
'RH': False,
|
149 |
+
'Pilotage': False
|
150 |
+
}
|
151 |
+
if response.data:
|
152 |
+
for perm in response.data:
|
153 |
+
permissions[perm['assistant_type']] = perm['is_authorized']
|
154 |
+
return permissions
|
155 |
+
except Exception as e:
|
156 |
+
log_to_file(f"Erreur lors de la récupération des permissions : {str(e)}", level=logging.ERROR)
|
157 |
+
return {
|
158 |
+
'insuranceSANTE': False,
|
159 |
+
'insuranceCAR': False,
|
160 |
+
'insuranceBTP': False,
|
161 |
+
'RH': False,
|
162 |
+
'Pilotage': False
|
163 |
+
}
|
164 |
+
|
165 |
+
|
166 |
+
def update_user_permissions(user_id: int, assistant_type: str, is_authorized: bool) -> Tuple[bool, str]:
|
167 |
+
"""
|
168 |
+
Met à jour les autorisations d'accès d'un utilisateur pour un assistant spécifique.
|
169 |
+
"""
|
170 |
+
try:
|
171 |
+
# Convertir user_id en int standard Python
|
172 |
+
user_id = int(user_id) # Ajoutez cette ligne
|
173 |
+
|
174 |
+
# Vérifier si une permission existe déjà
|
175 |
+
existing = supabase.table("user_assistant_permissions").select("*").eq(
|
176 |
+
"user_id", user_id
|
177 |
+
).eq("assistant_type", assistant_type).execute()
|
178 |
+
|
179 |
+
if existing.data:
|
180 |
+
# Mettre à jour la permission existante
|
181 |
+
supabase.table("user_assistant_permissions").update({
|
182 |
+
"is_authorized": is_authorized,
|
183 |
+
"modified_at": datetime.now(pytz.UTC).isoformat()
|
184 |
+
}).eq("user_id", user_id).eq("assistant_type", assistant_type).execute()
|
185 |
+
else:
|
186 |
+
# Créer une nouvelle permission
|
187 |
+
supabase.table("user_assistant_permissions").insert({
|
188 |
+
"user_id": user_id,
|
189 |
+
"assistant_type": assistant_type,
|
190 |
+
"is_authorized": is_authorized
|
191 |
+
}).execute()
|
192 |
+
|
193 |
+
return True, f"Autorisation {'accordée' if is_authorized else 'retirée'} avec succès"
|
194 |
+
except Exception as e:
|
195 |
+
log_to_file(f"Erreur lors de la mise à jour des permissions : {str(e)}", level=logging.ERROR)
|
196 |
+
return False, str(e)
|
197 |
+
|
198 |
+
|
199 |
+
def initialize_user_permissions(user_id: int) -> bool:
|
200 |
+
"""
|
201 |
+
Initialise les permissions par défaut pour un nouvel utilisateur.
|
202 |
+
"""
|
203 |
+
try:
|
204 |
+
default_permissions = [
|
205 |
+
{"user_id": user_id, "assistant_type": "insuranceSANTE", "is_authorized": False},
|
206 |
+
{"user_id": user_id, "assistant_type": "insuranceCAR", "is_authorized": False},
|
207 |
+
{"user_id": user_id, "assistant_type": "insuranceBTP", "is_authorized": False},
|
208 |
+
{"user_id": user_id, "assistant_type": "Pilotage", "is_authorized": False},
|
209 |
+
{"user_id": user_id, "assistant_type": "RH", "is_authorized": False},
|
210 |
+
]
|
211 |
+
|
212 |
+
supabase.table("user_assistant_permissions").insert(default_permissions).execute()
|
213 |
+
return True
|
214 |
+
except Exception as e:
|
215 |
+
log_to_file(f"Erreur lors de l'initialisation des permissions : {str(e)}", level=logging.ERROR)
|
216 |
+
return False
|
utils/async_flowise.py
ADDED
@@ -0,0 +1,198 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import asyncio
|
2 |
+
import aiohttp
|
3 |
+
import logging
|
4 |
+
from typing import Dict, Optional, Any, AsyncGenerator
|
5 |
+
import json
|
6 |
+
from datetime import datetime, timedelta
|
7 |
+
import pytz
|
8 |
+
from utils.logging_utils import log_to_file
|
9 |
+
import os
|
10 |
+
import hashlib
|
11 |
+
from dotenv import load_dotenv
|
12 |
+
from utils.rate_limiter import RateLimiter
|
13 |
+
from utils.circuit_breaker import AsyncCircuitBreaker
|
14 |
+
from utils.cache_manager import CacheManager
|
15 |
+
|
16 |
+
FLOWISE_API_URL_SANTE = os.getenv("FLOWISE_API_URL_SANTE")
|
17 |
+
FLOWISE_API_URL_CAR = os.getenv("FLOWISE_API_URL_CAR")
|
18 |
+
FLOWISE_API_URL_BTP = os.getenv("FLOWISE_API_URL_BTP")
|
19 |
+
FLOWISE_API_URL_RH = os.getenv("FLOWISE_API_URL_RH")
|
20 |
+
FLOWISE_API_URL_PILOTAGE = os.getenv("FLOWISE_API_URL_PILOTAGE")
|
21 |
+
|
22 |
+
class AsyncFlowiseClient:
|
23 |
+
def __init__(self):
|
24 |
+
"""Initialise le client asynchrone pour Flowise"""
|
25 |
+
self.api_urls = {
|
26 |
+
'insuranceSANTE': FLOWISE_API_URL_SANTE,
|
27 |
+
'insuranceCAR': FLOWISE_API_URL_CAR,
|
28 |
+
'insuranceBTP': FLOWISE_API_URL_BTP,
|
29 |
+
'RH': FLOWISE_API_URL_RH,
|
30 |
+
'Pilotage': FLOWISE_API_URL_PILOTAGE
|
31 |
+
}
|
32 |
+
self.session = None
|
33 |
+
self.rate_limiter = RateLimiter(requests_per_second=5, burst_limit=10)
|
34 |
+
self.circuit_breaker = AsyncCircuitBreaker(
|
35 |
+
failure_threshold=5,
|
36 |
+
recovery_timeout=60,
|
37 |
+
half_open_timeout=30
|
38 |
+
)
|
39 |
+
self.cache_manager = CacheManager()
|
40 |
+
|
41 |
+
def _generate_cache_key(self, question: str, assistant_type: str) -> str:
|
42 |
+
"""Génère une clé de cache unique pour une question"""
|
43 |
+
question_hash = hashlib.md5(question.encode()).hexdigest()
|
44 |
+
return f"assistant_response:{assistant_type}:{question_hash}"
|
45 |
+
|
46 |
+
def _clean_question(self, question: str) -> str:
|
47 |
+
"""Nettoie la question des métadonnées"""
|
48 |
+
try:
|
49 |
+
data = json.loads(question)
|
50 |
+
if isinstance(data, dict) and "next_inputs" in data and "query" in data["next_inputs"]:
|
51 |
+
return data["next_inputs"]["query"]
|
52 |
+
except json.JSONDecodeError:
|
53 |
+
pass
|
54 |
+
return question
|
55 |
+
|
56 |
+
def _process_response(self, response_data: Any) -> Dict[str, str]:
|
57 |
+
"""Extrait uniquement le texte de la réponse"""
|
58 |
+
try:
|
59 |
+
# Si c'est une chaîne, essayer de la parser comme JSON
|
60 |
+
if isinstance(response_data, str):
|
61 |
+
try:
|
62 |
+
data = json.loads(response_data)
|
63 |
+
if isinstance(data, dict) and "text" in data:
|
64 |
+
return {"answer": data["text"]}
|
65 |
+
except json.JSONDecodeError:
|
66 |
+
return {"answer": response_data}
|
67 |
+
|
68 |
+
# Si c'est déjà un dictionnaire
|
69 |
+
if isinstance(response_data, dict) and "text" in response_data:
|
70 |
+
return {"answer": response_data["text"]}
|
71 |
+
|
72 |
+
# Si on ne peut pas extraire le texte, retourner une erreur
|
73 |
+
return {"error": "Format de réponse non reconnu"}
|
74 |
+
|
75 |
+
except Exception as e:
|
76 |
+
log_to_file(f"Erreur lors du traitement de la réponse : {str(e)}", level=logging.ERROR)
|
77 |
+
return {"error": "Format de réponse non reconnu"}
|
78 |
+
|
79 |
+
async def query_assistant_stream(self, question: str, assistant_type: str, user_id: Optional[str] = None) -> AsyncGenerator[str, None]:
|
80 |
+
"""Envoie une requête en streaming à Flowise"""
|
81 |
+
if assistant_type not in self.api_urls:
|
82 |
+
log_to_file(f"Type d'assistant non valide : {assistant_type}", level=logging.ERROR)
|
83 |
+
yield json.dumps({"error": f"Type d'assistant non valide : {assistant_type}"})
|
84 |
+
return
|
85 |
+
|
86 |
+
if not await self.rate_limiter.acquire(user_id):
|
87 |
+
yield json.dumps({"error": "Trop de requêtes. Veuillez réessayer dans quelques instants."})
|
88 |
+
return
|
89 |
+
|
90 |
+
if not self.session:
|
91 |
+
self.session = aiohttp.ClientSession()
|
92 |
+
|
93 |
+
try:
|
94 |
+
url = self.api_urls[assistant_type]
|
95 |
+
clean_question = self._clean_question(question)
|
96 |
+
payload = {"question": clean_question}
|
97 |
+
|
98 |
+
async with self.session.post(url, json=payload) as response:
|
99 |
+
if response.status != 200:
|
100 |
+
error_text = await response.text()
|
101 |
+
log_to_file(f"Erreur lors de la requête à {assistant_type}: {error_text}", level=logging.ERROR)
|
102 |
+
yield json.dumps({"error": f"Erreur {response.status}: {error_text}"})
|
103 |
+
return
|
104 |
+
|
105 |
+
async for chunk in response.content.iter_any():
|
106 |
+
if chunk:
|
107 |
+
try:
|
108 |
+
text = chunk.decode('utf-8')
|
109 |
+
processed_response = self._process_response(text)
|
110 |
+
if "answer" in processed_response:
|
111 |
+
yield processed_response["answer"]
|
112 |
+
except Exception as e:
|
113 |
+
log_to_file(f"Erreur de décodage du chunk : {str(e)}", level=logging.ERROR)
|
114 |
+
continue
|
115 |
+
|
116 |
+
except Exception as e:
|
117 |
+
error_message = str(e)
|
118 |
+
log_to_file(f"Erreur lors du streaming pour {assistant_type}: {error_message}", level=logging.ERROR)
|
119 |
+
yield json.dumps({"error": error_message})
|
120 |
+
|
121 |
+
async def query_assistant(self, question: str, assistant_type: str, user_id: Optional[str] = None) -> Dict[str, Any]:
|
122 |
+
"""Envoie une requête asynchrone à Flowise avec cache"""
|
123 |
+
if assistant_type not in self.api_urls:
|
124 |
+
log_to_file(f"Type d'assistant non valide : {assistant_type}", level=logging.ERROR)
|
125 |
+
return {"error": f"Type d'assistant non valide : {assistant_type}"}
|
126 |
+
|
127 |
+
if not await self.rate_limiter.acquire(user_id):
|
128 |
+
return {"error": "Trop de requêtes. Veuillez réessayer dans quelques instants."}
|
129 |
+
|
130 |
+
cache_key = self._generate_cache_key(question, assistant_type)
|
131 |
+
cached_response = await self.cache_manager.get(cache_key)
|
132 |
+
|
133 |
+
if cached_response:
|
134 |
+
log_to_file(f"Réponse trouvée dans le cache pour {assistant_type}", level=logging.INFO)
|
135 |
+
return cached_response
|
136 |
+
|
137 |
+
if not self.session:
|
138 |
+
self.session = aiohttp.ClientSession()
|
139 |
+
|
140 |
+
try:
|
141 |
+
url = self.api_urls[assistant_type]
|
142 |
+
clean_question = self._clean_question(question)
|
143 |
+
payload = {"question": clean_question}
|
144 |
+
|
145 |
+
async with self.session.post(url, json=payload) as response:
|
146 |
+
if response.status != 200:
|
147 |
+
error_text = await response.text()
|
148 |
+
log_to_file(f"Erreur lors de la requête à {assistant_type}: {error_text}", level=logging.ERROR)
|
149 |
+
return {"error": f"Erreur {response.status}: {error_text}"}
|
150 |
+
|
151 |
+
response_text = await response.text()
|
152 |
+
processed_response = self._process_response(response_text)
|
153 |
+
|
154 |
+
if "error" not in processed_response:
|
155 |
+
await self.cache_manager.set(
|
156 |
+
cache_key,
|
157 |
+
processed_response,
|
158 |
+
expiry=timedelta(hours=1)
|
159 |
+
)
|
160 |
+
log_to_file(f"Réponse mise en cache pour {assistant_type}", level=logging.INFO)
|
161 |
+
|
162 |
+
return processed_response
|
163 |
+
|
164 |
+
except Exception as e:
|
165 |
+
error_message = str(e)
|
166 |
+
if "Circuit breaker ouvert" in error_message:
|
167 |
+
log_to_file(f"Service {assistant_type} temporairement indisponible (Circuit breaker)", level=logging.ERROR)
|
168 |
+
return {"error": "Service temporairement indisponible. Veuillez réessayer plus tard."}
|
169 |
+
|
170 |
+
log_to_file(f"Erreur lors de la requête à {assistant_type}: {error_message}", level=logging.ERROR)
|
171 |
+
return {"error": error_message}
|
172 |
+
|
173 |
+
async def check_health(self) -> Dict[str, bool]:
|
174 |
+
"""Vérifie la santé des connexions aux différents assistants"""
|
175 |
+
if not self.session:
|
176 |
+
self.session = aiohttp.ClientSession()
|
177 |
+
|
178 |
+
results = {}
|
179 |
+
for assistant_type, url in self.api_urls.items():
|
180 |
+
try:
|
181 |
+
base_url = '/'.join(url.split('/')[:-4]) # Obtenir l'URL de base
|
182 |
+
async with self.session.get(
|
183 |
+
f"{base_url}/health",
|
184 |
+
timeout=aiohttp.ClientTimeout(total=5)
|
185 |
+
) as response:
|
186 |
+
results[assistant_type] = response.status == 200
|
187 |
+
log_to_file(f"Statut santé {assistant_type}: {response.status}", level=logging.INFO)
|
188 |
+
except Exception as e:
|
189 |
+
log_to_file(f"Erreur de santé pour {assistant_type}: {str(e)}", level=logging.ERROR)
|
190 |
+
results[assistant_type] = False
|
191 |
+
|
192 |
+
return results
|
193 |
+
|
194 |
+
async def close(self):
|
195 |
+
"""Ferme la session client"""
|
196 |
+
if self.session:
|
197 |
+
await self.session.close()
|
198 |
+
self.session = None
|
utils/auth_cache.py
ADDED
@@ -0,0 +1,277 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import json
|
2 |
+
from datetime import datetime, timedelta
|
3 |
+
import pytz
|
4 |
+
from typing import Optional, List, Dict, Any
|
5 |
+
import logging
|
6 |
+
from utils.logging_utils import log_to_file
|
7 |
+
from utils.cache_manager import CacheManager
|
8 |
+
from utils.admin import supabase
|
9 |
+
|
10 |
+
class AuthenticationCache:
|
11 |
+
def __init__(self):
|
12 |
+
self.cache_manager = CacheManager() # initialise la connexion Redis
|
13 |
+
self.cache_duration = timedelta(hours=24) # Duree de validité du cache (24h)
|
14 |
+
self.cache_version_key = "auth_cache_version" # Pour suivre la version du cache
|
15 |
+
self.batch_size = 2 # Taille du batch pour le chargement par lots
|
16 |
+
self.session_cache_key = "user_session:"
|
17 |
+
self.history_cache_key = "chat_history:" # Ajout de cet attribut
|
18 |
+
|
19 |
+
async def is_cache_initialized(self) -> bool:
|
20 |
+
"""Vérifie si le cache est déjà initialisé"""
|
21 |
+
try:
|
22 |
+
all_keys = self.cache_manager.redis_client.keys("auth_user:*")
|
23 |
+
return len(all_keys) > 0
|
24 |
+
except Exception as e:
|
25 |
+
log_to_file(f"Erreur lors de la vérification du cache : {str(e)}", level=logging.ERROR)
|
26 |
+
return False
|
27 |
+
|
28 |
+
async def get_cache_version(self) -> str:
|
29 |
+
"""Récupère la version actuelle du cache"""
|
30 |
+
try:
|
31 |
+
return await self.cache_manager.get(self.cache_version_key) or ""
|
32 |
+
except Exception as e:
|
33 |
+
log_to_file(f"Erreur lors de la récupération de la version du cache : {str(e)}", level=logging.ERROR)
|
34 |
+
return ""
|
35 |
+
|
36 |
+
async def update_cache_version(self) -> None:
|
37 |
+
"""Met à jour la version du cache"""
|
38 |
+
try:
|
39 |
+
current_time = datetime.now(pytz.UTC).isoformat()
|
40 |
+
await self.cache_manager.set(self.cache_version_key, current_time, expiry=self.cache_duration)
|
41 |
+
except Exception as e:
|
42 |
+
log_to_file(f"Erreur lors de la mise à jour de la version du cache : {str(e)}", level=logging.ERROR)
|
43 |
+
|
44 |
+
async def is_cache_outdated(self) -> bool:
|
45 |
+
"""Vérifie si le cache doit être mis à jour"""
|
46 |
+
try:
|
47 |
+
# Vérifier la dernière mise à jour du cache
|
48 |
+
cache_version = await self.get_cache_version()
|
49 |
+
if not cache_version:
|
50 |
+
return True
|
51 |
+
|
52 |
+
# Vérifier les modifications récentes dans Supabase
|
53 |
+
last_update = datetime.fromisoformat(cache_version)
|
54 |
+
recent_changes = supabase.table("users").select("updated_at").gt(
|
55 |
+
"updated_at", last_update.isoformat()
|
56 |
+
).execute()
|
57 |
+
|
58 |
+
return bool(recent_changes.data)
|
59 |
+
except Exception as e:
|
60 |
+
log_to_file(f"Erreur lors de la vérification de l'état du cache : {str(e)}", level=logging.ERROR)
|
61 |
+
return True
|
62 |
+
|
63 |
+
async def get_user_from_cache(self, email: str) -> Optional[Dict[str, Any]]:
|
64 |
+
"""Récupère les informations utilisateur depuis le cache.
|
65 |
+
Args:
|
66 |
+
email: L'adresse email de l'utilisateur à rechercher dans le cache.
|
67 |
+
|
68 |
+
Returns:
|
69 |
+
Un dictionnaire contenant les informations de l'utilisateur si trouvé dans le cache, None sinon.
|
70 |
+
"""
|
71 |
+
try:
|
72 |
+
# Construit la clé de cache en utilisant l'email.
|
73 |
+
cache_key = f"auth_user:{email}"
|
74 |
+
# Récupère l'utilisateur depuis le cache en utilisant la clé.
|
75 |
+
return await self.cache_manager.get(cache_key)
|
76 |
+
except Exception as e:
|
77 |
+
# Enregistre une erreur si une exception se produit pendant la récupération du cache.
|
78 |
+
log_to_file(f"Erreur lors de la récupération du cache utilisateur : {str(e)}", level=logging.ERROR)
|
79 |
+
# Retourne None en cas d'erreur.
|
80 |
+
return None
|
81 |
+
|
82 |
+
async def set_user_in_cache(self, user_data: Dict[str, Any]) -> bool:
|
83 |
+
"""Stocke les informations utilisateur dans le cache"""
|
84 |
+
try:
|
85 |
+
cache_key = f"auth_user:{user_data['email']}"
|
86 |
+
return await self.cache_manager.set(cache_key, user_data, expiry=self.cache_duration)
|
87 |
+
except Exception as e:
|
88 |
+
log_to_file(f"Erreur lors du stockage en cache : {str(e)}", level=logging.ERROR)
|
89 |
+
return False
|
90 |
+
|
91 |
+
async def load_all_users_to_cache(self) -> bool:
|
92 |
+
try:
|
93 |
+
log_to_file("Début du chargement des utilisateurs dans Redis", level=logging.INFO)
|
94 |
+
|
95 |
+
# Récupérer le nombre total d'utilisateurs
|
96 |
+
total_users = supabase.table("users").select("id", count="exact").execute()
|
97 |
+
total_count = total_users.count
|
98 |
+
log_to_file(f"Nombre total d'utilisateurs : {total_count}", level=logging.INFO)
|
99 |
+
|
100 |
+
# Charger les utilisateurs par lots
|
101 |
+
loaded_count = 0
|
102 |
+
for offset in range(0, total_count, self.batch_size):
|
103 |
+
log_to_file(f"Chargement du lot {offset} - {offset + self.batch_size - 1}", level=logging.INFO)
|
104 |
+
users = supabase.table("users").select("*").range(offset, offset + self.batch_size - 1).execute()
|
105 |
+
if users.data:
|
106 |
+
for user in users.data:
|
107 |
+
if await self.set_user_in_cache(user):
|
108 |
+
loaded_count += 1
|
109 |
+
log_to_file(f"Utilisateur {user['email']} mis en cache", level=logging.INFO)
|
110 |
+
else:
|
111 |
+
log_to_file(f"Échec du chargement de l'utilisateur : {user['email']}", level=logging.ERROR)
|
112 |
+
else:
|
113 |
+
log_to_file(f"Aucun utilisateur trouvé dans la plage {offset} - {offset + self.batch_size - 1}", level=logging.WARNING)
|
114 |
+
|
115 |
+
log_to_file(f"Chargement terminé : {loaded_count}/{total_count} utilisateurs mis en cache", level=logging.INFO)
|
116 |
+
|
117 |
+
# Mettre à jour la version du cache
|
118 |
+
await self.update_cache_version()
|
119 |
+
|
120 |
+
# Debug
|
121 |
+
await self.debug_redis_content()
|
122 |
+
|
123 |
+
return True
|
124 |
+
except Exception as e:
|
125 |
+
log_to_file(f"Erreur lors du chargement des utilisateurs : {str(e)}", level=logging.ERROR)
|
126 |
+
return False
|
127 |
+
|
128 |
+
|
129 |
+
async def sync_cache_with_db(self) -> bool:
|
130 |
+
"""Synchronise le cache avec la base de données"""
|
131 |
+
try:
|
132 |
+
cache_version = await self.get_cache_version()
|
133 |
+
if not cache_version:
|
134 |
+
return await self.load_all_users_to_cache()
|
135 |
+
|
136 |
+
last_update = datetime.fromisoformat(cache_version)
|
137 |
+
updated_users = supabase.table("users").select("*").gt(
|
138 |
+
"updated_at", last_update.isoformat()
|
139 |
+
).execute()
|
140 |
+
|
141 |
+
if updated_users.data:
|
142 |
+
for user in updated_users.data:
|
143 |
+
await self.set_user_in_cache(user)
|
144 |
+
await self.update_cache_version()
|
145 |
+
log_to_file(f"Cache mis à jour avec {len(updated_users.data)} modifications", level=logging.INFO)
|
146 |
+
|
147 |
+
return True
|
148 |
+
except Exception as e:
|
149 |
+
log_to_file(f"Erreur lors de la synchronisation du cache : {str(e)}", level=logging.ERROR)
|
150 |
+
return False
|
151 |
+
|
152 |
+
|
153 |
+
async def load_user_sessions_to_cache(self) -> bool:
|
154 |
+
"""Charge toutes les sessions actives dans Redis"""
|
155 |
+
try:
|
156 |
+
log_to_file("Chargement des sessions utilisateurs dans Redis", level=logging.INFO)
|
157 |
+
|
158 |
+
# Récupérer toutes les sessions actives
|
159 |
+
sessions = supabase.table("chat_sessions").select("*").eq(
|
160 |
+
"is_active", True
|
161 |
+
).execute()
|
162 |
+
|
163 |
+
if not sessions.data:
|
164 |
+
log_to_file("Aucune session active trouvée", level=logging.INFO)
|
165 |
+
return True
|
166 |
+
|
167 |
+
# Charger chaque session dans Redis
|
168 |
+
for session in sessions.data:
|
169 |
+
session_key = f"{self.session_cache_key}{session['user_id']}"
|
170 |
+
await self.cache_manager.set(
|
171 |
+
session_key,
|
172 |
+
session,
|
173 |
+
expiry=self.cache_duration
|
174 |
+
)
|
175 |
+
|
176 |
+
# Charger l'historique associé
|
177 |
+
history = supabase.table("messages").select("*").eq(
|
178 |
+
"session_id", session['session_id']
|
179 |
+
).order('created_at', desc=False).execute()
|
180 |
+
|
181 |
+
if history.data:
|
182 |
+
history_key = f"{self.history_cache_key}{session['session_id']}"
|
183 |
+
await self.cache_manager.set(
|
184 |
+
history_key,
|
185 |
+
history.data,
|
186 |
+
expiry=self.cache_duration
|
187 |
+
)
|
188 |
+
|
189 |
+
log_to_file(f"Sessions et historiques chargés : {len(sessions.data)} sessions", level=logging.INFO)
|
190 |
+
return True
|
191 |
+
|
192 |
+
except Exception as e:
|
193 |
+
log_to_file(f"Erreur lors du chargement des sessions : {str(e)}", level=logging.ERROR)
|
194 |
+
return False
|
195 |
+
|
196 |
+
async def get_active_session(self, user_id: int) -> Optional[Dict]:
|
197 |
+
"""Récupère la session active d'un utilisateur depuis le cache"""
|
198 |
+
try:
|
199 |
+
session_key = f"{self.session_cache_key}{user_id}"
|
200 |
+
session = await self.cache_manager.get(session_key)
|
201 |
+
|
202 |
+
if not session:
|
203 |
+
# Si pas dans le cache, chercher dans Supabase et mettre en cache
|
204 |
+
db_session = supabase.table("chat_sessions").select("*").eq(
|
205 |
+
"user_id", user_id
|
206 |
+
).eq("is_active", True).execute()
|
207 |
+
|
208 |
+
if db_session.data:
|
209 |
+
session = db_session.data[0]
|
210 |
+
await self.cache_manager.set(
|
211 |
+
session_key,
|
212 |
+
session,
|
213 |
+
expiry=self.cache_duration
|
214 |
+
)
|
215 |
+
|
216 |
+
return session
|
217 |
+
except Exception as e:
|
218 |
+
log_to_file(f"Erreur lors de la récupération de la session : {str(e)}", level=logging.ERROR)
|
219 |
+
return None
|
220 |
+
|
221 |
+
async def get_chat_history(self, session_id: str) -> List[Dict]:
|
222 |
+
"""Récupère l'historique du chat depuis le cache"""
|
223 |
+
try:
|
224 |
+
history_key = f"{self.history_cache_key}{session_id}"
|
225 |
+
history = await self.cache_manager.get(history_key)
|
226 |
+
|
227 |
+
if not history:
|
228 |
+
# Si pas dans le cache, chercher dans Supabase et mettre en cache
|
229 |
+
db_history = supabase.table("messages").select("*").eq(
|
230 |
+
"session_id", session_id
|
231 |
+
).order('created_at', desc=False).execute()
|
232 |
+
|
233 |
+
if db_history.data:
|
234 |
+
history = db_history.data
|
235 |
+
await self.cache_manager.set(
|
236 |
+
history_key,
|
237 |
+
history,
|
238 |
+
expiry=self.cache_duration
|
239 |
+
)
|
240 |
+
|
241 |
+
return history or []
|
242 |
+
except Exception as e:
|
243 |
+
log_to_file(f"Erreur lors de la récupération de l'historique : {str(e)}", level=logging.ERROR)
|
244 |
+
return []
|
245 |
+
|
246 |
+
async def update_session_and_history(self, session_data: Dict, new_message: Dict = None):
|
247 |
+
"""Met à jour la session et l'historique dans le cache"""
|
248 |
+
try:
|
249 |
+
session_key = f"{self.session_cache_key}{session_data['user_id']}"
|
250 |
+
await self.cache_manager.set(
|
251 |
+
session_key,
|
252 |
+
session_data,
|
253 |
+
expiry=self.cache_duration
|
254 |
+
)
|
255 |
+
|
256 |
+
if new_message:
|
257 |
+
history_key = f"{self.history_cache_key}{session_data['session_id']}"
|
258 |
+
current_history = await self.get_chat_history(session_data['session_id'])
|
259 |
+
current_history.append(new_message)
|
260 |
+
await self.cache_manager.set(
|
261 |
+
history_key,
|
262 |
+
current_history,
|
263 |
+
expiry=self.cache_duration
|
264 |
+
)
|
265 |
+
except Exception as e:
|
266 |
+
log_to_file(f"Erreur lors de la mise à jour de la session : {str(e)}", level=logging.ERROR)
|
267 |
+
|
268 |
+
|
269 |
+
async def debug_redis_content(self):
|
270 |
+
"""Affiche le contenu de Redis pour debug"""
|
271 |
+
try:
|
272 |
+
all_keys = self.cache_manager.redis_client.keys("auth_user:*")
|
273 |
+
log_to_file(f"Nombre d'utilisateurs en cache : {len(all_keys)}", level=logging.INFO)
|
274 |
+
for key in all_keys:
|
275 |
+
log_to_file(f"Utilisateur en cache : {key}", level=logging.INFO)
|
276 |
+
except Exception as e:
|
277 |
+
log_to_file(f"Erreur debug Redis : {str(e)}", level=logging.ERROR)
|
utils/cache_manager.py
ADDED
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import redis
|
2 |
+
import json
|
3 |
+
import logging
|
4 |
+
from typing import Any, Optional
|
5 |
+
from datetime import timedelta
|
6 |
+
import os
|
7 |
+
from dotenv import load_dotenv
|
8 |
+
from utils.logging_utils import log_to_file
|
9 |
+
|
10 |
+
load_dotenv()
|
11 |
+
|
12 |
+
class CacheManager:
|
13 |
+
def __init__(self):
|
14 |
+
"""Initialise la connexion Redis"""
|
15 |
+
self.redis_client = redis.Redis(
|
16 |
+
host=os.getenv("REDIS_HOST"),
|
17 |
+
port=int(os.getenv("REDIS_PORT")),
|
18 |
+
decode_responses=True # Pour automatiquement décoder les réponses en str
|
19 |
+
)
|
20 |
+
self.default_expiry = timedelta(hours=1) # Expiration par défaut : 1 heure
|
21 |
+
|
22 |
+
async def get(self, key: str) -> Optional[Any]:
|
23 |
+
"""Récupère une valeur du cache"""
|
24 |
+
try:
|
25 |
+
value = self.redis_client.get(key)
|
26 |
+
if value:
|
27 |
+
log_to_file(f"Cache hit pour la clé: {key}", level=logging.INFO)
|
28 |
+
return json.loads(value)
|
29 |
+
log_to_file(f"Cache miss pour la clé: {key}", level=logging.INFO)
|
30 |
+
return None
|
31 |
+
except Exception as e:
|
32 |
+
log_to_file(f"Erreur lors de la lecture du cache: {str(e)}", level=logging.ERROR)
|
33 |
+
return None
|
34 |
+
|
35 |
+
async def set(self, key: str, value: Any, expiry: Optional[timedelta] = None) -> bool:
|
36 |
+
"""Enregistre une valeur dans le cache"""
|
37 |
+
try:
|
38 |
+
expiry_seconds = int((expiry or self.default_expiry).total_seconds())
|
39 |
+
success = self.redis_client.setex(
|
40 |
+
key,
|
41 |
+
expiry_seconds,
|
42 |
+
json.dumps(value)
|
43 |
+
)
|
44 |
+
if success:
|
45 |
+
log_to_file(f"Valeur mise en cache pour la clé: {key}", level=logging.INFO)
|
46 |
+
return success
|
47 |
+
except Exception as e:
|
48 |
+
log_to_file(f"Erreur lors de l'écriture dans le cache: {str(e)}", level=logging.ERROR)
|
49 |
+
return False
|
50 |
+
|
51 |
+
async def delete(self, key: str) -> bool:
|
52 |
+
"""Supprime une valeur du cache"""
|
53 |
+
try:
|
54 |
+
return bool(self.redis_client.delete(key))
|
55 |
+
except Exception as e:
|
56 |
+
log_to_file(f"Erreur lors de la suppression du cache: {str(e)}", level=logging.ERROR)
|
57 |
+
return False
|
58 |
+
|
59 |
+
async def clear_all(self) -> bool:
|
60 |
+
"""Vide tout le cache"""
|
61 |
+
try:
|
62 |
+
self.redis_client.flushall()
|
63 |
+
log_to_file("Cache entièrement vidé", level=logging.INFO)
|
64 |
+
return True
|
65 |
+
except Exception as e:
|
66 |
+
log_to_file(f"Erreur lors du vidage du cache: {str(e)}", level=logging.ERROR)
|
67 |
+
return False
|
utils/circuit_breaker.py
ADDED
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import asyncio
|
2 |
+
from enum import Enum
|
3 |
+
from datetime import datetime, timedelta
|
4 |
+
import logging
|
5 |
+
from typing import Optional, Callable, Any
|
6 |
+
from utils.logging_utils import log_to_file
|
7 |
+
|
8 |
+
class CircuitState(Enum):
|
9 |
+
CLOSED = "CLOSED" # Circuit fermé - Opérations normales
|
10 |
+
OPEN = "OPEN" # Circuit ouvert - Échecs détectés
|
11 |
+
HALF_OPEN = "HALF_OPEN" # Circuit semi-ouvert - Test de récupération
|
12 |
+
|
13 |
+
class AsyncCircuitBreaker:
|
14 |
+
def __init__(
|
15 |
+
self,
|
16 |
+
failure_threshold: int = 5,
|
17 |
+
recovery_timeout: int = 60,
|
18 |
+
half_open_timeout: int = 30
|
19 |
+
):
|
20 |
+
self.failure_threshold = failure_threshold
|
21 |
+
self.recovery_timeout = recovery_timeout
|
22 |
+
self.half_open_timeout = half_open_timeout
|
23 |
+
self.state = CircuitState.CLOSED
|
24 |
+
self.failure_count = 0
|
25 |
+
self.last_failure_time: Optional[datetime] = None
|
26 |
+
self.lock = asyncio.Lock()
|
27 |
+
|
28 |
+
async def call(self, func: Callable, *args, **kwargs) -> Any:
|
29 |
+
"""Exécute la fonction avec la logique du circuit breaker"""
|
30 |
+
async with self.lock:
|
31 |
+
if self.state == CircuitState.OPEN:
|
32 |
+
if self._should_attempt_recovery():
|
33 |
+
self.state = CircuitState.HALF_OPEN
|
34 |
+
log_to_file("Circuit breaker passé en état HALF_OPEN", level=logging.INFO)
|
35 |
+
else:
|
36 |
+
raise Exception("Circuit breaker ouvert - Service indisponible")
|
37 |
+
|
38 |
+
try:
|
39 |
+
result = await func(*args, **kwargs)
|
40 |
+
if self.state == CircuitState.HALF_OPEN:
|
41 |
+
self._reset()
|
42 |
+
return result
|
43 |
+
|
44 |
+
except Exception as e:
|
45 |
+
await self._handle_failure(e)
|
46 |
+
raise
|
47 |
+
|
48 |
+
def _should_attempt_recovery(self) -> bool:
|
49 |
+
"""Vérifie si on doit tenter une récupération"""
|
50 |
+
if not self.last_failure_time:
|
51 |
+
return True
|
52 |
+
|
53 |
+
recovery_time = self.last_failure_time + timedelta(seconds=self.recovery_timeout)
|
54 |
+
return datetime.now() > recovery_time
|
55 |
+
|
56 |
+
async def _handle_failure(self, exception: Exception) -> None:
|
57 |
+
"""Gère un échec d'appel"""
|
58 |
+
self.failure_count += 1
|
59 |
+
self.last_failure_time = datetime.now()
|
60 |
+
|
61 |
+
if self.state == CircuitState.HALF_OPEN or self.failure_count >= self.failure_threshold:
|
62 |
+
self.state = CircuitState.OPEN
|
63 |
+
log_to_file(f"Circuit breaker ouvert après {self.failure_count} échecs", level=logging.WARNING)
|
64 |
+
|
65 |
+
def _reset(self) -> None:
|
66 |
+
"""Réinitialise le circuit breaker"""
|
67 |
+
self.state = CircuitState.CLOSED
|
68 |
+
self.failure_count = 0
|
69 |
+
self.last_failure_time = None
|
70 |
+
log_to_file("Circuit breaker réinitialisé et fermé", level=logging.INFO)
|
71 |
+
|
72 |
+
def get_state(self) -> CircuitState:
|
73 |
+
"""Retourne l'état actuel du circuit breaker"""
|
74 |
+
return self.state
|
utils/email_utils.py
ADDED
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import smtplib
|
2 |
+
from email.mime.text import MIMEText
|
3 |
+
from email.mime.multipart import MIMEMultipart
|
4 |
+
import os
|
5 |
+
from dotenv import load_dotenv
|
6 |
+
from typing import Optional
|
7 |
+
from utils.logging_utils import log_to_file # remplace "import logging" car maintenat les logging sont définis et importés depuis le fichier dédié: logging_utils.py
|
8 |
+
|
9 |
+
load_dotenv()
|
10 |
+
|
11 |
+
# Configuration email
|
12 |
+
EMAIL_HOST = os.getenv("EMAIL_HOST", "smtp.gmail.com")
|
13 |
+
EMAIL_PORT = int(os.getenv("EMAIL_PORT", "587"))
|
14 |
+
EMAIL_HOST_USER = os.getenv("EMAIL_HOST_USER")
|
15 |
+
EMAIL_HOST_USER_PASSWORD = os.getenv("EMAIL_HOST_USER_PASSWORD")
|
16 |
+
DEFAULT_FROM_EMAIL = os.getenv("DEFAULT_FROM_EMAIL")
|
17 |
+
|
18 |
+
"""
|
19 |
+
Le fichier email_utils.py est responsable de l'envoi de tous les emails dans l'application, notamment :
|
20 |
+
|
21 |
+
Les emails de vérification lors de l'inscription d'un nouvel utilisateur
|
22 |
+
Les emails de réinitialisation de mot de passe quand un utilisateur clique sur "Mot de passe oublié"
|
23 |
+
Les emails de notification (activité du compte, changements de permission, etc.)
|
24 |
+
|
25 |
+
Pour tester les modifications :
|
26 |
+
Essayez de créer un nouveau compte (pour tester l'email de vérification)
|
27 |
+
Ou cliquez sur "Mot de passe oublié" sur la page de connexion
|
28 |
+
"""
|
29 |
+
|
30 |
+
# Configuration du logging
|
31 |
+
#logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
32 |
+
|
33 |
+
# Tentative de chargement du .env
|
34 |
+
env_path = '.env'
|
35 |
+
if os.path.exists(env_path):
|
36 |
+
load_dotenv(env_path)
|
37 |
+
log_to_file(f"Fichier .env trouvé et chargé")
|
38 |
+
# Afficher les variables pour vérification
|
39 |
+
log_to_file(f"EMAIL_HOST: {os.getenv('EMAIL_HOST')}")
|
40 |
+
log_to_file(f"EMAIL_PORT: {os.getenv('EMAIL_PORT')}")
|
41 |
+
log_to_file(f"EMAIL_HOST_USER: {os.getenv('EMAIL_HOST_USER')}")
|
42 |
+
log_to_file(f"DEFAULT_FROM_EMAIL: {os.getenv('DEFAULT_FROM_EMAIL')}")
|
43 |
+
# Ne pas logger le mot de passe pour des raisons de sécurité
|
44 |
+
else:
|
45 |
+
log_to_file(f"Fichier .env non trouvé à l'emplacement: {os.path.abspath(env_path)}")
|
46 |
+
|
47 |
+
def create_smtp_connection() -> Optional[smtplib.SMTP]:
|
48 |
+
"""
|
49 |
+
Crée et retourne une connexion SMTP sécurisée.
|
50 |
+
"""
|
51 |
+
try:
|
52 |
+
server = smtplib.SMTP(EMAIL_HOST, EMAIL_PORT)
|
53 |
+
server.ehlo() # Identifier le client au serveur
|
54 |
+
server.starttls() # Activer le chiffrement TLS
|
55 |
+
server.login(EMAIL_HOST_USER, EMAIL_HOST_USER_PASSWORD)
|
56 |
+
return server
|
57 |
+
except Exception as e:
|
58 |
+
log_to_file(f"Erreur lors de la connexion SMTP : {str(e)}")
|
59 |
+
return None
|
60 |
+
|
61 |
+
def send_email(to_email: str, subject: str, html_content: str) -> bool:
|
62 |
+
"""
|
63 |
+
Envoie un email avec un contenu HTML.
|
64 |
+
"""
|
65 |
+
if not all([EMAIL_HOST_USER, EMAIL_HOST_USER_PASSWORD, DEFAULT_FROM_EMAIL]):
|
66 |
+
log_to_file("Configuration email manquante dans le fichier .env")
|
67 |
+
return False
|
68 |
+
|
69 |
+
try:
|
70 |
+
# Création du message
|
71 |
+
msg = MIMEMultipart('alternative')
|
72 |
+
msg['Subject'] = subject
|
73 |
+
msg['From'] = DEFAULT_FROM_EMAIL
|
74 |
+
msg['To'] = to_email
|
75 |
+
|
76 |
+
# Ajout du contenu HTML
|
77 |
+
html_part = MIMEText(html_content, 'html')
|
78 |
+
msg.attach(html_part)
|
79 |
+
|
80 |
+
# Création de la connexion SMTP et envoi
|
81 |
+
server = create_smtp_connection()
|
82 |
+
if not server:
|
83 |
+
return False
|
84 |
+
|
85 |
+
try:
|
86 |
+
server.send_message(msg)
|
87 |
+
log_to_file(f"Email envoyé avec succès à {to_email}")
|
88 |
+
return True
|
89 |
+
except Exception as e:
|
90 |
+
log_to_file(f"Erreur lors de l'envoi de l'email : {str(e)}")
|
91 |
+
return False
|
92 |
+
finally:
|
93 |
+
try:
|
94 |
+
server.quit()
|
95 |
+
except:
|
96 |
+
pass
|
97 |
+
|
98 |
+
except Exception as e:
|
99 |
+
log_to_file(f"Erreur lors de la préparation de l'email : {str(e)}")
|
100 |
+
return False
|
utils/ip_utils.py
ADDED
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import requests
|
2 |
+
import logging # gardez l'import de logging même si nous n'utilisons plus directement ses fonctions de log, car nous avons toujours besoin de ses constantes de niveau (level=logging.INFO/ERROR/WARNING)
|
3 |
+
from utils.logging_utils import log_to_file # système de logging défini sur un fichier séparé pour une meilleure lisibilité.
|
4 |
+
|
5 |
+
|
6 |
+
def get_user_ip():
|
7 |
+
|
8 |
+
"""Récupère l'adresse IP publique de l'utilisateur.
|
9 |
+
Cette fonction utilise la bibliothèque requests pour récupérer l'adresse IP publique de l'utilisateur.
|
10 |
+
Si une erreur est rencontrée lors de la récupération de l'adresse IP, la fonction renvoie None.
|
11 |
+
"""
|
12 |
+
try:
|
13 |
+
response = requests.get('https://api.ipify.org?format=json')
|
14 |
+
response.raise_for_status() # Lever une exception si la requête a échoué
|
15 |
+
return response.json()['ip']
|
16 |
+
except requests.exceptions.RequestException as e:
|
17 |
+
# Gérer les erreurs de requête (connexion, timeout, etc.)
|
18 |
+
log_to_file(f"-(ip_utils.py/def get_user_ip():)Erreur lors de la récupération de l'adresse IP : {e}", level=logging.ERROR)
|
19 |
+
return None
|
utils/logging_utils.py
ADDED
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import logging
|
2 |
+
from datetime import datetime
|
3 |
+
|
4 |
+
|
5 |
+
#Nouveau système de lod
|
6 |
+
def log_to_file(message, level=logging.INFO):
|
7 |
+
"""Fonction centralisée pour la gestion des logs"""
|
8 |
+
logger = logging.getLogger(__name__)
|
9 |
+
logger.log(level, message)
|
10 |
+
|
11 |
+
# Écrire également dans un fichier
|
12 |
+
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
13 |
+
with open("app_logs.txt", "a") as log_file:
|
14 |
+
log_file.write(f"{timestamp} - {logging.getLevelName(level)} - {message}\n")
|
utils/notification_handler.py
ADDED
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# utils/notification_handler.py (fichier en relation avec profile.py)
|
2 |
+
import streamlit as st
|
3 |
+
from utils.email_utils import send_email
|
4 |
+
from utils.admin import supabase
|
5 |
+
from datetime import datetime
|
6 |
+
import pytz
|
7 |
+
import logging
|
8 |
+
|
9 |
+
def send_user_notification(user_id: int, notification_type: str, message: str) -> bool:
|
10 |
+
"""
|
11 |
+
Envoie une notification à l'utilisateur en respectant ses préférences
|
12 |
+
"""
|
13 |
+
try:
|
14 |
+
logging.info(f"Début de l'envoi de notification pour user_id: {user_id}, type: {notification_type}")
|
15 |
+
# Récupérer les préférences de l'utilisateur
|
16 |
+
user_prefs = supabase.table("notification_preferences").select("*").eq("user_id", user_id).execute()
|
17 |
+
user_info = supabase.table("users").select("email").eq("id", user_id).execute()
|
18 |
+
|
19 |
+
if not user_prefs.data:
|
20 |
+
logging.error(f"Aucune préférence trouvée pour user_id: {user_id}")
|
21 |
+
return False
|
22 |
+
if not user_info.data:
|
23 |
+
logging.error(f"Aucun utilisateur trouvé pour user_id: {user_id}")
|
24 |
+
return False
|
25 |
+
|
26 |
+
|
27 |
+
prefs = user_prefs.data[0]
|
28 |
+
user_email = user_info.data[0]['email']
|
29 |
+
logging.info(f"Préférences trouvées pour {user_email}: {prefs}")
|
30 |
+
|
31 |
+
# Vérifier si les notifications sont activées pour ce type
|
32 |
+
if notification_type == "security" and not prefs['security_alerts']:
|
33 |
+
logging.info(f"Alertes de sécurité désactivées pour {user_email}")
|
34 |
+
return False
|
35 |
+
elif notification_type == "assistant" and not prefs['assistant_updates']:
|
36 |
+
logging.info(f"Mises à jour assistant désactivées pour {user_email}")
|
37 |
+
return False
|
38 |
+
elif notification_type == "daily" and not prefs['daily_summary']:
|
39 |
+
logging.info(f"Résumé quotidien désactivé pour {user_email}")
|
40 |
+
return False
|
41 |
+
|
42 |
+
# Si les notifications par email sont activées
|
43 |
+
logging.info(f"Envoi d'email à {user_email}")
|
44 |
+
success = send_email(user_email,
|
45 |
+
f"Notification - {notification_type.capitalize()}",
|
46 |
+
f"""
|
47 |
+
<html>
|
48 |
+
<body>
|
49 |
+
<h2>Notification {notification_type}</h2>
|
50 |
+
<p>{message}</p>
|
51 |
+
<p>Date : {datetime.now(pytz.UTC).strftime('%Y-%m-%d %H:%M:%S UTC')}</p>
|
52 |
+
</body>
|
53 |
+
</html>
|
54 |
+
""")
|
55 |
+
|
56 |
+
if success:
|
57 |
+
logging.info(f"Email envoyé avec succès à {user_email}")
|
58 |
+
else:
|
59 |
+
logging.error(f"Échec de l'envoi de l'email à {user_email}")
|
60 |
+
|
61 |
+
return success
|
62 |
+
|
63 |
+
except Exception as e:
|
64 |
+
logging.error(f"Erreur lors de l'envoi de la notification : {str(e)}")
|
65 |
+
logging.error(f"Traceback complet : ", exc_info=True)
|
66 |
+
return False
|
67 |
+
|
68 |
+
def notify_security_alert(user_id: int, message: str) -> bool:
|
69 |
+
"""Envoie une alerte de sécurité"""
|
70 |
+
return send_user_notification(user_id, "security", message)
|
71 |
+
|
72 |
+
def notify_assistant_update(user_id: int, message: str) -> bool:
|
73 |
+
"""Envoie une notification de mise à jour des assistants"""
|
74 |
+
return send_user_notification(user_id, "assistant", message)
|
75 |
+
|
76 |
+
def send_daily_summary(user_id: int, summary_data: dict) -> bool:
|
77 |
+
"""Envoie le résumé quotidien"""
|
78 |
+
message = f"""
|
79 |
+
Résumé de vos activités :
|
80 |
+
- Nombre de conversations : {summary_data.get('conversations', 0)}
|
81 |
+
- Questions posées : {summary_data.get('questions', 0)}
|
82 |
+
- Temps total d'utilisation : {summary_data.get('usage_time', '0')} minutes
|
83 |
+
"""
|
84 |
+
return send_user_notification(user_id, "daily", message)
|
utils/password_reset.py
ADDED
@@ -0,0 +1,159 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import streamlit as st
|
2 |
+
from datetime import datetime, timedelta
|
3 |
+
import pytz
|
4 |
+
import secrets
|
5 |
+
import bcrypt
|
6 |
+
from supabase import create_client
|
7 |
+
import os
|
8 |
+
from dotenv import load_dotenv
|
9 |
+
from utils.email_utils import send_email
|
10 |
+
from urllib.parse import urljoin, urlparse
|
11 |
+
from utils.logging_utils import log_to_file # remplace "import logging" car maintenat les logging sont définis et importés depuis le fichier dédié: logging_utils.py
|
12 |
+
|
13 |
+
load_dotenv()
|
14 |
+
supabase = create_client(os.getenv("SUPABASE_URL"), os.getenv("SUPABASE_KEY"))
|
15 |
+
|
16 |
+
# Fonction pour générer un token de réinitialisation de mot de passe
|
17 |
+
def generate_reset_token() -> str:
|
18 |
+
"""Génère un token sécurisé pour la réinitialisation de mot de passe."""
|
19 |
+
return secrets.token_urlsafe(32)
|
20 |
+
|
21 |
+
# Fonction pour créer un token de réinitialisation de mot de passe pour un utilisateur spécifique
|
22 |
+
def create_reset_token(user_id: int) -> tuple[bool, str]:
|
23 |
+
"""
|
24 |
+
Crée un token de réinitialisation de mot de passe.
|
25 |
+
"""
|
26 |
+
try:
|
27 |
+
token = generate_reset_token()
|
28 |
+
expires_at = datetime.now(pytz.UTC) + timedelta(hours=24)
|
29 |
+
|
30 |
+
# Invalider les tokens précédents
|
31 |
+
supabase.table("user_tokens").update({
|
32 |
+
"used": True
|
33 |
+
}).eq("user_id", user_id).eq("type", "reset_password").execute()
|
34 |
+
|
35 |
+
# Créer un nouveau token
|
36 |
+
supabase.table("user_tokens").insert({
|
37 |
+
"user_id": user_id,
|
38 |
+
"token": token,
|
39 |
+
"type": "reset_password",
|
40 |
+
"expires_at": expires_at.isoformat()
|
41 |
+
}).execute()
|
42 |
+
|
43 |
+
return True, token
|
44 |
+
except Exception as e:
|
45 |
+
log_to_file(f"Erreur lors de la création du token : {str(e)}")
|
46 |
+
return False, ""
|
47 |
+
|
48 |
+
# Fonction pour envoyer l'email de réinitialisation
|
49 |
+
def send_reset_email(email: str, reset_url: str) -> bool:
|
50 |
+
"""
|
51 |
+
Envoie l'email de réinitialisation.
|
52 |
+
"""
|
53 |
+
subject = "Réinitialisation de votre mot de passe"
|
54 |
+
html_content = f"""
|
55 |
+
<html>
|
56 |
+
<body>
|
57 |
+
<h2>Réinitialisation de votre mot de passe</h2>
|
58 |
+
<p>Vous avez demandé la réinitialisation de votre mot de passe.</p>
|
59 |
+
<p>Cliquez sur le lien ci-dessous pour définir un nouveau mot de passe :</p>
|
60 |
+
<p><a href="{reset_url}">Réinitialiser mon mot de passe</a></p>
|
61 |
+
<p>Ce lien est valable pendant 24 heures.</p>
|
62 |
+
<p>Si vous n'avez pas demandé cette réinitialisation, ignorez cet email.</p>
|
63 |
+
</body>
|
64 |
+
</html>
|
65 |
+
"""
|
66 |
+
return send_email(email, subject, html_content)
|
67 |
+
|
68 |
+
|
69 |
+
# Fonction pour initier le processus de réinitialisation du mot de passe
|
70 |
+
def initiate_password_reset(email: str) -> tuple[bool, str]:
|
71 |
+
"""
|
72 |
+
Initie le processus de réinitialisation de mot de passe.
|
73 |
+
"""
|
74 |
+
try:
|
75 |
+
# Vérifier si l'utilisateur existe
|
76 |
+
user = supabase.table("users").select("id").eq("email", email).execute()
|
77 |
+
if not user.data:
|
78 |
+
return False, "Aucun compte associé à cet email."
|
79 |
+
|
80 |
+
user_id = user.data[0]['id']
|
81 |
+
success, token = create_reset_token(user_id)
|
82 |
+
|
83 |
+
if not success:
|
84 |
+
return False, "Erreur lors de la création du token."
|
85 |
+
|
86 |
+
|
87 |
+
# Construire l'URL de réinitialisation de manière sécurisée
|
88 |
+
base_url = os.getenv('APP_URL', '').rstrip('/')
|
89 |
+
# Nettoyer l'URL de base
|
90 |
+
if base_url.startswith('http://'):
|
91 |
+
base_url = base_url[7:]
|
92 |
+
elif base_url.startswith('https://'):
|
93 |
+
base_url = base_url[8:]
|
94 |
+
|
95 |
+
base_url = base_url.strip('/')
|
96 |
+
reset_url = f"http://{base_url}/reset_password?token={token}"
|
97 |
+
|
98 |
+
# Envoyer l'email
|
99 |
+
if send_reset_email(email, reset_url):
|
100 |
+
return True, "Instructions envoyées par email."
|
101 |
+
return False, "Erreur lors de l'envoi de l'email."
|
102 |
+
|
103 |
+
except Exception as e:
|
104 |
+
log_to_file(f"Erreur lors de l'initiation de la réinitialisation : {str(e)}")
|
105 |
+
return False, "Une erreur est survenue."
|
106 |
+
|
107 |
+
|
108 |
+
# Fonction pour vérifier la validité d'un token de réinitialisation
|
109 |
+
def verify_reset_token(token: str) -> tuple[bool, int]:
|
110 |
+
"""
|
111 |
+
Vérifie la validité d'un token de réinitialisation.
|
112 |
+
"""
|
113 |
+
try:
|
114 |
+
response = supabase.table("user_tokens").select("*").eq(
|
115 |
+
"token", token
|
116 |
+
).eq("type", "reset_password").eq("used", False).execute()
|
117 |
+
|
118 |
+
if not response.data:
|
119 |
+
return False, 0
|
120 |
+
|
121 |
+
token_data = response.data[0]
|
122 |
+
expires_at = datetime.fromisoformat(token_data['expires_at'].replace('Z', '+00:00'))
|
123 |
+
|
124 |
+
if expires_at < datetime.now(pytz.UTC):
|
125 |
+
return False, 0
|
126 |
+
|
127 |
+
return True, token_data['user_id']
|
128 |
+
except Exception as e:
|
129 |
+
log_to_file(f"Erreur lors de la vérification du token : {str(e)}")
|
130 |
+
return False, 0
|
131 |
+
|
132 |
+
|
133 |
+
# Fonction pour réinitialiser le mot de passe avec le token de réinitialisation
|
134 |
+
def reset_password(token: str, new_password: str) -> tuple[bool, str]:
|
135 |
+
"""
|
136 |
+
Réinitialise le mot de passe avec le token.
|
137 |
+
"""
|
138 |
+
try:
|
139 |
+
valid, user_id = verify_reset_token(token)
|
140 |
+
if not valid:
|
141 |
+
return False, "Token invalide ou expiré."
|
142 |
+
|
143 |
+
# Hasher le nouveau mot de passe
|
144 |
+
hashed_password = bcrypt.hashpw(new_password.encode(), bcrypt.gensalt()).decode()
|
145 |
+
|
146 |
+
# Mettre à jour le mot de passe
|
147 |
+
supabase.table("users").update({
|
148 |
+
"password": hashed_password
|
149 |
+
}).eq("id", user_id).execute()
|
150 |
+
|
151 |
+
# Marquer le token comme utilisé
|
152 |
+
supabase.table("user_tokens").update({
|
153 |
+
"used": True
|
154 |
+
}).eq("token", token).execute()
|
155 |
+
|
156 |
+
return True, "Mot de passe mis à jour avec succès."
|
157 |
+
except Exception as e:
|
158 |
+
log_to_file(f"Erreur lors de la réinitialisation du mot de passe : {str(e)}")
|
159 |
+
return False, "Une erreur est survenue."
|
utils/rate_limiter.py
ADDED
@@ -0,0 +1,139 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import asyncio
|
2 |
+
import pytz
|
3 |
+
from datetime import datetime, timedelta
|
4 |
+
import time
|
5 |
+
from typing import Dict, Optional
|
6 |
+
import logging
|
7 |
+
from utils.logging_utils import log_to_file
|
8 |
+
from utils.admin import supabase # Ajoutez cet import avec les autres
|
9 |
+
from utils.cache_manager import CacheManager
|
10 |
+
cache_manager = CacheManager() # optimiser les statistiques d'utilisation avec le cache
|
11 |
+
|
12 |
+
class RateLimiter:
|
13 |
+
def __init__(self, requests_per_second: int = 10, burst_limit: int = 20): # 10 requetes par seconde, 20 requetes en rafale
|
14 |
+
"""
|
15 |
+
Initialise le rate limiter
|
16 |
+
requests_per_second: Nombre maximal de requêtes par seconde
|
17 |
+
burst_limit: Nombre maximal de requêtes en rafale
|
18 |
+
"""
|
19 |
+
self.rate = requests_per_second # Nombre maximal de requetes pas seconde (aujourd'hui)
|
20 |
+
self.burst_limit = burst_limit # Nombre maximal de requetes en rafale (aujourd'hui)
|
21 |
+
self.tokens = burst_limit # Nombre de jetons restants en rafale (aujourd'hui)
|
22 |
+
self.last_update = time.monotonic() # Derniere mise à jour
|
23 |
+
self.lock = asyncio.Lock() # Prise en charge du verrouillage
|
24 |
+
self._requests_count: Dict[str, int] = {} # Compteur de requetes
|
25 |
+
self.last_cleanup = time.time() # Date du dernier nettoyage
|
26 |
+
self.cleanup_interval = 3600 # 1 heure en secondes pour le nettoyage au bout d'une heure
|
27 |
+
self.cache_manager = CacheManager() # utilisation du cache pour stocker les statisqtiques d'utilisation (aujourd'hui)
|
28 |
+
|
29 |
+
|
30 |
+
async def acquire(self, user_id: Optional[str] = None) -> bool:
|
31 |
+
"""Tente d'acquérir un token pour faire une requête"""
|
32 |
+
async with self.lock:
|
33 |
+
now = time.monotonic()
|
34 |
+
time_passed = now - self.last_update
|
35 |
+
self.tokens = min(
|
36 |
+
self.burst_limit,
|
37 |
+
self.tokens + time_passed * self.rate
|
38 |
+
)
|
39 |
+
self.last_update = now
|
40 |
+
|
41 |
+
if self.tokens >= 1:
|
42 |
+
self.tokens -= 1
|
43 |
+
if user_id:
|
44 |
+
await self._track_request(user_id)
|
45 |
+
return True
|
46 |
+
|
47 |
+
log_to_file(f"Rate limit atteint pour l'utilisateur {user_id}", level=logging.WARNING)
|
48 |
+
return False
|
49 |
+
|
50 |
+
|
51 |
+
async def _get_stats_from_cache(self, user_id: str) -> Optional[Dict]:
|
52 |
+
"""Récupère les statistiques depuis le cache"""
|
53 |
+
cache_key = f"usage_stats:{user_id}"
|
54 |
+
return await cache_manager.get(cache_key)
|
55 |
+
|
56 |
+
async def _update_stats_cache(self, user_id: str, stats: Dict):
|
57 |
+
"""Met à jour les statistiques dans le cache"""
|
58 |
+
cache_key = f"usage_stats:{user_id}"
|
59 |
+
await cache_manager.set(cache_key, stats, expiry=timedelta(hours=1))
|
60 |
+
|
61 |
+
async def _track_request(self, user_id: str) -> None:
|
62 |
+
"""Suit les requêtes par utilisateur avec cache"""
|
63 |
+
try:
|
64 |
+
current_time = datetime.now(pytz.UTC).isoformat()
|
65 |
+
stats_data = None # Initialiser stats_data
|
66 |
+
|
67 |
+
# Vérifier d'abord le cache
|
68 |
+
cached_stats = await self._get_stats_from_cache(user_id)
|
69 |
+
|
70 |
+
if cached_stats:
|
71 |
+
# Mettre à jour les stats en cache
|
72 |
+
cached_stats['request_count'] += 1
|
73 |
+
cached_stats['last_request'] = current_time
|
74 |
+
await self._update_stats_cache(user_id, cached_stats)
|
75 |
+
stats_data = cached_stats
|
76 |
+
|
77 |
+
# Mettre à jour Supabase de manière asynchrone
|
78 |
+
supabase.table("usage_statistics").update({
|
79 |
+
"request_count": cached_stats['request_count'],
|
80 |
+
"last_request": current_time
|
81 |
+
}).eq("user_id", user_id).execute()
|
82 |
+
else:
|
83 |
+
# Vérifier Supabase
|
84 |
+
existing = supabase.table("usage_statistics").select("*").eq("user_id", user_id).execute()
|
85 |
+
|
86 |
+
if existing.data:
|
87 |
+
# Mettre à jour les stats existantes
|
88 |
+
stats = existing.data[0]
|
89 |
+
new_count = stats.get('request_count', 0) + 1
|
90 |
+
stats_data = {
|
91 |
+
"request_count": new_count,
|
92 |
+
"last_request": current_time
|
93 |
+
}
|
94 |
+
|
95 |
+
supabase.table("usage_statistics").update(stats_data).eq("user_id", user_id).execute()
|
96 |
+
await self._update_stats_cache(user_id, stats_data)
|
97 |
+
else:
|
98 |
+
# Créer nouvelles stats
|
99 |
+
stats_data = {
|
100 |
+
"user_id": user_id,
|
101 |
+
"request_count": 1,
|
102 |
+
"last_request": current_time,
|
103 |
+
"last_cleanup": current_time
|
104 |
+
}
|
105 |
+
|
106 |
+
supabase.table("usage_statistics").insert(stats_data).execute()
|
107 |
+
await self._update_stats_cache(user_id, stats_data)
|
108 |
+
|
109 |
+
if stats_data: # Vérifier que stats_data existe
|
110 |
+
self._requests_count[user_id] = stats_data['request_count']
|
111 |
+
log_to_file(f"Nouvelle requête pour l'utilisateur {user_id}. Total: {stats_data['request_count']}", level=logging.INFO)
|
112 |
+
|
113 |
+
except Exception as e:
|
114 |
+
log_to_file(f"Erreur lors du suivi des requêtes: {str(e)}", level=logging.ERROR)
|
115 |
+
# Continuer avec le comptage en mémoire en cas d'erreur
|
116 |
+
if user_id not in self._requests_count:
|
117 |
+
self._requests_count[user_id] = 1
|
118 |
+
else:
|
119 |
+
self._requests_count[user_id] += 1
|
120 |
+
|
121 |
+
|
122 |
+
|
123 |
+
|
124 |
+
async def get_user_requests_count(self, user_id: str) -> int:
|
125 |
+
"""Retourne le nombre de requêtes avec cache"""
|
126 |
+
try:
|
127 |
+
cached_stats = await self._get_stats_from_cache(user_id)
|
128 |
+
if cached_stats:
|
129 |
+
return cached_stats.get('request_count', 0)
|
130 |
+
|
131 |
+
response = supabase.table("usage_statistics").select("request_count").eq("user_id", user_id).execute()
|
132 |
+
if response.data:
|
133 |
+
count = response.data[0].get('request_count', 0)
|
134 |
+
await self._update_stats_cache(user_id, {"request_count": count})
|
135 |
+
return count
|
136 |
+
return 0
|
137 |
+
except Exception as e:
|
138 |
+
log_to_file(f"Erreur lors de la récupération du compte des requêtes: {str(e)}", level=logging.ERROR)
|
139 |
+
return self._requests_count.get(user_id, 0)
|
utils/theme_utils.py
ADDED
@@ -0,0 +1,558 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import streamlit as st
|
2 |
+
import base64
|
3 |
+
import os
|
4 |
+
from datetime import datetime
|
5 |
+
import pytz
|
6 |
+
import logging
|
7 |
+
from utils.admin import supabase # Pour load_theme_preference et save_theme_preference
|
8 |
+
from utils.logging_utils import log_to_file
|
9 |
+
|
10 |
+
|
11 |
+
# Fonction pour charger la préférence de thème depuis la base de données
|
12 |
+
def load_theme_preference():
|
13 |
+
"""Charge la préférence de thème depuis la base de données"""
|
14 |
+
if 'user' in st.session_state and st.session_state['user']:
|
15 |
+
user_id = st.session_state['user'].get('id')
|
16 |
+
if user_id:
|
17 |
+
try:
|
18 |
+
response = supabase.table("notification_preferences").select("theme").eq("user_id", user_id).execute()
|
19 |
+
if response.data:
|
20 |
+
theme = response.data[0]['theme']
|
21 |
+
log_to_file(f"Thème '{theme}' chargé pour l'utilisateur {user_id}")
|
22 |
+
return theme
|
23 |
+
except Exception as e:
|
24 |
+
log_to_file(f"Erreur lors du chargement du thème : {str(e)}", logging.ERROR)
|
25 |
+
return 'Clair' # Thème par défaut
|
26 |
+
|
27 |
+
|
28 |
+
# Fonction pour sauvegarder la préférence de thème dans la base de données
|
29 |
+
def save_theme_preference(theme):
|
30 |
+
"""Sauvegarde la préférence de thème dans la base de données"""
|
31 |
+
if 'user' in st.session_state and st.session_state['user']:
|
32 |
+
user_id = st.session_state['user'].get('id')
|
33 |
+
if user_id:
|
34 |
+
try:
|
35 |
+
# Vérifier si une préférence existe déjà
|
36 |
+
existing_preference = supabase.table("notification_preferences").select("*").eq("user_id", user_id).execute()
|
37 |
+
|
38 |
+
if existing_preference.data:
|
39 |
+
# Mettre à jour la ligne existante
|
40 |
+
supabase.table("notification_preferences").update({"theme": theme}).eq("user_id", user_id).execute()
|
41 |
+
else:
|
42 |
+
# Insérer une nouvelle ligne
|
43 |
+
supabase.table("notification_preferences").insert({"user_id": user_id, "theme": theme}).execute()
|
44 |
+
|
45 |
+
st.session_state.user_theme = theme
|
46 |
+
log_to_file(f"Thème '{theme}' sauvegardé pour l'utilisateur {user_id}")
|
47 |
+
except Exception as e:
|
48 |
+
log_to_file(f"Erreur lors de la sauvegarde du thème : {str(e)}", logging.ERROR)
|
49 |
+
|
50 |
+
|
51 |
+
def apply_theme(theme):
|
52 |
+
# Définition des couleurs communes selon le thème choisi (sombre ou clair)
|
53 |
+
|
54 |
+
# Texte principal : Gris clair (#E0E0E0) pour le thème sombre, Gris foncé (#4a4a4a) pour le thème clair
|
55 |
+
text_color = "#E0E0E0" if theme == "Sombre" else "#4a4a4a"
|
56 |
+
|
57 |
+
# Couleur de fond de la page : Gris très foncé (#1E1E1E) pour le thème sombre, blanc cassé (#f7f9fc) pour le thème clair
|
58 |
+
bg_color = "#1E1E1E" if theme == "Sombre" else "#f7f9fc"
|
59 |
+
|
60 |
+
# Couleur de fond des zones de saisie (inputs) : Gris foncé (#333333) pour le thème sombre, gris très clair avec une légère teinte bleue (#eef3f7) pour le thème clair
|
61 |
+
input_bg_color= "#333333" if theme == "Sombre" else "#eef3f7"
|
62 |
+
|
63 |
+
# Couleur de fond des zones de saisie (inputs) : Gris foncé (#333333) pour le thème sombre, rouge saumon clair (#ffcccb pour le thème clair
|
64 |
+
input_text_area_inactive = "#333333" if theme == "Sombre" else "#ffcccb"
|
65 |
+
|
66 |
+
# Couleur de fond des zones de saisie active (inputs) : Gris foncé (#999999) pour le thème sombre, Blanc pur (#ffffff) pour le thème clair
|
67 |
+
input_text_area_active = "#666666" if theme == "Sombre" else "#ffffff"
|
68 |
+
|
69 |
+
|
70 |
+
# Définir la couleur du curseur (caret) selon le thème
|
71 |
+
caret_color = "white" if theme == "Sombre" else "black" # Blanc pour le thème sombre, noir pour le thème clair
|
72 |
+
|
73 |
+
# Couleur de fond de la barre latérale : Gris très foncé (#2D2D2D) pour le thème sombre, gris bleu très clair ( #e3e9f1) pour le thème clair
|
74 |
+
sidebar_bg_color = "#2D2D2D" if theme == "Sombre" else " #e3e9f1"
|
75 |
+
|
76 |
+
# Couleurs spécifiques pour les messages utilisateur et assistant :
|
77 |
+
|
78 |
+
# Messages de l'utilisateur : Bleu foncé (#0E4C92) pour le thème sombre, Bleu clair désaturé (#d1e7ff) pour le thème clair
|
79 |
+
user_msg_bg = "#0E4C92" if theme == "Sombre" else "#d1e7ff"
|
80 |
+
|
81 |
+
# Messages de l'assistant : Vert foncé (#1A472A) pour le thème sombre, Gris perle (#f0f0f0) pour le thème clair
|
82 |
+
assistant_msg_bg = "#1A472A" if theme == "Sombre" else "#f0f0f0"
|
83 |
+
|
84 |
+
# Couleurs pour les boutons et éléments interactifs :
|
85 |
+
|
86 |
+
# Fond des boutons : Bleu acier (#4682B4) pour le thème sombre, Or (#FFD700) pour le thème clair
|
87 |
+
button_bg = "#4682B4" if theme == "Sombre" else "#FFD700"
|
88 |
+
|
89 |
+
# Texte des boutons : Blanc (#FFFFFF) pour le thème sombre, Vert mer (#8FBC8F) pour le thème clair
|
90 |
+
button_text = "white" if theme == "Sombre" else "#8FBC8F"
|
91 |
+
|
92 |
+
# Fond du selectbox : Bleu foncé (#0E4C92) pour le thème sombre, gris très clair avec une légère teinte bleue (#eef3f7) pour le thème clair
|
93 |
+
select_bg = "#ff5e4d" if theme == "Sombre" else "#eef3f7"
|
94 |
+
|
95 |
+
# Fond du selectbox : Bleu foncé (#0E4C92) pour le thème sombre, Gris clair (#e6f7ff) pour le thème clair
|
96 |
+
select_option_bg = "#1c39bb" if theme == "Sombre" else "#e6f7ff"
|
97 |
+
|
98 |
+
select_text_color = "black" if theme == "Sombre" else "#333333" # Couleur du texte du selectbox
|
99 |
+
|
100 |
+
# Génération des styles CSS pour Streamlit
|
101 |
+
st.markdown(f"""
|
102 |
+
<style>
|
103 |
+
/* Styles généraux */
|
104 |
+
.stApp {{
|
105 |
+
background-color: {bg_color}; /* Couleur de fond générale */
|
106 |
+
color: {text_color} !important; /* Couleur de texte principale */
|
107 |
+
}}
|
108 |
+
|
109 |
+
/* Styles pour les messages de chat */
|
110 |
+
.stChatMessage {{
|
111 |
+
background-color: {input_bg_color} !important; /* Fond des messages */
|
112 |
+
color: {text_color} !important; /* Couleur de texte dans les messages */
|
113 |
+
}}
|
114 |
+
.stChatMessage.user {{ background-color: {user_msg_bg} !important; }} /* Fond des messages utilisateur */
|
115 |
+
.stChatMessage.assistant {{ background-color: {assistant_msg_bg} !important; }} /* Fond des messages assistant */
|
116 |
+
.stChatMessage * {{ color: {text_color} !important; }} /* Couleur de texte dans tous les éléments des messages */
|
117 |
+
|
118 |
+
/* Style pour le conteneur de la zone de saisie */
|
119 |
+
[data-testid="stBottomBlockContainer"] {{
|
120 |
+
background-color: {input_bg_color} !important; /* Fond de la zone de saisie */
|
121 |
+
}}
|
122 |
+
|
123 |
+
/* Styles pour la zone de saisie et son conteneur interne */
|
124 |
+
.stChatInput,
|
125 |
+
.stChatInput > div,
|
126 |
+
.stChatInput textarea {{
|
127 |
+
background-color: {input_text_area_inactive} !important; /* Fond de la zone de saisie */
|
128 |
+
border-color: {"#555555" if theme == "Sombre" else "#b0e0e6"} !important; /* Bordure des champs de saisie */
|
129 |
+
color: {text_color} !important; /* Texte dans la zone de saisie */
|
130 |
+
}}
|
131 |
+
.stChatInput textarea:focus {{
|
132 |
+
background-color: {input_text_area_active} !important; /* Fond quand la zone est active */
|
133 |
+
color: {text_color} !important; /* Texte actif */
|
134 |
+
caret-color: {caret_color} !important; /* Couleur du curseur actif */
|
135 |
+
box-shadow: none !important; /* Pas de halo lumineux lors de la saisie */
|
136 |
+
}}
|
137 |
+
|
138 |
+
/* Style pour le bouton d'envoi */
|
139 |
+
.stChatInput button {{
|
140 |
+
background-color: {button_bg} !important; /* Fond des boutons */
|
141 |
+
color: {button_text} !important; /* Couleur du texte des boutons */
|
142 |
+
}}
|
143 |
+
|
144 |
+
/* Styles pour la barre latérale */
|
145 |
+
[data-testid="stSidebar"] {{
|
146 |
+
background-color: {sidebar_bg_color}; /* Couleur de fond de la barre latérale */
|
147 |
+
color: {text_color}; /* Texte de la barre latérale */
|
148 |
+
}}
|
149 |
+
[data-testid="stSidebar"] .st-bw,
|
150 |
+
[data-testid="stSidebar"] h3 {{ color: {text_color} !important; }} /* Couleur des titres et textes dans la barre latérale */
|
151 |
+
|
152 |
+
/* Styles pour les boutons */
|
153 |
+
.stButton > button {{
|
154 |
+
background-color: {button_bg}; /* Fond des boutons généraux */
|
155 |
+
color: {button_text}; /* Couleur du texte des boutons */
|
156 |
+
}}
|
157 |
+
|
158 |
+
/* Styles pour les expandeurs */
|
159 |
+
.streamlit-expanderHeader {{
|
160 |
+
background-color: {input_bg_color}; /* Fond des en-têtes d'expandeur */
|
161 |
+
color: {text_color}; /* Couleur du texte des en-têtes d'expandeur */
|
162 |
+
transition: all 0.3s ease-out; /* Transition fluide pour l'expansion */
|
163 |
+
}}
|
164 |
+
.streamlit-expanderHeader[aria-expanded="true"] {{ transform: translateX(10px); }} /* Effet de déplacement quand l'expandeur est ouvert */
|
165 |
+
.streamlit-expanderContent {{
|
166 |
+
background-color: {"#1E90FF" if theme == "Sombre" else "#F0F0F0"} !important; /* Couleur de fond du contenu de l'expandeur */
|
167 |
+
padding: 10px;
|
168 |
+
border-radius: 5px; /* Coins arrondis du contenu de l'expandeur */
|
169 |
+
}}
|
170 |
+
|
171 |
+
/* Styles pour le selectbox */
|
172 |
+
.stSelectbox > div > div:first-child,
|
173 |
+
.stSelectbox > div > div:nth-child(2),
|
174 |
+
.stSelectbox ul {{
|
175 |
+
background-color: {select_bg} !important; /* Fond des éléments du selectbox */
|
176 |
+
color: {"white" if theme == "Sombre" else "#333333"} !important; /* Couleur du texte des éléments du selectbox */
|
177 |
+
}}
|
178 |
+
.stSelectbox ul li:hover, .stSelectbox ul li[aria-selected="true"] {{
|
179 |
+
background-color: {"#ffe4e1" if theme == "Sombre" else "#e6f7ff"} !important; /* Fond des éléments au survol et sélectionnés */
|
180 |
+
}}
|
181 |
+
|
182 |
+
/* Styles pour le selectbox */
|
183 |
+
[data-testid="stSelectboxVirtualDropdown"], .st-emotion-cache-1o2fhjg.e1811lun0 {{
|
184 |
+
background-color: {select_option_bg} !important; /* Fond des éléments du selectbox */
|
185 |
+
color: {select_text_color} !important; /* Couleur du texte des options */
|
186 |
+
}}
|
187 |
+
|
188 |
+
[data-testid="stSelectboxVirtualDropdown"] li[aria-selected="true"], .st-emotion-cache-1o2fhjg.e1811lun0[aria-selected="true"] {{
|
189 |
+
background-color: {"#1A6BBF" if theme == "Sombre" else "#ffcccb"} !important; /* Fond des options sélectionnées */
|
190 |
+
}}
|
191 |
+
|
192 |
+
|
193 |
+
[data-testid="stSelectboxVirtualDropdown"] li:hover {{
|
194 |
+
background-color: {"#1A6BBF" if theme == "Sombre" else "#ffa07a"} !important; /* Fond au survol des options */
|
195 |
+
}}
|
196 |
+
|
197 |
+
/* Styles pour les boutons de feedback */
|
198 |
+
.stChatMessage .stButton > button {{
|
199 |
+
background-color: transparent !important; /* Fond transparent pour les boutons dans les messages */
|
200 |
+
border: none !important;
|
201 |
+
box-shadow: none !important;
|
202 |
+
padding: 0 !important;
|
203 |
+
}}
|
204 |
+
.stChatMessage .stButton > button:hover {{
|
205 |
+
background-color: rgba(0, 0, 0, 0.05) !important; /* Léger changement de fond au survol */
|
206 |
+
}}
|
207 |
+
|
208 |
+
/* Styles pour les boutons de la barre latérale */
|
209 |
+
[data-testid="stSidebar"] .stButton > button {{
|
210 |
+
background-color: {"#1E90FF" if theme == "Sombre" else "#FFD700"} !important; /* Fond des boutons spécifiques dans la barre latérale */
|
211 |
+
color: {button_text} !important; /* Couleur du texte des boutons dans la barre latérale */
|
212 |
+
border: inherit !important;
|
213 |
+
box-shadow: inherit !important;
|
214 |
+
padding: inherit !important;
|
215 |
+
}}
|
216 |
+
|
217 |
+
/* Assurez-vous que tous les textes sont lisibles */
|
218 |
+
body, p, h1, h2, h3, h4, h5, h6, span, div {{
|
219 |
+
color: {"#E0E0E0" if theme == "Sombre" else "#4a4a4a"} !important; /* Couleur des textes dans tout le contenu de la page, */
|
220 |
+
}}
|
221 |
+
|
222 |
+
</style>
|
223 |
+
""", unsafe_allow_html=True)
|
224 |
+
|
225 |
+
|
226 |
+
# ajout de logo sur l'en -tête de l'application (cette focntion est appelé dans inject_custom_css(theme))
|
227 |
+
def get_base64_encoded_image(image_file):
|
228 |
+
with open(image_file, "rb") as img_file:
|
229 |
+
return base64.b64encode(img_file.read()).decode('utf-8')
|
230 |
+
|
231 |
+
# ajout de logo sur l'en -tête de l'application et animation du logo
|
232 |
+
def inject_custom_css(theme):
|
233 |
+
# Définition des paramètres visuels en fonction du thème
|
234 |
+
is_dark_theme = theme == "Sombre"
|
235 |
+
|
236 |
+
# Sélection du logo et des couleurs selon le thème
|
237 |
+
logo_file = "assets/logo2.svg" if is_dark_theme else "assets/logo.svg"
|
238 |
+
background_color = "#333333" if is_dark_theme else "#eef3f7"
|
239 |
+
text_color = "#E0E0E0" if is_dark_theme else "#333333"
|
240 |
+
# Encodage du logo en base64
|
241 |
+
logo_base64 = get_base64_encoded_image(logo_file)
|
242 |
+
|
243 |
+
st.markdown(
|
244 |
+
f"""
|
245 |
+
<style>
|
246 |
+
@keyframes rotate {{
|
247 |
+
from {{
|
248 |
+
transform: rotate(0deg);
|
249 |
+
}}
|
250 |
+
to {{
|
251 |
+
transform: rotate(360deg);
|
252 |
+
}}
|
253 |
+
}}
|
254 |
+
|
255 |
+
.stApp header {{
|
256 |
+
display: flex;
|
257 |
+
align-items: center;
|
258 |
+
background-color: {background_color} !important;
|
259 |
+
padding-left: 240px !important;
|
260 |
+
}}
|
261 |
+
|
262 |
+
.stApp header::before {{
|
263 |
+
content: "";
|
264 |
+
background-image: url("data:image/svg+xml;base64,{logo_base64}");
|
265 |
+
background-repeat: no-repeat;
|
266 |
+
background-position: center;
|
267 |
+
background-size: contain;
|
268 |
+
width: 190px;
|
269 |
+
height: 190px;
|
270 |
+
position: absolute;
|
271 |
+
left: 350px;
|
272 |
+
animation: rotate 10s linear infinite;
|
273 |
+
}}
|
274 |
+
|
275 |
+
.stApp header::after {{
|
276 |
+
content: "Colibri IA: la précision d'un colibri, la puissance d'un assistant";
|
277 |
+
font-size: 15px;
|
278 |
+
font-weight: normal;
|
279 |
+
color: {text_color};
|
280 |
+
margin-left: 250px;
|
281 |
+
background-color: rgba(0, 0, 0, 0.02) !important;
|
282 |
+
}}
|
283 |
+
</style>
|
284 |
+
""",
|
285 |
+
unsafe_allow_html=True,)
|
286 |
+
|
287 |
+
|
288 |
+
# ajout de logo sur la sidebar avec un texte simulant un en-tête
|
289 |
+
def add_sidebar_logo(theme):
|
290 |
+
# Définition des couleurs en fonction du thème
|
291 |
+
text_color = "#E0E0E0" if theme == "Sombre" else "#333333"
|
292 |
+
# #E0E0E0 : Gris très clair, presque blanc (pour le thème sombre)
|
293 |
+
# #333333 : Gris très foncé, presque noir (pour le thème clair)
|
294 |
+
|
295 |
+
# Utilisation d'un contexte 'with' pour la barre latérale
|
296 |
+
with st.sidebar:
|
297 |
+
# Ajout du logo (identique pour les deux thèmes)
|
298 |
+
st.image("assets/logo.png", width=150)
|
299 |
+
|
300 |
+
# Ajout du titre avec la couleur appropriée
|
301 |
+
st.markdown(
|
302 |
+
f"""
|
303 |
+
<h2 style="color:{text_color};">Colibri Assistant</h2>
|
304 |
+
""",
|
305 |
+
unsafe_allow_html=True
|
306 |
+
)
|
307 |
+
|
308 |
+
# Ajout d'une ligne de séparation
|
309 |
+
st.write("---")
|
310 |
+
|
311 |
+
|
312 |
+
def apply_chat_styles(theme):
|
313 |
+
light_user_bg = "#d1e7ff" # bleu clair désaturé (arrière-plan des messages utilisateur en thème clair)
|
314 |
+
light_assistant_bg = "#f0f0f0" # Gris perle (arrière-plan des messages assistant en thème clair)
|
315 |
+
dark_user_bg = "#0E4C92" # #0E4C92 = bleu foncé (arrière-plan des messages utilisateur en thème sombre)
|
316 |
+
dark_assistant_bg = "#1A472A" # #1A472A = vert foncé (arrière-plan des messages assistant en thème sombre)
|
317 |
+
|
318 |
+
user_bg = dark_user_bg if theme == "Sombre" else light_user_bg # Utilise la couleur bleu foncé en thème sombre ou bleu clair désaturé en thème clair pour l'utilisateur
|
319 |
+
assistant_bg = dark_assistant_bg if theme == "Sombre" else light_assistant_bg # Utilise la couleur vert foncé en thème sombre ou Gris perle en thème clair pour l'assistant
|
320 |
+
text_color = "#E0E0E0" if theme == "Sombre" else "#000000" # Utilise la couleur gris clair (#E0E0E0) en thème sombre ou noir (#000000) en thème clair pour le texte
|
321 |
+
|
322 |
+
st.markdown(f"""
|
323 |
+
<style>
|
324 |
+
[data-testid="stChatMessage"] {{
|
325 |
+
width: fit-content !important;
|
326 |
+
max-width: 80% !important;
|
327 |
+
padding: 10px !important;
|
328 |
+
border-radius: 15px !important;
|
329 |
+
margin-bottom: 10px !important;
|
330 |
+
clear: both !important;
|
331 |
+
}}
|
332 |
+
|
333 |
+
[data-testid="stChatMessage"]:has([data-testid="stChatMessageAvatarUser"]) {{
|
334 |
+
background-color: {user_bg} !important; # Arrière-plan des messages utilisateur (dépend du thème)
|
335 |
+
border-radius: 15px 15px 0 15px !important;
|
336 |
+
margin-left: auto !important;
|
337 |
+
margin-right: 10px !important;
|
338 |
+
text-align: right !important;
|
339 |
+
float: right !important;
|
340 |
+
}}
|
341 |
+
|
342 |
+
[data-testid="stChatMessage"]:not(:has([data-testid="stChatMessageAvatarUser"])) {{
|
343 |
+
background-color: {assistant_bg} !important; # Arrière-plan des messages assistant (dépend du thème)
|
344 |
+
border-radius: 15px 15px 15px 0 !important;
|
345 |
+
margin-right: auto !important;
|
346 |
+
margin-left: 10px !important;
|
347 |
+
text-align: left !important;
|
348 |
+
float: left !important;
|
349 |
+
}}
|
350 |
+
|
351 |
+
[data-testid="stChatMessage"] * {{
|
352 |
+
color: {text_color} !important; # Couleur du texte (dépend du thème)
|
353 |
+
}}
|
354 |
+
|
355 |
+
</style>
|
356 |
+
""", unsafe_allow_html=True)
|
357 |
+
|
358 |
+
def inject_custom_toggle_css(theme):
|
359 |
+
# Définition de la couleur du texte en fonction du thème
|
360 |
+
text_color = "#e0e0e0" if theme == "Sombre" else "#666666"
|
361 |
+
# #e0e0e0 : Gris très clair, presque blanc (pour le thème sombre)
|
362 |
+
# #666666 : Gris moyen foncé (pour le thème clair)
|
363 |
+
|
364 |
+
# CSS personnalisé pour le style des toggles
|
365 |
+
custom_css = f"""
|
366 |
+
<style>
|
367 |
+
/* Style pour le titre du toggle */
|
368 |
+
.stExpander .stToggleButton label,
|
369 |
+
.streamlit-expanderHeader .stToggleButton label {{
|
370 |
+
color: {text_color} !important;
|
371 |
+
font-weight: bold;
|
372 |
+
}}
|
373 |
+
</style>
|
374 |
+
"""
|
375 |
+
# Injection du CSS personnalisé dans la page Streamlit
|
376 |
+
st.markdown(custom_css, unsafe_allow_html=True)
|
377 |
+
|
378 |
+
|
379 |
+
def create_custom_footer():
|
380 |
+
footer = """
|
381 |
+
<style>
|
382 |
+
.footer {
|
383 |
+
position: fixed;
|
384 |
+
left: 0;
|
385 |
+
bottom: 0;
|
386 |
+
width: 100%;
|
387 |
+
background-color: #0E1117; # Couleur de fond sombre, ajustez selon votre thème
|
388 |
+
color: #FAFAFA; # Couleur du texte claire
|
389 |
+
text-align: center;
|
390 |
+
padding: 10px;
|
391 |
+
font-size: 14px;
|
392 |
+
}
|
393 |
+
</style>
|
394 |
+
<div class="footer">
|
395 |
+
<p>© 2024 Votre Entreprise. Tous droits réservés.</p>
|
396 |
+
</div>
|
397 |
+
"""
|
398 |
+
st.markdown(footer, unsafe_allow_html=True)
|
399 |
+
|
400 |
+
# Fonction pour permettre à l'utilisateur de changer de thème avec un toggle
|
401 |
+
def theme_switcher():
|
402 |
+
current_theme = st.session_state.get('user_theme', 'Clair')
|
403 |
+
|
404 |
+
# Utiliser des colonnes Streamlit pour l'alignement
|
405 |
+
col1, col2 = st.columns([0.9, 4]) # Ajustez les ratios selon vos besoins
|
406 |
+
|
407 |
+
with col1:
|
408 |
+
# Toggle sans label
|
409 |
+
is_dark = st.toggle("", value=current_theme == "Sombre", key=f"theme_toggle", label_visibility="collapsed")
|
410 |
+
|
411 |
+
with col2:
|
412 |
+
# Label personnalisé
|
413 |
+
title_color = "#FF6B6B" if current_theme == "Sombre" else "#1E90FF"
|
414 |
+
st.markdown(f'<p style="color: {title_color}; font-weight: normal; margin-top: 5px;">Thème sombre</i>', unsafe_allow_html=True)
|
415 |
+
|
416 |
+
new_theme = "Sombre" if is_dark else "Clair"
|
417 |
+
|
418 |
+
if new_theme != current_theme:
|
419 |
+
st.session_state.user_theme = new_theme
|
420 |
+
save_theme_preference(new_theme)
|
421 |
+
apply_theme(new_theme)
|
422 |
+
apply_chat_styles(new_theme)
|
423 |
+
st.rerun()
|
424 |
+
|
425 |
+
# Cette fonction supprimera certains styles par défaut de Streamlit qui pourraient interférer avec vos styles personnalisés.
|
426 |
+
def remove_streamlit_style():
|
427 |
+
st.markdown("""
|
428 |
+
<style>
|
429 |
+
#MainMenu {visibility: hidden;}
|
430 |
+
footer {visibility: hidden;}
|
431 |
+
</style>
|
432 |
+
""", unsafe_allow_html=True)
|
433 |
+
# Appelez cette fonction au début de votre improved_ui
|
434 |
+
|
435 |
+
|
436 |
+
def force_streamlit_style_update():
|
437 |
+
st.markdown("""
|
438 |
+
<script>
|
439 |
+
// Force Streamlit to re-evaluate the styles
|
440 |
+
window.parent.postMessage({
|
441 |
+
type: "streamlit:setComponentValue",
|
442 |
+
value: true
|
443 |
+
}, "*")
|
444 |
+
</script>
|
445 |
+
""", unsafe_allow_html=True
|
446 |
+
)
|
447 |
+
|
448 |
+
# Fonction pour cacher les pages de navigation spécifiques (voir dossier pages)
|
449 |
+
def hide_pages():
|
450 |
+
hide_pages_style = """
|
451 |
+
<style>
|
452 |
+
/* Cache les liens de navigation spécifiques */
|
453 |
+
.st-emotion-cache-79elbk .st-emotion-cache-1ekxtbt li:nth-child(2), /* admin page */
|
454 |
+
.st-emotion-cache-79elbk .st-emotion-cache-1ekxtbt li:nth-child(3) { /* reset password */
|
455 |
+
display: none !important;
|
456 |
+
}
|
457 |
+
|
458 |
+
/* Cache le séparateur si nécessaire */
|
459 |
+
.st-emotion-cache-uzxc3z {
|
460 |
+
display: none !important;
|
461 |
+
}
|
462 |
+
|
463 |
+
/* Pour s'assurer que tous les liens sont cachés même si les classes changent */
|
464 |
+
[data-testid="stSidebarNavItems"] li a[href*="/admin_page"],
|
465 |
+
[data-testid="stSidebarNavItems"] li a[href*="/reset_password"] {
|
466 |
+
display: none !important;
|
467 |
+
}
|
468 |
+
|
469 |
+
/* Cache les conteneurs parents si nécessaire */
|
470 |
+
[data-testid="stSidebarNavItems"] li:has(a[href*="/admin_page"]),
|
471 |
+
[data-testid="stSidebarNavItems"] li:has(a[href*="/reset_password"]) {
|
472 |
+
display: none !important;
|
473 |
+
}
|
474 |
+
</style>
|
475 |
+
"""
|
476 |
+
st.markdown(hide_pages_style, unsafe_allow_html=True)
|
477 |
+
|
478 |
+
|
479 |
+
# styles pour le dashboard administrateur (tooltip et bouttons)
|
480 |
+
def apply_tooltip_and_button_styles(theme):
|
481 |
+
# Définition des couleurs en fonction du thème
|
482 |
+
text_color = "#E0E0E0" if theme == "Sombre" else "#333333"
|
483 |
+
background_color = "#333333" if theme == "Sombre" else "#fff"
|
484 |
+
border_color = "#ccc" if theme == "Sombre" else "#333"
|
485 |
+
button_background_color = "#4682B4" if theme == "Sombre" else "#FFD700"
|
486 |
+
button_text_color = "white" if theme == "Sombre" else "#8FBC8F"
|
487 |
+
|
488 |
+
# Injection des styles CSS pour les tooltips et le bouton de soumission du formulaire
|
489 |
+
st.markdown(
|
490 |
+
f"""
|
491 |
+
<style>
|
492 |
+
/* Style pour le conteneur principal du tooltip */
|
493 |
+
.st-emotion-cache-oj1fi {{
|
494 |
+
background-color: {background_color} !important;
|
495 |
+
color: {text_color} !important;
|
496 |
+
border: 1px solid {border_color} !important;
|
497 |
+
}}
|
498 |
+
|
499 |
+
/* Style pour le contenu du tooltip */
|
500 |
+
.stTooltipIcon {{
|
501 |
+
background-color: {background_color} !important;
|
502 |
+
color: {text_color} !important;
|
503 |
+
}}
|
504 |
+
|
505 |
+
/* Style pour le texte du tooltip */
|
506 |
+
.st-emotion-cache-1whk732 {{
|
507 |
+
color: {text_color} !important;
|
508 |
+
}}
|
509 |
+
|
510 |
+
/* Style pour l'icône du tooltip */
|
511 |
+
.stTooltipIcon svg {{
|
512 |
+
stroke: {text_color} !important;
|
513 |
+
}}
|
514 |
+
|
515 |
+
/* Style pour la couche externe du popup */
|
516 |
+
div[data-baseweb="tooltip"] {{
|
517 |
+
background-color: {background_color} !important;
|
518 |
+
border: 1px solid {border_color} !important;
|
519 |
+
padding: 5px 10px !important;
|
520 |
+
border-radius: 4px !important;
|
521 |
+
font-size: 12px !important;
|
522 |
+
z-index: 999999 !important;
|
523 |
+
}}
|
524 |
+
|
525 |
+
/* Style pour la couche interne du popup et son contenu */
|
526 |
+
div[data-baseweb="tooltip"] > div,
|
527 |
+
div[data-baseweb="tooltip"] > div > div {{
|
528 |
+
background-color: {background_color} !important;
|
529 |
+
color: {text_color} !important;
|
530 |
+
}}
|
531 |
+
|
532 |
+
/* Force la couleur du texte sur toutes les couches possibles */
|
533 |
+
div[data-baseweb="tooltip"] *,
|
534 |
+
div[role="tooltip"] * {{
|
535 |
+
color: {text_color} !important;
|
536 |
+
background-color: {background_color} !important;
|
537 |
+
}}
|
538 |
+
|
539 |
+
/* Style pour le bouton de soumission du formulaire */
|
540 |
+
div[data-testid="stFormSubmitButton"] button {{
|
541 |
+
background-color: {button_background_color} !important;
|
542 |
+
color: {button_text_color} !important;
|
543 |
+
border: none !important;
|
544 |
+
padding: 0.5rem 1rem !important;
|
545 |
+
border-radius: 0.5rem !important;
|
546 |
+
font-weight: bold !important;
|
547 |
+
transition: all 0.3s ease !important;
|
548 |
+
}}
|
549 |
+
|
550 |
+
/* Style pour le bouton de soumission du formulaire lorsqu'il est survolé */
|
551 |
+
div[data-testid="stFormSubmitButton"] button:hover {{
|
552 |
+
border: 1px solid red !important; # Ajoutez cette ligne pour définir la bordure rouge au survol
|
553 |
+
opacity: 0.8 !important;
|
554 |
+
}}
|
555 |
+
</style>
|
556 |
+
""",
|
557 |
+
unsafe_allow_html=True
|
558 |
+
)
|