first commit

This commit is contained in:
mrtoine 2025-09-20 13:18:04 +02:00
commit e6c52820cd
227 changed files with 16156 additions and 0 deletions

View file

@ -0,0 +1,5 @@
from .client import Client
from .handler import ClientHandler
from .prospect import Prospect
from .prospect_handler import ProspectHandler
from .cli import main

849
modules/crm/cli.py Normal file
View file

@ -0,0 +1,849 @@
import os
from .client import Client
from .handler import ClientHandler
from .prospect import Prospect
from .prospect_handler import ProspectHandler
from datetime import date
def clear_screen():
"""Efface l'écran du terminal"""
os.system('cls' if os.name == 'nt' else 'clear')
def main():
"""Point d'entrée pour l'application CRM"""
client_handler = ClientHandler()
prospect_handler = ProspectHandler()
while True:
clear_screen()
print("=== CRM - Gestion des Relations Clients ===\n")
print("1. Gestion des Clients")
print("2. Gestion des Prospects")
print("3. Tableau de bord")
print("4. Retour au menu principal")
choice = input("\nSélectionnez une option (1-4): ")
if choice == "1":
# Gestion des clients
manage_clients(client_handler, prospect_handler)
elif choice == "2":
# Gestion des prospects
manage_prospects(client_handler, prospect_handler)
elif choice == "3":
# Tableau de bord
show_dashboard(client_handler, prospect_handler)
elif choice == "4":
# Retour au menu principal
print("\nRetour au menu principal...")
break
else:
print("\nOption invalide. Veuillez réessayer.")
input("\nAppuyez sur Entrée pour continuer...")
def display_all_clients(client_handler):
"""Affiche tous les clients"""
clear_screen()
print("=== Liste des Clients ===\n")
clients = client_handler.get_all_clients()
if not clients:
print("Aucun client trouvé.")
return
for i, client in enumerate(clients, 1):
print(f"{i}. {client.name} ({client.company})")
print(f" Email: {client.email} | Téléphone: {client.phone}")
print(f" Dernière interaction: {client.last_contact}")
print(f" Documents: Devis ({len(client.linked_docs['devis'])}) | "
f"Propositions ({len(client.linked_docs['propositions'])}) | "
f"Factures ({len(client.linked_docs['factures'])})")
print("")
def search_clients(client_handler):
"""Recherche des clients par nom"""
clear_screen()
print("=== Recherche de Clients ===\n")
search_term = input("Entrez un terme de recherche (nom, email, entreprise): ")
if not search_term:
print("Recherche annulée.")
return
results = []
clients = client_handler.get_all_clients()
for client in clients:
if (search_term.lower() in client.name.lower() or
search_term.lower() in client.email.lower() or
search_term.lower() in client.company.lower()):
results.append(client)
if not results:
print(f"Aucun client trouvé pour '{search_term}'.")
return
print(f"\n{len(results)} client(s) trouvé(s):\n")
for i, client in enumerate(results, 1):
print(f"{i}. {client.name} ({client.company})")
print(f" Email: {client.email} | Téléphone: {client.phone}")
print("")
# Offrir la possibilité de voir les détails d'un client
if results:
choice = input("\nEntrez le numéro du client pour voir les détails (ou Entrée pour annuler): ")
if choice.isdigit() and 1 <= int(choice) <= len(results):
client = results[int(choice) - 1]
display_client_details(client)
def add_new_client(client_handler):
"""Ajoute un nouveau client"""
clear_screen()
print("=== Ajout d'un Nouveau Client ===\n")
# Recueillir les informations du client
name = input("Nom du client: ")
if not name:
print("Le nom est obligatoire. Opération annulée.")
return
company = input("Entreprise: ")
email = input("Email: ")
phone = input("Téléphone: ")
notes = input("Notes: ")
# Créer le nouveau client
new_client = Client(
name=name,
company=company,
email=email,
phone=phone,
notes=notes
)
# Ajouter le client
client_handler.add_client(new_client)
print(f"\nClient '{name}' ajouté avec succès!")
def edit_client(client_handler):
"""Modifie un client existant"""
clear_screen()
print("=== Modification d'un Client ===\n")
# Afficher tous les clients pour sélection
clients = client_handler.get_all_clients()
if not clients:
print("Aucun client trouvé.")
return
for i, client in enumerate(clients, 1):
print(f"{i}. {client.name} ({client.company})")
# Sélectionner un client à modifier
choice = input("\nEntrez le numéro du client à modifier (ou Entrée pour annuler): ")
if not choice.isdigit() or int(choice) < 1 or int(choice) > len(clients):
print("Opération annulée ou sélection invalide.")
return
client = clients[int(choice) - 1]
# Afficher les détails actuels
print(f"\nModification de '{client.name}':")
print(f"1. Nom: {client.name}")
print(f"2. Entreprise: {client.company}")
print(f"3. Email: {client.email}")
print(f"4. Téléphone: {client.phone}")
print(f"5. Notes: {client.notes}")
print("6. Tout modifier")
print("7. Annuler")
# Choisir ce qu'il faut modifier
edit_choice = input("\nQue souhaitez-vous modifier (1-7): ")
if edit_choice == "7":
print("Modification annulée.")
return
if edit_choice == "1" or edit_choice == "6":
client.name = input(f"Nouveau nom [{client.name}]: ") or client.name
if edit_choice == "2" or edit_choice == "6":
client.company = input(f"Nouvelle entreprise [{client.company}]: ") or client.company
if edit_choice == "3" or edit_choice == "6":
client.email = input(f"Nouvel email [{client.email}]: ") or client.email
if edit_choice == "4" or edit_choice == "6":
client.phone = input(f"Nouveau téléphone [{client.phone}]: ") or client.phone
if edit_choice == "5" or edit_choice == "6":
client.notes = input(f"Nouvelles notes [{client.notes}]: ") or client.notes
# Mettre à jour le client
client_handler.update_client(client)
print(f"\nClient '{client.name}' mis à jour avec succès!")
def delete_client(client_handler):
"""Supprime un client"""
clear_screen()
print("=== Suppression d'un Client ===\n")
# Afficher tous les clients pour sélection
clients = client_handler.get_all_clients()
if not clients:
print("Aucun client trouvé.")
return
for i, client in enumerate(clients, 1):
print(f"{i}. {client.name} ({client.company})")
# Sélectionner un client à supprimer
choice = input("\nEntrez le numéro du client à supprimer (ou Entrée pour annuler): ")
if not choice.isdigit() or int(choice) < 1 or int(choice) > len(clients):
print("Opération annulée ou sélection invalide.")
return
client = clients[int(choice) - 1]
# Confirmer la suppression
confirm = input(f"Êtes-vous sûr de vouloir supprimer '{client.name}'? (o/n): ")
if confirm.lower() != 'o':
print("Suppression annulée.")
return
# Supprimer le client
client_handler.delete_client(client.id)
print(f"\nClient '{client.name}' supprimé avec succès!")
def view_client_documents(client_handler):
"""Affiche les documents associés à un client"""
clear_screen()
print("=== Documents d'un Client ===\n")
# Afficher tous les clients pour sélection
clients = client_handler.get_all_clients()
if not clients:
print("Aucun client trouvé.")
return
for i, client in enumerate(clients, 1):
print(f"{i}. {client.name} ({client.company})")
# Sélectionner un client
choice = input("\nEntrez le numéro du client pour voir ses documents (ou Entrée pour annuler): ")
if not choice.isdigit() or int(choice) < 1 or int(choice) > len(clients):
print("Opération annulée ou sélection invalide.")
return
client = clients[int(choice) - 1]
display_client_documents(client)
def display_client_details(client):
"""Affiche les détails d'un client"""
clear_screen()
print(f"=== Détails du Client: {client.name} ===\n")
print(f"ID: {client.id}")
print(f"Nom: {client.name}")
print(f"Entreprise: {client.company}")
print(f"Email: {client.email}")
print(f"Téléphone: {client.phone}")
print(f"Notes: {client.notes}")
print(f"Dernière interaction: {client.last_contact}")
print(f"Prochaine action: {client.next_action}")
if client.tags:
print(f"Tags: {', '.join(client.tags)}")
# Afficher les documents
display_client_documents(client)
def display_client_documents(client):
"""Affiche les documents associés à un client"""
print(f"\n=== Documents de {client.name} ===\n")
devis = client.linked_docs.get('devis', [])
propositions = client.linked_docs.get('propositions', [])
factures = client.linked_docs.get('factures', [])
if not devis and not propositions and not factures:
print("Aucun document trouvé pour ce client.")
return
if devis:
print("Devis:")
for i, doc in enumerate(devis, 1):
print(f" {i}. {os.path.basename(doc)}")
if propositions:
print("\nPropositions commerciales:")
for i, doc in enumerate(propositions, 1):
print(f" {i}. {os.path.basename(doc)}")
if factures:
print("\nFactures:")
for i, doc in enumerate(factures, 1):
print(f" {i}. {os.path.basename(doc)}")
# Option pour ouvrir un document
print("\n1. Ouvrir un document")
print("2. Retour")
choice = input("\nQue souhaitez-vous faire (1-2): ")
if choice == "1":
# Choisir le type de document
print("\nType de document:")
print("1. Devis")
print("2. Proposition commerciale")
print("3. Facture")
doc_type_choice = input("\nSélectionnez le type de document (1-3): ")
doc_list = []
if doc_type_choice == "1" and devis:
doc_list = devis
doc_type_name = "devis"
elif doc_type_choice == "2" and propositions:
doc_list = propositions
doc_type_name = "proposition commerciale"
elif doc_type_choice == "3" and factures:
doc_list = factures
doc_type_name = "facture"
else:
print("Choix invalide ou aucun document de ce type.")
return
# Afficher les documents du type sélectionné
print(f"\n{doc_type_name.capitalize()}s disponibles:")
for i, doc in enumerate(doc_list, 1):
print(f" {i}. {os.path.basename(doc)}")
# Sélectionner un document à ouvrir
doc_choice = input(f"\nSélectionnez un {doc_type_name} à ouvrir (1-{len(doc_list)}): ")
if doc_choice.isdigit() and 1 <= int(doc_choice) <= len(doc_list):
selected_doc = doc_list[int(doc_choice) - 1]
open_document(selected_doc)
else:
print("Sélection invalide.")
def open_document(document_path):
"""Ouvre un document PDF"""
try:
if os.path.exists(document_path):
if os.name == 'nt': # Windows
os.startfile(document_path)
elif os.name == 'posix': # macOS et Linux
if 'darwin' in os.uname().sysname.lower(): # macOS
os.system(f'open "{document_path}"')
else: # Linux
os.system(f'xdg-open "{document_path}"')
print(f"Document ouvert: {os.path.basename(document_path)}")
else:
print(f"Erreur: Le document {document_path} n'existe pas.")
except Exception as e:
print(f"Erreur lors de l'ouverture du document: {e}")
def manage_clients(client_handler, prospect_handler):
"""Gestion des clients"""
while True:
clear_screen()
print("=== Gestion des Clients ===\n")
print("1. Afficher tous les clients")
print("2. Rechercher un client")
print("3. Ajouter un nouveau client")
print("4. Modifier un client")
print("5. Supprimer un client")
print("6. Voir les documents d'un client")
print("7. Retour")
choice = input("\nSélectionnez une option (1-7): ")
if choice == "1":
# Afficher tous les clients
display_all_clients(client_handler)
elif choice == "2":
# Rechercher un client
search_clients(client_handler)
elif choice == "3":
# Ajouter un nouveau client
add_new_client(client_handler)
elif choice == "4":
# Modifier un client
edit_client(client_handler)
elif choice == "5":
# Supprimer un client
delete_client(client_handler)
elif choice == "6":
# Voir les documents d'un client
view_client_documents(client_handler)
elif choice == "7":
# Retour
break
else:
print("\nOption invalide. Veuillez réessayer.")
input("\nAppuyez sur Entrée pour continuer...")
def manage_prospects(client_handler, prospect_handler):
"""Gestion des prospects"""
while True:
clear_screen()
print("=== Gestion des Prospects ===\n")
print("1. Afficher tous les prospects")
print("2. Rechercher un prospect")
print("3. Ajouter un nouveau prospect")
print("4. Modifier un prospect")
print("5. Supprimer un prospect")
print("6. Convertir un prospect en client")
print("7. Afficher les prospects par statut")
print("8. Retour")
choice = input("\nSélectionnez une option (1-8): ")
if choice == "1":
# Afficher tous les prospects
display_all_prospects(prospect_handler)
elif choice == "2":
# Rechercher un prospect
search_prospects(prospect_handler)
elif choice == "3":
# Ajouter un nouveau prospect
add_new_prospect(prospect_handler)
elif choice == "4":
# Modifier un prospect
edit_prospect(prospect_handler)
elif choice == "5":
# Supprimer un prospect
delete_prospect(prospect_handler)
elif choice == "6":
# Convertir un prospect en client
convert_prospect_to_client(prospect_handler, client_handler)
elif choice == "7":
# Afficher les prospects par statut
display_prospects_by_status(prospect_handler)
elif choice == "8":
# Retour
break
else:
print("\nOption invalide. Veuillez réessayer.")
input("\nAppuyez sur Entrée pour continuer...")
def show_dashboard(client_handler, prospect_handler):
"""Affiche un tableau de bord avec des statistiques"""
clear_screen()
print("=== Tableau de Bord CRM ===\n")
# Récupérer les données
clients = client_handler.get_all_clients()
prospects = prospect_handler.get_all_prospects()
# Statistiques de base
print(f"Nombre total de clients: {len(clients)}")
print(f"Nombre total de prospects: {len(prospects)}")
# Statistiques des prospects par statut
statuses = {}
for prospect in prospects:
status = prospect.status
if status in statuses:
statuses[status] += 1
else:
statuses[status] = 1
if statuses:
print("\nProspects par statut:")
for status, count in statuses.items():
print(f" {status}: {count}")
# Derniers prospects ajoutés
recent_prospects = prospect_handler.get_recent_prospects(days=30)
if recent_prospects:
print(f"\nNouveaux prospects (30 derniers jours): {len(recent_prospects)}")
# Activité récente
print("\nRécapitulatif des activités récentes:")
print(" Propositions commerciales en attente...")
print(" Devis en cours...")
# Suggestions d'actions
print("\nSuggestions d'actions:")
if len(prospects) > 0:
print(" • Suivez vos prospects pour augmenter votre taux de conversion")
if len(clients) > 0:
print(" • Contactez vos clients existants pour des opportunités de vente additionnelle")
if len(prospects) == 0:
print(" • Ajoutez des prospects pour développer votre portefeuille client")
def display_all_prospects(prospect_handler):
"""Affiche tous les prospects"""
clear_screen()
print("=== Liste des Prospects ===\n")
prospects = prospect_handler.get_all_prospects()
if not prospects:
print("Aucun prospect trouvé.")
return
for i, prospect in enumerate(prospects, 1):
print(f"{i}. {prospect.name} ({prospect.company})")
print(f" Email: {prospect.email} | Téléphone: {prospect.phone}")
print(f" Statut: {prospect.status} | Source: {prospect.source}")
print(f" Dernière interaction: {prospect.last_contact}")
print("")
def search_prospects(prospect_handler):
"""Recherche des prospects par nom"""
clear_screen()
print("=== Recherche de Prospects ===\n")
search_term = input("Entrez un terme de recherche (nom, email, entreprise): ")
if not search_term:
print("Recherche annulée.")
return
results = []
prospects = prospect_handler.get_all_prospects()
for prospect in prospects:
if (search_term.lower() in prospect.name.lower() or
search_term.lower() in prospect.email.lower() or
search_term.lower() in prospect.company.lower()):
results.append(prospect)
if not results:
print(f"Aucun prospect trouvé pour '{search_term}'.")
return
print(f"\n{len(results)} prospect(s) trouvé(s):\n")
for i, prospect in enumerate(results, 1):
print(f"{i}. {prospect.name} ({prospect.company})")
print(f" Email: {prospect.email} | Téléphone: {prospect.phone}")
print(f" Statut: {prospect.status}")
print("")
# Offrir la possibilité de voir les détails d'un prospect
if results:
choice = input("\nEntrez le numéro du prospect pour voir les détails (ou Entrée pour annuler): ")
if choice.isdigit() and 1 <= int(choice) <= len(results):
prospect = results[int(choice) - 1]
display_prospect_details(prospect)
def add_new_prospect(prospect_handler):
"""Ajoute un nouveau prospect"""
clear_screen()
print("=== Ajout d'un Nouveau Prospect ===\n")
# Recueillir les informations du prospect
name = input("Nom du prospect: ")
if not name:
print("Le nom est obligatoire. Opération annulée.")
return
company = input("Entreprise: ")
email = input("Email: ")
phone = input("Téléphone: ")
source = input("Source (site web, référence, salon, etc.): ")
notes = input("Notes: ")
# Statut du prospect
print("\nStatut du prospect:")
print("1. Nouveau")
print("2. Contacté")
print("3. Qualifié")
print("4. Proposition envoyée")
print("5. Non intéressé")
status_choice = input("\nSélectionnez le statut (1-5): ")
status = "Nouveau" # Défaut
if status_choice == "1":
status = "Nouveau"
elif status_choice == "2":
status = "Contacté"
elif status_choice == "3":
status = "Qualifié"
elif status_choice == "4":
status = "Proposition envoyée"
elif status_choice == "5":
status = "Non intéressé"
# Créer le nouveau prospect
new_prospect = Prospect(
name=name,
company=company,
email=email,
phone=phone,
source=source,
notes=notes,
status=status
)
# Ajouter le prospect
prospect_handler.add_prospect(new_prospect)
print(f"\nProspect '{name}' ajouté avec succès!")
def edit_prospect(prospect_handler):
"""Modifie un prospect existant"""
clear_screen()
print("=== Modification d'un Prospect ===\n")
# Afficher tous les prospects pour sélection
prospects = prospect_handler.get_all_prospects()
if not prospects:
print("Aucun prospect trouvé.")
return
for i, prospect in enumerate(prospects, 1):
print(f"{i}. {prospect.name} ({prospect.company}) - {prospect.status}")
# Sélectionner un prospect à modifier
choice = input("\nEntrez le numéro du prospect à modifier (ou Entrée pour annuler): ")
if not choice.isdigit() or int(choice) < 1 or int(choice) > len(prospects):
print("Opération annulée ou sélection invalide.")
return
prospect = prospects[int(choice) - 1]
# Afficher les détails actuels
print(f"\nModification de '{prospect.name}':")
print(f"1. Nom: {prospect.name}")
print(f"2. Entreprise: {prospect.company}")
print(f"3. Email: {prospect.email}")
print(f"4. Téléphone: {prospect.phone}")
print(f"5. Source: {prospect.source}")
print(f"6. Statut: {prospect.status}")
print(f"7. Notes: {prospect.notes}")
print("8. Tout modifier")
print("9. Annuler")
# Choisir ce qu'il faut modifier
edit_choice = input("\nQue souhaitez-vous modifier (1-9): ")
if edit_choice == "9":
print("Modification annulée.")
return
if edit_choice == "1" or edit_choice == "8":
prospect.name = input(f"Nouveau nom [{prospect.name}]: ") or prospect.name
if edit_choice == "2" or edit_choice == "8":
prospect.company = input(f"Nouvelle entreprise [{prospect.company}]: ") or prospect.company
if edit_choice == "3" or edit_choice == "8":
prospect.email = input(f"Nouvel email [{prospect.email}]: ") or prospect.email
if edit_choice == "4" or edit_choice == "8":
prospect.phone = input(f"Nouveau téléphone [{prospect.phone}]: ") or prospect.phone
if edit_choice == "5" or edit_choice == "8":
prospect.source = input(f"Nouvelle source [{prospect.source}]: ") or prospect.source
if edit_choice == "6" or edit_choice == "8":
print("\nStatut du prospect:")
print("1. Nouveau")
print("2. Contacté")
print("3. Qualifié")
print("4. Proposition envoyée")
print("5. Non intéressé")
status_choice = input(f"\nSélectionnez le statut (1-5) [Actuel: {prospect.status}]: ")
if status_choice == "1":
prospect.status = "Nouveau"
elif status_choice == "2":
prospect.status = "Contacté"
elif status_choice == "3":
prospect.status = "Qualifié"
elif status_choice == "4":
prospect.status = "Proposition envoyée"
elif status_choice == "5":
prospect.status = "Non intéressé"
if edit_choice == "7" or edit_choice == "8":
prospect.notes = input(f"Nouvelles notes [{prospect.notes}]: ") or prospect.notes
# Mettre à jour le prospect
prospect_handler.update_prospect(prospect)
print(f"\nProspect '{prospect.name}' mis à jour avec succès!")
def delete_prospect(prospect_handler):
"""Supprime un prospect"""
clear_screen()
print("=== Suppression d'un Prospect ===\n")
# Afficher tous les prospects pour sélection
prospects = prospect_handler.get_all_prospects()
if not prospects:
print("Aucun prospect trouvé.")
return
for i, prospect in enumerate(prospects, 1):
print(f"{i}. {prospect.name} ({prospect.company}) - {prospect.status}")
# Sélectionner un prospect à supprimer
choice = input("\nEntrez le numéro du prospect à supprimer (ou Entrée pour annuler): ")
if not choice.isdigit() or int(choice) < 1 or int(choice) > len(prospects):
print("Opération annulée ou sélection invalide.")
return
prospect = prospects[int(choice) - 1]
# Confirmer la suppression
confirm = input(f"Êtes-vous sûr de vouloir supprimer '{prospect.name}'? (o/n): ")
if confirm.lower() != 'o':
print("Suppression annulée.")
return
# Supprimer le prospect
prospect_handler.delete_prospect(prospect.id)
print(f"\nProspect '{prospect.name}' supprimé avec succès!")
def convert_prospect_to_client(prospect_handler, client_handler):
"""Convertit un prospect en client"""
clear_screen()
print("=== Conversion d'un Prospect en Client ===\n")
# Afficher tous les prospects pour sélection
prospects = prospect_handler.get_all_prospects()
if not prospects:
print("Aucun prospect trouvé.")
return
for i, prospect in enumerate(prospects, 1):
print(f"{i}. {prospect.name} ({prospect.company}) - {prospect.status}")
# Sélectionner un prospect à convertir
choice = input("\nEntrez le numéro du prospect à convertir en client (ou Entrée pour annuler): ")
if not choice.isdigit() or int(choice) < 1 or int(choice) > len(prospects):
print("Opération annulée ou sélection invalide.")
return
prospect = prospects[int(choice) - 1]
# Confirmer la conversion
confirm = input(f"Êtes-vous sûr de vouloir convertir '{prospect.name}' en client? (o/n): ")
if confirm.lower() != 'o':
print("Conversion annulée.")
return
# Convertir le prospect en client
client = prospect_handler.convert_to_client(prospect.id, client_handler)
if client:
print(f"\nProspect '{prospect.name}' converti en client avec succès!")
else:
print("\nErreur lors de la conversion du prospect en client.")
def display_prospect_details(prospect):
"""Affiche les détails d'un prospect"""
clear_screen()
print(f"=== Détails du Prospect: {prospect.name} ===\n")
print(f"ID: {prospect.id}")
print(f"Nom: {prospect.name}")
print(f"Entreprise: {prospect.company}")
print(f"Email: {prospect.email}")
print(f"Téléphone: {prospect.phone}")
print(f"Source: {prospect.source}")
print(f"Statut: {prospect.status}")
print(f"Notes: {prospect.notes}")
print(f"Dernière interaction: {prospect.last_contact}")
print(f"Prochaine action: {prospect.next_action}")
if prospect.tags:
print(f"Tags: {', '.join(prospect.tags)}")
def display_prospects_by_status(prospect_handler):
"""Affiche les prospects regroupés par statut"""
clear_screen()
print("=== Prospects par Statut ===\n")
# Récupérer tous les prospects
prospects = prospect_handler.get_all_prospects()
if not prospects:
print("Aucun prospect trouvé.")
return
# Regrouper les prospects par statut
status_groups = {}
for prospect in prospects:
status = prospect.status
if status not in status_groups:
status_groups[status] = []
status_groups[status].append(prospect)
# Afficher les prospects par groupe de statut
for status, prospects_list in status_groups.items():
print(f"\n=== {status} ({len(prospects_list)}) ===")
for i, prospect in enumerate(prospects_list, 1):
print(f"{i}. {prospect.name} ({prospect.company})")
print(f" Email: {prospect.email} | Téléphone: {prospect.phone}")
print("\n")
# Option pour filtrer par un statut spécifique
print("Voulez-vous voir les détails d'un statut spécifique?")
print("1. Nouveau")
print("2. Contacté")
print("3. Qualifié")
print("4. Proposition envoyée")
print("5. Non intéressé")
print("6. Retour")
choice = input("\nSélectionnez une option (1-6): ")
status_mapping = {
"1": "Nouveau",
"2": "Contacté",
"3": "Qualifié",
"4": "Proposition envoyée",
"5": "Non intéressé"
}
if choice in status_mapping:
selected_status = status_mapping[choice]
filtered_prospects = prospect_handler.get_prospects_by_status(selected_status)
if filtered_prospects:
clear_screen()
print(f"=== Prospects avec statut: {selected_status} ===\n")
for i, prospect in enumerate(filtered_prospects, 1):
print(f"{i}. {prospect.name} ({prospect.company})")
print(f" Email: {prospect.email} | Téléphone: {prospect.phone}")
print(f" Dernière interaction: {prospect.last_contact}")
print(f" Notes: {prospect.notes[:50]}..." if len(prospect.notes) > 50 else f" Notes: {prospect.notes}")
print("")
# Option pour voir les détails d'un prospect spécifique
detail_choice = input("\nEntrez le numéro du prospect pour voir les détails (ou Entrée pour annuler): ")
if detail_choice.isdigit() and 1 <= int(detail_choice) <= len(filtered_prospects):
prospect = filtered_prospects[int(detail_choice) - 1]
display_prospect_details(prospect)
else:
print(f"\nAucun prospect avec le statut '{selected_status}' trouvé.")
if __name__ == "__main__":
main()

54
modules/crm/client.py Normal file
View file

@ -0,0 +1,54 @@
from datetime import date
import uuid
from datetime import date
import uuid
class Client:
def __init__(
self,
name: str,
company: str = "",
email: str = "",
phone: str = "",
notes: str = "",
tags: list = None,
last_contact: str = None,
next_action: str = "",
linked_docs: dict = None,
id: str = None,
created_at: str = None
):
self.id = id or f"cli_{uuid.uuid4().hex[:8]}"
self.name = name
self.company = company
self.email = email
self.phone = phone
self.notes = notes
self.tags = tags or []
self.last_contact = last_contact or str(date.today())
self.next_action = next_action
self.linked_docs = linked_docs or {"devis": [], "propositions": [], "factures": []}
self.created_at = created_at or str(date.today())
def to_dict(self):
return {
"id": self.id,
"name": self.name,
"company": self.company,
"email": self.email,
"phone": self.phone,
"notes": self.notes,
"tags": self.tags,
"last_contact": self.last_contact,
"next_action": self.next_action,
"linked_docs": self.linked_docs,
"created_at": self.created_at
}
@staticmethod
def from_dict(data: dict):
return Client(**data)
def __str__(self):
return f"Client({self.name}, {self.company}, {self.email}, {self.phone})"

172
modules/crm/handler.py Normal file
View file

@ -0,0 +1,172 @@
from datetime import date
import os
import json
from .client import Client
from core.data import Data
class ClientHandler:
def __init__(self, clients_folder="Data/clients"):
self.clients_folder = clients_folder
self.clients = []
self.load_clients()
def load_clients(self):
"""Charge tous les clients depuis les fichiers existants"""
self.clients = []
# Charger les clients depuis les fichiers JSON existants
if os.path.exists(self.clients_folder):
for filename in os.listdir(self.clients_folder):
if filename.endswith('.json'):
client_path = os.path.join(self.clients_folder, filename)
try:
# Utiliser le gestionnaire de données pour charger le fichier JSON
data_manager = Data(client_path)
client_data = data_manager.load_data()
# Si le fichier a la structure d'un client, créer un objet Client
if "client_name" in client_data:
# Conversion du format existant vers le format Client
client_obj = {
"name": client_data.get("client_name", ""),
"email": client_data.get("client_email", ""),
"phone": client_data.get("client_phone", ""),
"notes": client_data.get("additional_info", ""),
"company": client_data.get("project_name", ""),
"tags": [], # Pas de tags dans les données existantes
"next_action": "", # Pas d'action suivante dans les données existantes
"linked_docs": self._find_linked_docs(client_data.get("client_name", ""))
}
client = Client(**client_obj)
self.clients.append(client)
except Exception as e:
print(f"Erreur lors du chargement du client {filename}: {e}")
return self.clients
def _find_linked_docs(self, client_name):
"""Trouve les documents associés à un client"""
linked_docs = {"devis": [], "propositions": [], "factures": []}
client_name_normalized = client_name.replace(" ", "_").lower()
# Rechercher les devis
devis_folder = "output/devis"
if os.path.exists(devis_folder):
for filename in os.listdir(devis_folder):
if filename.startswith(client_name_normalized) and filename.endswith(".pdf"):
linked_docs["devis"].append(os.path.join(devis_folder, filename))
# Rechercher les propositions
propositions_folder = "output/propositions"
if os.path.exists(propositions_folder):
for filename in os.listdir(propositions_folder):
if filename.startswith(client_name_normalized) and filename.endswith(".pdf"):
linked_docs["propositions"].append(os.path.join(propositions_folder, filename))
# Rechercher les factures (si elles existent)
factures_folder = "output/factures"
if os.path.exists(factures_folder):
for filename in os.listdir(factures_folder):
if filename.startswith(client_name_normalized) and filename.endswith(".pdf"):
linked_docs["factures"].append(os.path.join(factures_folder, filename))
return linked_docs
def get_all_clients(self):
"""Retourne tous les clients"""
return self.clients
def get_client_by_id(self, client_id):
"""Retourne un client par son ID"""
for client in self.clients:
if client.id == client_id:
return client
return None
def get_client_by_name(self, client_name):
"""Retourne un client par son nom"""
for client in self.clients:
if client.name.lower() == client_name.lower():
return client
return None
def add_client(self, client):
"""Ajoute un nouveau client"""
self.clients.append(client)
# Sauvegarder le client dans un fichier JSON
self._save_client(client)
return client
def update_client(self, client):
"""Met à jour un client existant"""
for i, existing_client in enumerate(self.clients):
if existing_client.id == client.id:
self.clients[i] = client
# Sauvegarder le client mis à jour
self._save_client(client)
return client
return None
def delete_client(self, client_id):
"""Supprime un client"""
for i, client in enumerate(self.clients):
if client.id == client_id:
deleted_client = self.clients.pop(i)
# Supprimer le fichier JSON du client
client_file = os.path.join(self.clients_folder, f"{deleted_client.name.replace(' ', '_').lower()}.json")
if os.path.exists(client_file):
os.remove(client_file)
return deleted_client
return None
def _save_client(self, client):
"""Sauvegarde un client dans un fichier JSON"""
client_data = {
"client_name": client.name,
"client_email": client.email,
"client_phone": client.phone,
"additional_info": client.notes,
"project_name": client.company,
# Autres champs pour maintenir la compatibilité avec le format existant
"project_type": "",
"project_description": "",
"features": [],
"budget": "",
"payment_terms": "",
"contact_info": f"{client.name}, {client.phone}",
"deadline": ""
}
client_file = os.path.join(self.clients_folder, f"{client.name.replace(' ', '_').lower()}.json")
data_manager = Data(client_file)
data_manager.save_data(client_data)
return client_file
def add_task_for_client(self, client_id: str, title: str, due_date: str, description: str = "", priority: str = "normale", metadata: dict = None):
"""
Crée une tâche liée à un client.
:param client_id: ID du client
:param title: Titre de la tâche
:param due_date: Date d'échéance au format ISO (YYYY-MM-DD)
:param description: Description optionnelle
:param priority: 'basse' | 'normale' | 'haute'
:param metadata: métadonnées optionnelles
:return: ID de la tâche créée ou None si client introuvable
"""
client = self.get_client_by_id(client_id)
if not client:
return None
# Import local pour éviter dépendances circulaires au chargement
from modules.tasks.task import Task
from modules.tasks.task_handler import TaskHandler
task = Task(
title=title,
due_date=due_date,
description=description,
entity_type="client",
entity_id=client_id,
priority=priority,
metadata=metadata or {},
)
return TaskHandler().add_task(task)

95
modules/crm/prospect.py Normal file
View file

@ -0,0 +1,95 @@
from datetime import date
import uuid
from typing import Union
from .status import ProspectStatus
class Prospect:
def __init__(
self,
name: str,
company: str = "",
email: str = "",
phone: str = "",
source: str = "", # D'où vient ce prospect (site web, référence, etc.)
notes: str = "",
status: Union[ProspectStatus, str] = ProspectStatus.NOUVEAU, # Nouveau, Contacté, Qualifié, Proposition, Non intéressé
tags: list = None,
last_contact: str = None,
next_action: str = "",
linked_docs: dict = None,
id: str = None,
created_at: str = None,
score: int = 0,
last_interaction: str = None
):
self.id = id or f"pros_{uuid.uuid4().hex[:8]}"
self.name = name
self.company = company
self.email = email
self.phone = phone
self.source = source
self.notes = notes
# Stockage interne en Enum
self._status: ProspectStatus = ProspectStatus.from_value(status)
self.tags = tags or []
self.last_contact = last_contact or str(date.today())
self.next_action = next_action
self.linked_docs = linked_docs or {"propositions": []}
self.created_at = created_at or str(date.today())
self.score = score or 0
self.last_interaction = last_interaction
@property
def status(self) -> str:
"""Exposition publique du statut en chaîne pour compatibilité (templates, JSON)."""
return self._status.value
@status.setter
def status(self, value: Union[ProspectStatus, str]) -> None:
"""Permet d'assigner soit une chaîne, soit l'enum directement."""
self._status = ProspectStatus.from_value(value)
def to_dict(self):
return {
"id": self.id,
"name": self.name,
"company": self.company,
"email": self.email,
"phone": self.phone,
"source": self.source,
"notes": self.notes,
"status": self.status, # sérialise en chaîne
"tags": self.tags,
"last_contact": self.last_contact,
"next_action": self.next_action,
"linked_docs": self.linked_docs,
"created_at": self.created_at,
"score": self.score,
"last_interaction": self.last_interaction
}
@staticmethod
def from_dict(data: dict):
# __init__ gère la conversion status -> Enum
return Prospect(**data)
def convert_to_client(self):
"""Convertit ce prospect en client"""
from .client import Client
return Client(
name=self.name,
company=self.company,
email=self.email,
phone=self.phone,
notes=self.notes,
tags=self.tags,
last_contact=self.last_contact,
next_action=self.next_action,
linked_docs=self.linked_docs
)
def __str__(self):
return f"Prospect({self.name}, {self.company}, {self.status}, {self.email})"

View file

@ -0,0 +1,157 @@
from datetime import date
import os
import json
from .prospect import Prospect
from core.data import Data
class ProspectHandler:
def __init__(self, prospects_folder="Data/prospects"):
self.prospects_folder = prospects_folder
self.prospects = []
# Créer le dossier des prospects s'il n'existe pas
if not os.path.exists(self.prospects_folder):
os.makedirs(self.prospects_folder)
self.load_prospects()
def load_prospects(self):
"""Charge tous les prospects depuis les fichiers existants"""
self.prospects = []
# Charger les prospects depuis les fichiers JSON existants
if os.path.exists(self.prospects_folder):
for filename in os.listdir(self.prospects_folder):
if filename.endswith('.json'):
prospect_path = os.path.join(self.prospects_folder, filename)
try:
# Utiliser le gestionnaire de données pour charger le fichier JSON
data_manager = Data(prospect_path)
prospect_data = data_manager.load_data()
# Si le fichier a la structure d'un prospect, créer un objet Prospect
if "name" in prospect_data:
prospect = Prospect.from_dict(prospect_data)
self.prospects.append(prospect)
except Exception as e:
print(f"Erreur lors du chargement du prospect {filename}: {e}")
return self.prospects
def get_all_prospects(self):
"""Retourne tous les prospects"""
return self.prospects
def get_prospect_by_id(self, prospect_id):
"""Retourne un prospect par son ID"""
for prospect in self.prospects:
if prospect.id == prospect_id:
return prospect
return None
def get_prospect_by_name(self, prospect_name):
"""Retourne un prospect par son nom"""
for prospect in self.prospects:
if prospect.name.lower() == prospect_name.lower():
return prospect
return None
def add_prospect(self, prospect):
"""Ajoute un nouveau prospect"""
self.prospects.append(prospect)
# Sauvegarder le prospect dans un fichier JSON
self._save_prospect(prospect)
return prospect
def update_prospect(self, prospect):
"""Met à jour un prospect existant"""
for i, existing_prospect in enumerate(self.prospects):
if existing_prospect.id == prospect.id:
self.prospects[i] = prospect
# Sauvegarder le prospect mis à jour
self._save_prospect(prospect)
return prospect
return None
def delete_prospect(self, prospect_id):
"""Supprime un prospect"""
for i, prospect in enumerate(self.prospects):
if prospect.id == prospect_id:
deleted_prospect = self.prospects.pop(i)
# Supprimer le fichier JSON du prospect
prospect_file = os.path.join(self.prospects_folder, f"{deleted_prospect.id}.json")
if os.path.exists(prospect_file):
os.remove(prospect_file)
return deleted_prospect
return None
def convert_to_client(self, prospect_id, client_handler):
"""Convertit un prospect en client et le supprime de la liste des prospects"""
prospect = self.get_prospect_by_id(prospect_id)
if prospect:
# Créer un client à partir du prospect
client = prospect.convert_to_client()
# Ajouter le client
client_handler.add_client(client)
# Supprimer le prospect
self.delete_prospect(prospect_id)
return client
return None
def _save_prospect(self, prospect):
"""Sauvegarde un prospect dans un fichier JSON"""
prospect_data = prospect.to_dict()
prospect_file = os.path.join(self.prospects_folder, f"{prospect.id}.json")
data_manager = Data(prospect_file)
data_manager.save_data(prospect_data)
return prospect_file
def get_prospects_by_status(self, status):
"""Retourne les prospects par statut"""
return [p for p in self.prospects if p.status.lower() == status.lower()]
def get_recent_prospects(self, days=30):
"""Retourne les prospects créés dans les X derniers jours"""
today = date.today()
recent_prospects = []
for prospect in self.prospects:
try:
created_date = date.fromisoformat(prospect.created_at)
delta = today - created_date
if delta.days <= days:
recent_prospects.append(prospect)
except ValueError:
# Si la date n'est pas au format ISO, ignorer ce prospect
pass
return recent_prospects
def add_task_for_prospect(self, prospect_id: str, title: str, due_date: str, description: str = "", priority: str = "normale", metadata: dict = None):
"""
Crée une tâche liée à un prospect.
:param prospect_id: ID du prospect
:param title: Titre de la tâche
:param due_date: Date d'échéance au format ISO (YYYY-MM-DD)
:param description: Description optionnelle
:param priority: 'basse' | 'normale' | 'haute'
:param metadata: métadonnées optionnelles
:return: ID de la tâche créée ou None si prospect introuvable
"""
prospect = self.get_prospect_by_id(prospect_id)
if not prospect:
return None
from modules.tasks.task import Task
from modules.tasks.task_handler import TaskHandler
task = Task(
title=title,
due_date=due_date,
description=description,
entity_type="prospect",
entity_id=prospect_id,
priority=priority,
metadata=metadata or {},
)
return TaskHandler().add_task(task)

52
modules/crm/scoring.py Normal file
View file

@ -0,0 +1,52 @@
from datetime import date
from typing import Literal
from modules.crm.prospect_handler import ProspectHandler
def categorize(score: int) -> Literal["Froid", "Tiède", "Chaud"]:
if score is None:
score = 0
if score >= 50:
return "Chaud"
if score >= 20:
return "Tiède"
return "Froid"
def adjust_score(prospect_id: str, delta: int) -> int:
"""
Ajuste le score d'un prospect et met à jour la dernière interaction.
Retourne le score à jour.
"""
handler = ProspectHandler()
p = handler.get_prospect_by_id(prospect_id)
if not p:
return 0
try:
current = int(getattr(p, "score", 0) or 0)
except Exception:
current = 0
new_score = max(0, current + int(delta))
setattr(p, "score", new_score)
setattr(p, "last_interaction", date.today().isoformat())
# On ne change pas le statut ici, sauf règle spécifique (reply)
handler.update_prospect(p)
return new_score
def adjust_score_for_event(prospect_id: str, event: Literal["open", "click", "reply"]) -> int:
if event == "open":
return adjust_score(prospect_id, 10)
if event == "click":
return adjust_score(prospect_id, 20)
if event == "reply":
score = adjust_score(prospect_id, 30)
# Règle: un reply passe le prospect en statut "Chaud"
handler = ProspectHandler()
p = handler.get_prospect_by_id(prospect_id)
if p:
p.status = "Chaud"
handler.update_prospect(p)
return score
return adjust_score(prospect_id, 0)

181
modules/crm/search.py Normal file
View file

@ -0,0 +1,181 @@
import json
import os
from datetime import datetime
from typing import List, Dict, Any, Optional
def _project_root() -> str:
# modules/crm/search.py -> remonter à la racine projet
return os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
def _prospects_dir() -> str:
return os.path.join(_project_root(), "Data", "prospects")
def _read_json(path: str) -> Optional[Dict[str, Any]]:
try:
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
return None
def _norm_date(date_str: Optional[str]) -> Optional[str]:
if not date_str:
return None
# Tente de normaliser vers YYYY-MM-DD
s = str(date_str)
# Si déjà au format ISO ou contient "T"
if len(s) >= 10:
return s[:10]
return None
def _extract_fields(data: Dict[str, Any], fallback_id: str) -> Dict[str, Any]:
# Essaye plusieurs variantes de clés
name = data.get("name") or data.get("nom") or data.get("full_name") or data.get("contact_name") or ""
email = data.get("email") or data.get("mail") or ""
company = data.get("company") or data.get("societe") or data.get("entreprise") or data.get("organization") or ""
status = data.get("status") or data.get("statut") or ""
city = data.get("city") or data.get("ville") or ""
tags = data.get("tags") or []
if isinstance(tags, str):
# transformer "a, b, c" -> ["a","b","c"]
tags = [t.strip() for t in tags.split(",") if t.strip()]
created_at = data.get("created_at") or data.get("created") or data.get("date_created") or ""
created_at = _norm_date(created_at) or ""
pid = data.get("id") or fallback_id
# Liaisons potentielles
client_id = data.get("client_id") or data.get("clientId") or data.get("client")
project_ids = data.get("project_ids") or data.get("projects") or data.get("projectId") or []
if isinstance(project_ids, (str, int)):
project_ids = [str(project_ids)]
elif isinstance(project_ids, list):
project_ids = [str(x) for x in project_ids]
return {
"id": pid,
"entity_type": "prospect",
"name": name,
"email": email,
"company": company,
"status": status,
"city": city,
"tags": tags,
"created_at": created_at,
"client_id": client_id,
"project_ids": project_ids,
}
def load_all_prospects() -> List[Dict[str, Any]]:
base_dir = _prospects_dir()
os.makedirs(base_dir, exist_ok=True)
results: List[Dict[str, Any]] = []
for fn in os.listdir(base_dir):
if not fn.endswith(".json"):
continue
path = os.path.join(base_dir, fn)
data = _read_json(path)
if not data:
continue
pid = os.path.splitext(fn)[0]
results.append(_extract_fields(data, pid))
return results
def _contains(hay: str, needle: str) -> bool:
return needle in hay
def _safe_lower(s: Optional[str]) -> str:
return (s or "").lower()
def filter_prospects(
*,
q: Optional[str] = None,
name: Optional[str] = None,
email: Optional[str] = None,
company: Optional[str] = None,
status: Optional[str] = None,
city: Optional[str] = None,
tags: Optional[List[str]] = None,
date_from: Optional[str] = None, # YYYY-MM-DD
date_to: Optional[str] = None, # YYYY-MM-DD
) -> List[Dict[str, Any]]:
items = load_all_prospects()
q = _safe_lower(q)
name = _safe_lower(name)
email = _safe_lower(email)
company = _safe_lower(company)
status = _safe_lower(status)
city = _safe_lower(city)
tags = [t.strip().lower() for t in (tags or []) if t.strip()]
df = None
dt = None
try:
if date_from:
df = datetime.strptime(date_from, "%Y-%m-%d").date()
except Exception:
df = None
try:
if date_to:
dt = datetime.strptime(date_to, "%Y-%m-%d").date()
except Exception:
dt = None
out: List[Dict[str, Any]] = []
for it in items:
iname = _safe_lower(it.get("name"))
iemail = _safe_lower(it.get("email"))
icompany = _safe_lower(it.get("company"))
istatus = _safe_lower(it.get("status"))
icity = _safe_lower(it.get("city"))
itags = [str(t).lower() for t in (it.get("tags") or [])]
icreated = it.get("created_at") or ""
icreated_d = None
try:
if icreated:
icreated_d = datetime.strptime(icreated[:10], "%Y-%m-%d").date()
except Exception:
icreated_d = None
# Filtre global q
if q and not (
_contains(iname, q) or _contains(iemail, q) or _contains(icompany, q) or _contains(icity, q)
):
continue
# Filtres spécifiques
if name and not _contains(iname, name):
continue
if email and not _contains(iemail, email):
continue
if company and not _contains(icompany, company):
continue
if status and istatus != status:
continue
if city and not _contains(icity, city):
continue
if tags:
# exiger que tous les tags demandés soient présents
itags_set = set(itags)
if not all(tag in itags_set for tag in tags):
continue
if df and (not icreated_d or icreated_d < df):
continue
if dt and (not icreated_d or icreated_d > dt):
continue
out.append(it)
# On peut trier par date de création desc, puis nom
out.sort(key=lambda d: (d.get("created_at") or "", d.get("name") or ""), reverse=True)
return out

51
modules/crm/status.py Normal file
View file

@ -0,0 +1,51 @@
import unicodedata
from enum import Enum
from typing import Union, Optional
def _normalize_no_accents(s: str) -> str:
"""Normalise une chaîne en supprimant les accents et en la passant en minuscule."""
return "".join(c for c in unicodedata.normalize("NFKD", s) if not unicodedata.combining(c)).lower()
class ProspectStatus(str, Enum):
NOUVEAU = "Nouveau"
CONTACTE = "Contacté"
RELANCE = "Relancé"
QUALIFIE = "Qualifié"
PROPOSITION = "Proposition"
NON_INTERESSE = "Non intéressé"
@classmethod
def from_value(cls, value: Optional[Union["ProspectStatus", str]]) -> "ProspectStatus":
"""Convertit une valeur (enum ou chaîne) en ProspectStatus, avec tolérance sur la casse/accents."""
if isinstance(value, cls):
return value
if value is None:
return cls.NOUVEAU
s = str(value).strip()
if not s:
return cls.NOUVEAU
n = _normalize_no_accents(s)
if n in {"nouveau", "new"}:
return cls.NOUVEAU
if n in {"contacte", "contacted"}:
return cls.CONTACTE
if n in {"relance", "relaunched"}:
return cls.RELANCE
if n in {"qualifie", "qualified"}:
return cls.QUALIFIE
if n in {"proposition", "proposal"}:
return cls.PROPOSITION
if n in {"non interesse", "noninteresse", "not interested", "refuse", "refus", "abandon"}:
return cls.NON_INTERESSE
# Tentative de correspondance exacte insensible aux accents/casse avec les valeurs de l'enum
for member in cls:
if _normalize_no_accents(member.value) == n:
return member
# Valeur inconnue -> par défaut
return cls.NOUVEAU

78
modules/crm/web.py Normal file
View file

@ -0,0 +1,78 @@
from flask import Blueprint, request, jsonify, url_for
from typing import List
from modules.crm.search import filter_prospects
crm_api_bp = Blueprint("crm_api", __name__, url_prefix="/api/crm")
@crm_api_bp.get("/prospects/search")
def prospects_search():
"""
API de recherche/filtre prospects.
Paramètres (query string):
- q: recherche globale (nom, email, société, ville)
- name, email, company, status, city: filtres spécifiques (contient, sauf status: égal insensible à la casse)
- tags: liste séparée par des virgules (tous doivent être présents)
- date_from, date_to: période sur created_at (YYYY-MM-DD)
"""
q = request.args.get("q")
name = request.args.get("name")
email = request.args.get("email")
company = request.args.get("company")
status = request.args.get("status")
city = request.args.get("city")
tags_raw = request.args.get("tags") or ""
tags: List[str] = [t.strip() for t in tags_raw.split(",")] if tags_raw else []
date_from = request.args.get("date_from") or None
date_to = request.args.get("date_to") or None
results = filter_prospects(
q=q,
name=name,
email=email,
company=company,
status=status,
city=city,
tags=tags,
date_from=date_from,
date_to=date_to,
)
# Enrichit avec des URLs navigables
enriched = []
for it in results:
new_it = dict(it)
# URL principale (prospect)
try:
new_it["url"] = url_for("prospect_details", prospect_id=it.get("id"))
except Exception:
pass
related = []
cid = it.get("client_id")
if cid:
try:
related.append({"type": "client", "url": url_for("client_details", client_id=cid)})
except Exception:
pass
pids = it.get("project_ids") or []
for pid in pids:
try:
related.append({"type": "project", "url": url_for("project_details", project_id=pid)})
except Exception:
pass
if related:
new_it["related"] = related
# S'assure que le type est présent
new_it["entity_type"] = new_it.get("entity_type") or "prospect"
enriched.append(new_it)
return jsonify(
{
"count": len(enriched),
"results": enriched,
}
)

88
modules/devis/app.py Normal file
View file

@ -0,0 +1,88 @@
def main():
print("=== Générateur de devis ===\n")
# Importation des modules nécessaires
from core.form import Form
from core.generator import Generator
from core.data import Data
import os
# On demande d'abord si le client existe déjà
client_name = input("Entrez le nom du client : ")
client_name = client_name.replace(" ", "_").lower() # Normaliser le nom du client
client_data_path = f"Data/clients/{client_name}.json"
if os.path.exists(client_data_path):
print(f"Le client {client_name} existe déjà. Chargement des données...")
data_manager = Data(client_data_path)
client_data = data_manager.load_data()
else:
print(f"Le client {client_name} n'existe pas. Création d'un nouveau client...")
#On charge le module proposition pour créer un nouveau client
from modules.proposition.app import main as generate_proposal
generate_proposal()
data_manager = Data(client_data_path)
client_data = data_manager.load_data()
print(f"Le client {client_name} a été créé avec succès.")
# On génère le devis via les données du client en servant du template "devis" du dossier Templates
if client_data:
print("\n=== Création du devis ===")
# Charger le modèle de devis
from core.generator import Generator
template_path = "Templates/devis.json"
# Préparer les champs pour le formulaire
fields = [
{"name": "numero", "label": "Numéro du devis", "type": "text"},
]
# Collecter les données via le formulaire
form = Form(fields)
form.ask()
form_data = form.get_data()
# Préparer les données du client pour le modèle
client_data_formatted = {
"client_name": client_data.get("client_name", ""),
"client_email": client_data.get("email", ""),
"client_phone": client_data.get("telephone", ""),
"client_adress": client_data.get("adresse", "")
}
# Récupérer les fonctionnalités du client
features = client_data.get("features", [])
if not isinstance(features, list):
print("Erreur : Les fonctionnalités du client ne sont pas correctement formatées. Elles seront ignorées.")
features = []
# Ajouter les services avec tarifs pour chaque fonctionnalité
services = []
for feature in features:
if isinstance(feature, dict): # Vérifier que 'feature' est un dictionnaire
description = feature.get("description", "")
prix_unitaire = float(input(f"Entrez le tarif pour la fonctionnalité '{description}' : "))
services.append({"description": description, "prix_unitaire": prix_unitaire})
else:
print(f"Erreur : La fonctionnalité '{feature}' n'est pas valide et sera ignorée.")
# Ajouter les données spécifiques au devis
devis_content = {
"numero": form_data["numero"],
"date": "11/05/2025",
"client": client_data_formatted, # Transmettre les données formatées du client
"services": services,
"total": sum(service["prix_unitaire"] for service in services)
}
# Générer le devis
output_path = f"devis"
try:
generator = Generator(devis_content, template_path)
generator.generate_facture(output_path)
print("\n✅ Devis généré avec succès.")
print(f"Le devis a été enregistré dans le dossier '{output_path}'.")
except Exception as e:
print(f"\n❌ Une erreur est survenue lors de la génération du devis : {e}")
else:
print("\n❌ Impossible de générer le devis car les données du client n'ont pas été chargées.")

0
modules/devis/fields.py Normal file
View file

70
modules/email/draft.py Normal file
View file

@ -0,0 +1,70 @@
from typing import Optional, Dict, Any
from datetime import datetime
import uuid
class EmailDraft:
"""
Représente un brouillon d'email généré automatiquement.
Stockage fichier: Data/email_drafts/<id>.json
"""
def __init__(
self,
prospect_id: str,
to_email: str,
subject: str,
content: str,
status: str = "draft", # draft | sent | failed
template_id: Optional[str] = None,
task_id: Optional[str] = None,
id: Optional[str] = None,
created_at: Optional[str] = None,
sent_at: Optional[str] = None,
error_message: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
):
self.id = id or f"ed_{uuid.uuid4().hex[:10]}"
self.prospect_id = prospect_id
self.to_email = to_email
self.subject = subject
self.content = content
self.status = status
self.template_id = template_id
self.task_id = task_id
self.created_at = created_at or datetime.utcnow().isoformat()
self.sent_at = sent_at
self.error_message = error_message
self.metadata = metadata or {}
def to_dict(self) -> Dict[str, Any]:
return {
"id": self.id,
"prospect_id": self.prospect_id,
"to_email": self.to_email,
"subject": self.subject,
"content": self.content,
"status": self.status,
"template_id": self.template_id,
"task_id": self.task_id,
"created_at": self.created_at,
"sent_at": self.sent_at,
"error_message": self.error_message,
"metadata": self.metadata,
}
@staticmethod
def from_dict(data: Dict[str, Any]) -> "EmailDraft":
return EmailDraft(
id=data.get("id"),
prospect_id=data.get("prospect_id", ""),
to_email=data.get("to_email", ""),
subject=data.get("subject", ""),
content=data.get("content", ""),
status=data.get("status", "draft"),
template_id=data.get("template_id"),
task_id=data.get("task_id"),
created_at=data.get("created_at"),
sent_at=data.get("sent_at"),
error_message=data.get("error_message"),
metadata=data.get("metadata") or {},
)

View file

@ -0,0 +1,89 @@
import os
import json
from typing import List, Optional, Dict, Any
from datetime import datetime
from modules.email.draft import EmailDraft
class DraftHandler:
"""
Gestionnaire de brouillons (fichiers JSON).
Répertoire: Data/email_drafts
"""
def __init__(self, base_dir: Optional[str] = None):
base_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
self.base_dir = base_dir or os.path.join(base_root, "Data", "email_drafts")
os.makedirs(self.base_dir, exist_ok=True)
def _draft_path(self, draft_id: str) -> str:
return os.path.join(self.base_dir, f"{draft_id}.json")
def add_draft(self, draft: EmailDraft) -> str:
path = self._draft_path(draft.id)
with open(path, "w", encoding="utf-8") as f:
json.dump(draft.to_dict(), f, ensure_ascii=False, indent=2)
return draft.id
def get_draft(self, draft_id: str) -> Optional[EmailDraft]:
path = self._draft_path(draft_id)
if not os.path.exists(path):
return None
with open(path, "r", encoding="utf-8") as f:
return EmailDraft.from_dict(json.load(f))
def update_draft(self, draft: EmailDraft) -> bool:
path = self._draft_path(draft.id)
if not os.path.exists(path):
return False
with open(path, "w", encoding="utf-8") as f:
json.dump(draft.to_dict(), f, ensure_ascii=False, indent=2)
return True
def delete_draft(self, draft_id: str) -> bool:
path = self._draft_path(draft_id)
if os.path.exists(path):
try:
os.remove(path)
return True
except Exception:
return False
return False
def list_drafts(self, status: Optional[str] = None) -> List[EmailDraft]:
drafts: List[EmailDraft] = []
for filename in os.listdir(self.base_dir):
if filename.endswith(".json"):
try:
with open(os.path.join(self.base_dir, filename), "r", encoding="utf-8") as f:
data = json.load(f)
d = EmailDraft.from_dict(data)
if status is None or d.status == status:
drafts.append(d)
except Exception:
continue
# Tri: plus récents d'abord
drafts.sort(key=lambda d: d.created_at or "", reverse=True)
return drafts
def list_pending(self) -> List[EmailDraft]:
return self.list_drafts(status="draft")
def mark_sent(self, draft_id: str, success: bool, error_message: Optional[str] = None) -> bool:
d = self.get_draft(draft_id)
if not d:
return False
d.status = "sent" if success else "failed"
d.sent_at = datetime.utcnow().isoformat()
d.error_message = None if success else (error_message or "Unknown error")
return self.update_draft(d)
def find_existing_for_task(self, task_id: str) -> Optional[EmailDraft]:
"""
Évite les doublons: si un draft 'draft' existe déjà pour cette tâche, le renvoie.
Les brouillons 'failed' ne bloquent pas la régénération.
"""
for d in self.list_drafts():
if d.task_id == task_id and d.status == "draft":
return d
return None

View file

@ -0,0 +1,87 @@
from flask import Blueprint, request, redirect, url_for, flash, Response
from typing import List
from html import escape
from modules.email.draft_handler import DraftHandler
from modules.email.email_manager import EmailSender
email_drafts_bp = Blueprint("email_drafts", __name__, url_prefix="/email/drafts")
@email_drafts_bp.get("/")
def list_drafts_page():
"""
Page HTML minimaliste listant les brouillons avec bouton [Envoyer].
Pas de template Jinja requis (HTML inline pour simplicité d'intégration).
"""
handler = DraftHandler()
drafts = handler.list_pending()
def row_html(d):
# Contenu HTML tel quel (on suppose content déjà HTML sûr)
return f"""
<div style="border:1px solid #ddd; padding:12px; margin-bottom:12px; border-radius:8px;">
<div style="display:flex; justify-content:space-between; align-items:center;">
<h3 style="margin:0; font-size:1.05rem;">{escape(d.subject)}</h3>
<form method="post" action="{url_for('email_drafts.send_draft')}">
<input type="hidden" name="draft_id" value="{escape(d.id)}" />
<button type="submit" style="padding:6px 12px;">Envoyer</button>
</form>
</div>
<div style="color:#555; margin:6px 0 8px 0;">À: {escape(d.to_email)}</div>
<div style="background:#fafafa; padding:10px; border-radius:6px;">{d.content}</div>
<div style="font-size:12px; color:#777; margin-top:6px;">
Prospect: {escape(d.prospect_id)} | Template: {escape(d.template_id or '-') }
</div>
</div>
"""
items_html = "\n".join(row_html(d) for d in drafts) or "<p>Aucun brouillon à envoyer.</p>"
page = f"""
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<title>Brouillons d'emails</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
</head>
<body style="max-width:920px; margin: 20px auto; font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;">
<h2>Brouillons d'emails à envoyer</h2>
<div>{items_html}</div>
</body>
</html>
"""
return Response(page, mimetype="text/html")
@email_drafts_bp.post("/send")
def send_draft():
"""
Envoi d'un brouillon sélectionné, puis mise à jour du statut.
"""
draft_id = (request.form.get("draft_id") or "").strip()
if not draft_id:
flash("Brouillon invalide", "warning")
return redirect(url_for("email_drafts.list_drafts_page"))
handler = DraftHandler()
draft = handler.get_draft(draft_id)
if not draft:
flash("Brouillon introuvable", "danger")
return redirect(url_for("email_drafts.list_drafts_page"))
sender = EmailSender()
try:
res = sender.send_email(draft.to_email, draft.subject, draft.content)
if res.get("success"):
handler.mark_sent(draft.id, success=True)
flash("Email envoyé.", "success")
else:
handler.mark_sent(draft.id, success=False, error_message=res.get("error"))
flash("Échec de l'envoi de l'email.", "danger")
except Exception as e:
handler.mark_sent(draft.id, success=False, error_message=str(e))
flash("Erreur lors de l'envoi de l'email.", "danger")
return redirect(url_for("email_drafts.list_drafts_page"))

View file

@ -0,0 +1,353 @@
from typing import List, Dict, Any, Union
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from datetime import datetime
import os
import json
import re
from urllib.parse import quote_plus
from uuid import uuid4
from core.data import Data
class EmailTemplate:
"""Classe gérant les templates d'emails"""
def __init__(self, template_folder="Data/email_templates"):
self.template_folder = template_folder
# Créer le dossier de templates s'il n'existe pas
if not os.path.exists(self.template_folder):
os.makedirs(self.template_folder)
def get_all_templates(self):
"""Retourne tous les templates disponibles"""
templates = []
if os.path.exists(self.template_folder):
for filename in os.listdir(self.template_folder):
if filename.endswith('.json'):
template_path = os.path.join(self.template_folder, filename)
try:
data_manager = Data(template_path)
template_data = data_manager.load_data()
templates.append(template_data)
except Exception as e:
print(f"Erreur lors du chargement du template {filename}: {e}")
return templates
def get_template_by_id(self, template_id):
"""Récupère un template par son ID"""
template_path = os.path.join(self.template_folder, f"{template_id}.json")
if os.path.exists(template_path):
data_manager = Data(template_path)
return data_manager.load_data()
return None
def save_template(self, template_data):
"""Sauvegarde un template d'email"""
template_id = template_data.get('id')
if not template_id:
# Générer un ID s'il n'existe pas
import uuid
template_id = f"tpl_{uuid.uuid4().hex[:8]}"
template_data['id'] = template_id
template_path = os.path.join(self.template_folder, f"{template_id}.json")
data_manager = Data(template_path)
data_manager.save_data(template_data)
return template_data
def delete_template(self, template_id):
"""Supprime un template d'email"""
template_path = os.path.join(self.template_folder, f"{template_id}.json")
if os.path.exists(template_path):
os.remove(template_path)
return True
return False
def render_template(self, template_id, context=None):
"""Rend un template avec les variables spécifiées dans le contexte"""
template = self.get_template_by_id(template_id)
if not template:
return None
subject = template.get('subject', '')
content = template.get('content', '')
# Remplacer les variables dans le sujet et le contenu
if context:
for key, value in context.items():
placeholder = f"{{{{{key}}}}}"
subject = subject.replace(placeholder, str(value))
content = content.replace(placeholder, str(value))
return {
"subject": subject,
"content": content
}
class EmailSender:
"""Classe gérant l'envoi d'emails"""
def __init__(self, config_file="config/email_config.json"):
# Ensure config_file is an absolute path
if not os.path.isabs(config_file):
base_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
self.config_file = os.path.join(base_dir, config_file)
else:
self.config_file = config_file
self.config = self._load_config()
self.template_manager = EmailTemplate()
def _load_config(self):
"""Charge la configuration email depuis le fichier de configuration"""
config_dir = os.path.dirname(self.config_file)
if not os.path.exists(config_dir):
os.makedirs(config_dir)
print(f"Loading email config from: {self.config_file}")
if os.path.exists(self.config_file):
try:
with open(self.config_file, 'r') as f:
config = json.load(f)
print(f"Loaded email config: {config}")
return config
except Exception as e:
print(f"Erreur lors du chargement de la configuration email: {e}")
# Configuration par défaut
default_config = {
"smtp_server": "smtp.gmail.com",
"smtp_port": 587,
"username": "",
"password": "",
"sender_name": "Suite Consultance",
"sender_email": ""
}
print(f"Using default email config: {default_config}")
return default_config
def save_config(self, config):
"""Sauvegarde la configuration email"""
config_dir = os.path.dirname(self.config_file)
if not os.path.exists(config_dir):
os.makedirs(config_dir)
with open(self.config_file, 'w') as f:
json.dump(config, f, indent=4)
self.config = config
return True
def send_email(self, to_email, subject, body, cc=None, bcc=None):
"""Envoie un email à un destinataire"""
if not self.config.get('username') or not self.config.get('password'):
raise ValueError("La configuration email n'est pas complète")
message = MIMEMultipart()
message["From"] = f"{self.config.get('sender_name')} <{self.config.get('sender_email')}>"
message["To"] = to_email
message["Subject"] = subject
if cc:
message["Cc"] = ", ".join(cc) if isinstance(cc, list) else cc
if bcc:
message["Bcc"] = ", ".join(bcc) if isinstance(bcc, list) else bcc
message.attach(MIMEText(body, "html"))
try:
server = smtplib.SMTP(self.config.get('smtp_server'), self.config.get('smtp_port'))
server.starttls()
server.login(self.config.get('username'), self.config.get('password'))
recipients = [to_email]
if cc:
recipients.extend(cc if isinstance(cc, list) else [cc])
if bcc:
recipients.extend(bcc if isinstance(bcc, list) else [bcc])
server.sendmail(self.config.get('sender_email'), recipients, message.as_string())
server.quit()
return {
"success": True,
"timestamp": datetime.now().isoformat(),
"to": to_email,
"subject": subject
}
except Exception as e:
return {
"success": False,
"error": str(e),
"timestamp": datetime.now().isoformat()
}
def send_templated_email(self, to_email, template_id, context=None, cc=None, bcc=None):
"""Envoie un email basé sur un template à un destinataire"""
rendered = self.template_manager.render_template(template_id, context)
if not rendered:
return {
"success": False,
"error": "Template not found",
"timestamp": datetime.now().isoformat()
}
return self.send_email(to_email, rendered['subject'], rendered['content'], cc, bcc)
def send_bulk_email(self, emails, subject, body, cc=None, bcc=None):
"""Envoie le même email à plusieurs destinataires"""
results = []
for email in emails:
result = self.send_email(email, subject, body, cc, bcc)
results.append({
"email": email,
**result
})
return results
def send_bulk_templated_email(self, recipients, template_id, cc=None, bcc=None):
"""
Envoie un email basé sur un template à plusieurs destinataires
recipients: liste de dictionnaires contenant l'email du destinataire et le contexte
[{
"email": "example@example.com",
"context": {"name": "John Doe", "company": "ACME Inc."}
}]
"""
results = []
for recipient in recipients:
email = recipient.get('email')
context = recipient.get('context', {})
result = self.send_templated_email(email, template_id, context, cc, bcc)
results.append({
"email": email,
**result
})
return results
# ---------- Tracking helpers ----------
def _embed_tracking(self, html_body: str, tracking_id: str, prospect_id: str) -> str:
"""
Ajoute un pixel d'ouverture et réécrit les liens pour le click tracking.
Utilise APP_BASE_URL si définie, sinon génère des liens relatifs.
"""
base = (os.environ.get("APP_BASE_URL") or "").rstrip("/")
prefix = f"{base}/tasks/t" # routes de tracking montées sur le blueprint 'tasks'
# Pixel d'ouverture (1x1 PNG)
pixel = f'<img src="{prefix}/o/{tracking_id}.png?pid={quote_plus(prospect_id)}" alt="" width="1" height="1" style="display:none;" />'
body = html_body or ""
# Injection du pixel avant la fermeture de body si possible
if "</body>" in body.lower():
# trouver la vraie balise en conservant la casse
idx = body.lower().rfind("</body>")
body = body[:idx] + pixel + body[idx:]
else:
body = body + pixel
# Réécriture des liens <a href="...">
def _rewrite(match):
url = match.group(1)
# ignore si déjà tracké
if "/tasks/t/c/" in url:
return f'href="{url}"'
tracked = f'{prefix}/c/{tracking_id}?u={quote_plus(url)}'
return f'href="{tracked}"'
body = re.sub(r'href="([^"]+)"', _rewrite, body)
return body
def send_tracked_email(self, to_email: str, subject: str, body: str, prospect_id: str, template_id: str = None, cc=None, bcc=None) -> Dict[str, Any]:
"""
Envoie un email avec tracking (open/click).
Crée un enregistrement de tracking et insère un pixel + réécriture des liens.
"""
tracking_id = f"trk_{uuid4().hex[:16]}"
# Créer l'enregistrement de tracking
try:
from modules.tracking.store import TrackingStore
store = TrackingStore()
store.create_record(tracking_id, {
"prospect_id": prospect_id,
"to": to_email,
"subject": subject,
"template_id": template_id,
"opens": 0,
"clicks": 0,
})
except Exception:
# même si le tracking store échoue, on tente d'envoyer l'email
pass
tracked_body = self._embed_tracking(body, tracking_id, prospect_id)
result = self.send_email(to_email, subject, tracked_body, cc, bcc)
result["tracking_id"] = tracking_id
return result
class EmailHistory:
"""Classe gérant l'historique des emails envoyés"""
def __init__(self, history_folder="Data/email_history"):
self.history_folder = history_folder
# Créer le dossier d'historique s'il n'existe pas
if not os.path.exists(self.history_folder):
os.makedirs(self.history_folder)
def add_email_record(self, prospect_id, email_data):
"""Ajoute un email à l'historique d'un prospect"""
history_file = os.path.join(self.history_folder, f"{prospect_id}.json")
# Charger l'historique existant
history = []
if os.path.exists(history_file):
try:
with open(history_file, 'r') as f:
history = json.load(f)
except:
history = []
# Ajouter le nouvel email à l'historique
history.append({
**email_data,
"timestamp": datetime.now().isoformat()
})
# Sauvegarder l'historique
with open(history_file, 'w') as f:
json.dump(history, f, indent=4)
return True
def get_prospect_email_history(self, prospect_id):
"""Récupère l'historique des emails pour un prospect"""
history_file = os.path.join(self.history_folder, f"{prospect_id}.json")
if os.path.exists(history_file):
try:
with open(history_file, 'r') as f:
return json.load(f)
except:
return []
return []
def get_all_email_history(self):
"""Récupère l'historique de tous les emails envoyés"""
all_history = {}
if os.path.exists(self.history_folder):
for filename in os.listdir(self.history_folder):
if filename.endswith('.json'):
prospect_id = filename.split('.')[0]
history_file = os.path.join(self.history_folder, filename)
try:
with open(history_file, 'r') as f:
all_history[prospect_id] = json.load(f)
except:
all_history[prospect_id] = []
return all_history

View file

@ -0,0 +1,968 @@
import requests
from bs4 import BeautifulSoup
import re
from urllib.parse import urljoin, urlparse
import time
from typing import List, Set, Dict
import json
import os
from datetime import datetime
class EmailScraper:
def __init__(self):
self.session = requests.Session()
self.session.headers.update({
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
})
self.email_pattern = re.compile(r'[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}')
self.phone_pattern = re.compile(r'(?:\+32|0)\s?[1-9](?:[\s\-\.\/]?\d){8}|\+32\s?[1-9](?:[\s\-\.\/]?\d){8}|(?:\+33|0)[1-9](?:[\s\-\.\/]?\d){8}')
self.visited_urls = set()
self.found_emails = set()
self.contact_info = {}
def scrape_page(self, url: str, max_pages: int = 10) -> Dict:
"""
Scrape une page avec pagination pour extraire les données d'entreprises
"""
results = {
'url': url,
'contacts': [], # Liste des contacts avec email, nom, téléphone, etc.
'pages_scraped': [],
'errors': [],
'start_time': datetime.now().isoformat(),
'end_time': None,
'domain_info': {}
}
try:
self._scrape_with_pagination(url, results, max_pages)
self._extract_domain_info(url, results)
except Exception as e:
results['errors'].append(f"Erreur générale: {str(e)}")
results['end_time'] = datetime.now().isoformat()
return results
def _scrape_with_pagination(self, base_url: str, results: Dict, max_pages: int):
"""
Scraper avec gestion de la pagination
"""
current_page = 1
current_url = base_url
while current_page <= max_pages:
if current_url in self.visited_urls:
break
try:
# Normaliser l'URL
parsed_url = urlparse(current_url)
if not parsed_url.scheme:
current_url = 'https://' + current_url
self.visited_urls.add(current_url)
print(f"Scraping page {current_page}: {current_url}")
# Faire la requête
response = self.session.get(current_url, timeout=15)
response.raise_for_status()
# Parser le HTML
soup = BeautifulSoup(response.content, 'html.parser')
# Extraire les entreprises/contacts de la page
page_contacts = self._extract_business_contacts(soup, response.text, current_url)
# Ajouter les contacts à la liste principale
for contact in page_contacts:
# Vérifier si ce contact existe déjà (par email)
existing_contact = next((c for c in results['contacts'] if c['email'] == contact['email']), None)
if existing_contact:
# Fusionner les informations si le contact existe
self._merge_contact_info(existing_contact, contact)
else:
results['contacts'].append(contact)
results['pages_scraped'].append({
'url': current_url,
'page_number': current_page,
'contacts_found': len(page_contacts),
'contacts': page_contacts,
'status': 'success',
'timestamp': datetime.now().isoformat()
})
print(f" - Page {current_page}: Trouvé {len(page_contacts)} contact(s)")
# Si aucun contact trouvé, peut-être qu'on a atteint la fin
if len(page_contacts) == 0:
print(f" - Aucun contact trouvé sur la page {current_page}, arrêt du scraping")
break
# Chercher le lien vers la page suivante
next_url = self._find_next_page_url(soup, current_url, current_page)
if not next_url:
print(f" - Pas de page suivante trouvée, arrêt du scraping")
break
current_url = next_url
current_page += 1
# Délai entre les pages pour éviter la surcharge
time.sleep(2)
except requests.exceptions.RequestException as e:
results['errors'].append(f"Erreur de requête pour la page {current_page} ({current_url}): {str(e)}")
results['pages_scraped'].append({
'url': current_url,
'page_number': current_page,
'contacts_found': 0,
'contacts': [],
'status': 'error',
'error': str(e),
'timestamp': datetime.now().isoformat()
})
break
except Exception as e:
results['errors'].append(f"Erreur lors du parsing de la page {current_page}: {str(e)}")
break
def _extract_business_contacts(self, soup: BeautifulSoup, text: str, page_url: str) -> List[Dict]:
"""
Extraire les informations d'entreprises d'une page (spécialisé pour les annuaires)
"""
contacts = []
# Chercher des conteneurs d'entreprises communs
business_containers = self._find_business_containers(soup)
if business_containers:
# Si on trouve des conteneurs structurés, les traiter
for container in business_containers:
contact = self._extract_contact_from_container(container, page_url)
if contact and contact.get('email'):
contacts.append(contact)
else:
# Fallback: extraction générale comme avant
contacts = self._extract_contact_info(soup, text, page_url)
return contacts
def _find_business_containers(self, soup: BeautifulSoup) -> List:
"""
Trouver les conteneurs qui contiennent probablement des informations d'entreprises
"""
containers = []
# Patterns communs pour les annuaires d'entreprises
business_selectors = [
# Classes/IDs communs
'[class*="business"]',
'[class*="company"]',
'[class*="enterprise"]',
'[class*="contact"]',
'[class*="listing"]',
'[class*="directory"]',
'[class*="card"]',
'[class*="item"]',
'[class*="entry"]',
'[class*="result"]',
# Balises sémantiques
'article',
'[itemtype*="Organization"]',
'[itemtype*="LocalBusiness"]',
# Structures de liste
'li[class*="business"]',
'li[class*="company"]',
'div[class*="row"]',
'div[class*="col"]'
]
for selector in business_selectors:
try:
elements = soup.select(selector)
for element in elements:
# Vérifier si l'élément contient des informations utiles
if self._container_has_business_info(element):
containers.append(element)
except:
continue
# Déduplication basée sur le contenu
unique_containers = []
for container in containers:
if not any(self._containers_are_similar(container, existing) for existing in unique_containers):
unique_containers.append(container)
return unique_containers[:50] # Limiter pour éviter la surcharge
def _container_has_business_info(self, container) -> bool:
"""
Vérifier si un conteneur a des informations d'entreprise
"""
text = container.get_text(strip=True).lower()
# Indicateurs d'informations d'entreprise
business_indicators = [
'@', 'email', 'mail', 'contact',
'tel', 'phone', 'telephone', 'gsm',
'rue', 'avenue', 'boulevard', 'place',
'www.', 'http', '.com', '.be', '.fr',
'sarl', 'sprl', 'sa', 'nv', 'bvba'
]
score = sum(1 for indicator in business_indicators if indicator in text)
return score >= 2 and len(text) > 20
def _containers_are_similar(self, container1, container2) -> bool:
"""
Vérifier si deux conteneurs sont similaires (pour éviter les doublons)
"""
text1 = container1.get_text(strip=True)
text2 = container2.get_text(strip=True)
# Si les textes sont identiques ou très similaires
if text1 == text2:
return True
# Si un conteneur est inclus dans l'autre
if len(text1) > len(text2):
return text2 in text1
else:
return text1 in text2
def _extract_contact_from_container(self, container, page_url: str) -> Dict:
"""
Extraire les informations de contact d'un conteneur spécifique
"""
contact = {
'email': '',
'name': '',
'first_name': '',
'last_name': '',
'company': '',
'phone': '',
'location': '',
'source_url': page_url,
'notes': ''
}
# Extraire l'email depuis les balises individuelles d'abord
email_found = False
# Chercher dans les liens mailto
mailto_links = container.find_all('a', href=re.compile(r'^mailto:', re.I))
if mailto_links:
href = mailto_links[0].get('href', '')
email_match = re.search(r'mailto:([^?&]+)', href, re.I)
if email_match and self._is_valid_email(email_match.group(1)):
contact['email'] = email_match.group(1).lower()
email_found = True
# Si pas trouvé dans mailto, chercher dans les balises individuelles
if not email_found:
for element in container.find_all(['p', 'div', 'span', 'td', 'li']):
element_text = element.get_text(strip=True)
# Ajouter des espaces autour des balises pour éviter la concaténation
element_text = ' ' + element_text + ' '
email_matches = self.email_pattern.findall(element_text)
if email_matches:
for email in email_matches:
email = email.strip()
if re.match(r'^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$', email) and self._is_valid_email(email):
contact['email'] = email.lower()
email_found = True
break
if email_found:
break
# Si toujours pas trouvé, chercher dans le texte global avec des patterns plus précis
if not email_found:
container_text = container.get_text(separator=' ', strip=True) # Utiliser un séparateur
# Patterns avec contexte pour éviter la capture parasite
context_patterns = [
r'(?:email|e-mail|mail|contact)\s*:?\s*([A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,})',
r'([A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,})(?=\s|$|[^\w.-])',
]
for pattern in context_patterns:
matches = re.findall(pattern, container_text, re.IGNORECASE)
if matches:
email = matches[0] if isinstance(matches[0], str) else matches[0][0] if matches[0] else ''
if email and self._is_valid_email(email):
contact['email'] = email.lower()
email_found = True
break
# Extraire le téléphone
container_text = container.get_text(separator=' ', strip=True)
phone_matches = self.phone_pattern.findall(container_text)
if phone_matches:
# Prendre le premier numéro et le nettoyer
phone = phone_matches[0]
# S'assurer qu'on n'a que des chiffres, espaces, tirets, points, slash et +
clean_phone = re.sub(r'[^0-9\s\-\.\/\+].*$', '', phone)
contact['phone'] = clean_phone.strip()
# Extraire le nom de l'entreprise
contact['company'] = self._extract_company_name(container, container_text)
# Extraire les noms de personnes
names = self._extract_person_names(container, container_text)
if names:
contact.update(names)
# Extraire la localisation
contact['location'] = self._extract_location_from_container(container, container_text)
# Enrichir avec des informations contextuelles
self._enhance_business_contact(contact, container, container_text)
return contact if contact['email'] or contact['company'] else None
def _extract_company_name(self, container, text: str) -> str:
"""
Extraire le nom de l'entreprise d'un conteneur
"""
# Chercher dans les balises title, h1-h6, strong, b
title_elements = container.find_all(['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'strong', 'b', '[class*="title"]', '[class*="name"]', '[class*="company"]'])
for element in title_elements:
company_text = element.get_text(strip=True)
if len(company_text) > 2 and len(company_text) < 100:
# Éviter les textes trop génériques
if not any(generic in company_text.lower() for generic in ['accueil', 'contact', 'email', 'téléphone', 'adresse']):
return company_text
# Fallback: prendre la première ligne non-vide qui semble être un nom
lines = text.split('\n')
for line in lines[:3]: # Les 3 premières lignes
line = line.strip()
if len(line) > 2 and len(line) < 100 and not '@' in line and not any(char.isdigit() for char in line[:3]):
return line
return ''
def _extract_person_names(self, container, text: str) -> Dict:
"""
Extraire les noms de personnes
"""
names = {'name': '', 'first_name': '', 'last_name': ''}
# Patterns pour les noms de personnes
name_patterns = [
r'\b([A-Z][a-zÀ-ÿ]+)\s+([A-Z][a-zÀ-ÿ]+)\b', # Prénom Nom
r'\b([A-Z][A-Z]+)\s+([A-Z][a-zÀ-ÿ]+)\b', # NOM Prénom
]
# Chercher dans les balises spécifiques
name_elements = container.find_all(['[class*="name"]', '[class*="contact"]', '[class*="person"]'])
for element in name_elements:
element_text = element.get_text(strip=True)
for pattern in name_patterns:
match = re.search(pattern, element_text)
if match:
names['first_name'] = match.group(1)
names['last_name'] = match.group(2)
names['name'] = f"{names['first_name']} {names['last_name']}"
return names
# Si pas trouvé dans les balises, chercher dans le texte
for pattern in name_patterns:
match = re.search(pattern, text)
if match:
names['first_name'] = match.group(1)
names['last_name'] = match.group(2)
names['name'] = f"{names['first_name']} {names['last_name']}"
break
return names
def _extract_location_from_container(self, container, text: str) -> str:
"""
Extraire la localisation d'un conteneur
"""
# Chercher dans les balises d'adresse
address_elements = container.find_all(['address', '[class*="address"]', '[class*="location"]', '[class*="ville"]', '[class*="city"]'])
for element in address_elements:
location_text = element.get_text(strip=True)
if len(location_text) > 5:
return location_text
# Patterns pour les adresses belges/françaises
location_patterns = [
r'\b\d{4,5}\s+[A-Za-zÀ-ÿ\s\-]+\b', # Code postal + ville
r'\b[A-Za-zÀ-ÿ\s\-]+,\s*[A-Za-zÀ-ÿ\s\-]+\b', # Ville, Région/Pays
r'\b(?:rue|avenue|boulevard|place|chemin)\s+[A-Za-zÀ-ÿ\s\d\-,]+\b' # Adresse complète
]
for pattern in location_patterns:
match = re.search(pattern, text, re.IGNORECASE)
if match:
return match.group(0).strip()
return ''
def _enhance_business_contact(self, contact: Dict, container, text: str):
"""
Améliorer les informations de contact d'entreprise
"""
# Si pas de nom trouvé, essayer d'extraire depuis l'email
if not contact['name'] and contact['email']:
local_part = contact['email'].split('@')[0]
domain_part = contact['email'].split('@')[1]
if '.' in local_part:
parts = local_part.split('.')
contact['first_name'] = parts[0].title()
contact['last_name'] = parts[1].title() if len(parts) > 1 else ''
contact['name'] = f"{contact['first_name']} {contact['last_name']}".strip()
# Si pas d'entreprise, essayer de deviner depuis le domaine
if not contact['company']:
company_name = domain_part.split('.')[0]
contact['company'] = company_name.title()
# Enrichir les notes avec des informations contextuelles
notes_parts = []
# Chercher des informations sur l'activité
activity_patterns = [
r'(?i)\b(restaurant|café|boulangerie|pharmacie|garage|coiffeur|médecin|avocat|comptable|architecte|dentiste|vétérinaire|magasin|boutique|salon)\b',
r'(?i)\b(commerce|service|entreprise|société|bureau|cabinet|clinique|centre|institut)\b'
]
for pattern in activity_patterns:
matches = re.findall(pattern, text)
if matches:
notes_parts.append(f"Activité: {', '.join(set(matches))}")
break
# Chercher des horaires
horaires_pattern = r'(?i)(?:ouvert|fermé|horaires?)[:\s]*([^.!?\n]{10,50})'
horaires_match = re.search(horaires_pattern, text)
if horaires_match:
notes_parts.append(f"Horaires: {horaires_match.group(1).strip()}")
# Chercher un site web
website_pattern = r'\b(?:www\.)?[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]*\.(?:com|be|fr|org|net)\b'
website_match = re.search(website_pattern, text)
if website_match:
notes_parts.append(f"Site web: {website_match.group(0)}")
contact['notes'] = ' | '.join(notes_parts)
def _find_next_page_url(self, soup: BeautifulSoup, current_url: str, current_page: int) -> str:
"""
Trouver l'URL de la page suivante
"""
base_url = '/'.join(current_url.split('/')[:-1]) if '/' in current_url else current_url
# Patterns communs pour les liens de pagination
next_patterns = [
# Liens avec texte
'a[href]:contains("Suivant")',
'a[href]:contains("Next")',
'a[href]:contains(">")',
'a[href]:contains("Page suivante")',
# Liens avec classes
'a[class*="next"]',
'a[class*="suivant"]',
'a[class*="pagination"]',
# Numéros de page
f'a[href]:contains("{current_page + 1}")',
]
for pattern in next_patterns:
try:
links = soup.select(pattern)
for link in links:
href = link.get('href')
if href:
# Construire l'URL complète
if href.startswith('http'):
return href
elif href.startswith('/'):
parsed = urlparse(current_url)
return f"{parsed.scheme}://{parsed.netloc}{href}"
else:
return urljoin(current_url, href)
except:
continue
# Essayer de construire l'URL de la page suivante par pattern
# Pattern 1: ?page=X
if 'page=' in current_url:
return re.sub(r'page=\d+', f'page={current_page + 1}', current_url)
# Pattern 2: /pageX
if f'/page{current_page}' in current_url:
return current_url.replace(f'/page{current_page}', f'/page{current_page + 1}')
# Pattern 3: Ajouter ?page=2 si c'est la première page
if current_page == 1:
separator = '&' if '?' in current_url else '?'
return f"{current_url}{separator}page={current_page + 1}"
return None
def _extract_contact_info(self, soup: BeautifulSoup, text: str, page_url: str) -> List[Dict]:
"""
Extraire les informations de contact complètes d'une page
"""
contacts = []
# Extraire tous les emails
emails = set()
emails.update(self._extract_emails_from_text(text))
emails.update(self._extract_emails_from_links(soup))
# Extraire les numéros de téléphone
phones = self._extract_phone_numbers(text)
# Extraire les noms et entreprises depuis les balises structurées
structured_contacts = self._extract_structured_contacts(soup)
# Extraire l'adresse/localité
location = self._extract_location_info(soup, text)
# Créer des contacts pour chaque email trouvé
for email in emails:
if not self._is_valid_email(email):
continue
contact = {
'email': email.lower(),
'name': '',
'first_name': '',
'last_name': '',
'company': '',
'phone': '',
'location': location,
'source_url': page_url,
'notes': ''
}
# Essayer de trouver des informations complémentaires
self._enhance_contact_info(contact, soup, text, structured_contacts, phones)
contacts.append(contact)
return contacts
def _extract_phone_numbers(self, text: str) -> List[str]:
"""
Extraire les numéros de téléphone
"""
phones = []
matches = self.phone_pattern.findall(text)
for phone in matches:
# Nettoyer le numéro
clean_phone = re.sub(r'[\s\-\.\/]', '', phone)
if len(clean_phone) >= 9: # Numéro valide
phones.append(phone)
return phones
def _extract_structured_contacts(self, soup: BeautifulSoup) -> List[Dict]:
"""
Extraire les contacts depuis les données structurées (microdata, JSON-LD, etc.)
"""
contacts = []
# Chercher les données JSON-LD
json_scripts = soup.find_all('script', type='application/ld+json')
for script in json_scripts:
try:
data = json.loads(script.string)
if isinstance(data, dict):
contact = self._parse_json_ld_contact(data)
if contact:
contacts.append(contact)
elif isinstance(data, list):
for item in data:
contact = self._parse_json_ld_contact(item)
if contact:
contacts.append(contact)
except:
continue
# Chercher les microdata
contacts.extend(self._extract_microdata_contacts(soup))
return contacts
def _parse_json_ld_contact(self, data: Dict) -> Dict:
"""
Parser un contact depuis les données JSON-LD
"""
contact = {}
if data.get('@type') in ['Organization', 'LocalBusiness', 'Person']:
contact['name'] = data.get('name', '')
contact['company'] = data.get('name', '') if data.get('@type') != 'Person' else ''
# Email
email = data.get('email')
if email:
contact['email'] = email
# Téléphone
phone = data.get('telephone')
if phone:
contact['phone'] = phone
# Adresse
address = data.get('address')
if address:
if isinstance(address, dict):
location_parts = []
if address.get('addressLocality'):
location_parts.append(address['addressLocality'])
if address.get('addressRegion'):
location_parts.append(address['addressRegion'])
if address.get('addressCountry'):
location_parts.append(address['addressCountry'])
contact['location'] = ', '.join(location_parts)
elif isinstance(address, str):
contact['location'] = address
return contact if contact.get('email') or contact.get('name') else None
def _extract_microdata_contacts(self, soup: BeautifulSoup) -> List[Dict]:
"""
Extraire les contacts depuis les microdata
"""
contacts = []
# Chercher les éléments avec itemtype Person ou Organization
items = soup.find_all(attrs={'itemtype': re.compile(r'.*(Person|Organization|LocalBusiness).*')})
for item in items:
contact = {}
# Nom
name_elem = item.find(attrs={'itemprop': 'name'})
if name_elem:
contact['name'] = name_elem.get_text(strip=True)
# Email
email_elem = item.find(attrs={'itemprop': 'email'})
if email_elem:
contact['email'] = email_elem.get('href', '').replace('mailto:', '') or email_elem.get_text(strip=True)
# Téléphone
phone_elem = item.find(attrs={'itemprop': 'telephone'})
if phone_elem:
contact['phone'] = phone_elem.get_text(strip=True)
if contact.get('email') or contact.get('name'):
contacts.append(contact)
return contacts
def _extract_location_info(self, soup: BeautifulSoup, text: str) -> str:
"""
Extraire les informations de localisation
"""
location_indicators = [
r'\b\d{4,5}\s+[A-Za-zÀ-ÿ\s\-]+\b', # Code postal + ville
r'\b[A-Za-zÀ-ÿ\s\-]+,\s*[A-Za-zÀ-ÿ\s\-]+\b', # Ville, Pays
]
# Chercher dans les balises d'adresse
address_tags = soup.find_all(['address', 'div'], class_=re.compile(r'.*address.*|.*location.*|.*contact.*'))
for tag in address_tags:
address_text = tag.get_text(strip=True)
for pattern in location_indicators:
match = re.search(pattern, address_text, re.IGNORECASE)
if match:
return match.group(0)
# Chercher dans le texte global
for pattern in location_indicators:
match = re.search(pattern, text, re.IGNORECASE)
if match:
return match.group(0)
return ''
def _enhance_contact_info(self, contact: Dict, soup: BeautifulSoup, text: str, structured_contacts: List[Dict], phones: List[str]):
"""
Améliorer les informations de contact en croisant les données
"""
email = contact['email']
# Chercher dans les contacts structurés
for struct_contact in structured_contacts:
if struct_contact.get('email') == email:
contact.update(struct_contact)
break
# Si pas de nom trouvé, essayer d'extraire depuis l'email
if not contact['name']:
local_part = email.split('@')[0]
domain_part = email.split('@')[1]
# Essayer de deviner le nom depuis la partie locale
if '.' in local_part:
parts = local_part.split('.')
contact['first_name'] = parts[0].title()
contact['last_name'] = parts[1].title() if len(parts) > 1 else ''
contact['name'] = f"{contact['first_name']} {contact['last_name']}".strip()
else:
contact['name'] = local_part.title()
# Essayer de deviner l'entreprise depuis le domaine
if not contact['company']:
company_name = domain_part.split('.')[0]
contact['company'] = company_name.title()
# Ajouter un numéro de téléphone si disponible
if not contact['phone'] and phones:
contact['phone'] = phones[0] # Prendre le premier numéro trouvé
# Enrichir les notes avec des informations contextuelles
notes_parts = []
if contact['location']:
notes_parts.append(f"Localisation: {contact['location']}")
# Chercher des informations sur la fonction/titre
title_patterns = [
r'(?i)(?:directeur|manager|responsable|chef|président|ceo|cto|cfo)\s+[a-zA-ZÀ-ÿ\s]+',
r'(?i)[a-zA-ZÀ-ÿ\s]+\s+(?:director|manager|head|chief|president)'
]
for pattern in title_patterns:
matches = re.findall(pattern, text)
if matches:
notes_parts.append(f"Fonction possible: {matches[0]}")
break
contact['notes'] = ' | '.join(notes_parts)
def _merge_contact_info(self, existing: Dict, new: Dict):
"""
Fusionner les informations de deux contacts
"""
for key, value in new.items():
if value and not existing.get(key):
existing[key] = value
# Fusionner les notes
if new.get('notes') and existing.get('notes'):
existing['notes'] = f"{existing['notes']} | {new['notes']}"
elif new.get('notes'):
existing['notes'] = new['notes']
def _extract_domain_info(self, url: str, results: Dict):
"""
Extraire les informations générales du domaine
"""
domain = urlparse(url).netloc
results['domain_info'] = {
'domain': domain,
'company_guess': domain.split('.')[0].title(),
'total_contacts': len(results['contacts']),
'total_pages_scraped': len(results['pages_scraped'])
}
def _extract_emails_from_links(self, soup: BeautifulSoup) -> Set[str]:
"""
Extraire les emails des liens mailto
"""
emails = set()
# Chercher les liens mailto
mailto_links = soup.find_all('a', href=re.compile(r'^mailto:', re.I))
for link in mailto_links:
href = link.get('href', '')
email_match = re.search(r'mailto:([^?&]+)', href, re.I)
if email_match:
email = email_match.group(1)
if self._is_valid_email(email):
emails.add(email.lower())
return emails
def _extract_emails_from_text(self, text: str) -> Set[str]:
"""
Extraire les emails du texte de la page
"""
emails = set()
matches = self.email_pattern.findall(text)
for email in matches:
# Filtrer les emails indésirables
if not self._is_valid_email(email):
continue
emails.add(email.lower())
return emails
def _extract_internal_links(self, soup: BeautifulSoup, base_url: str) -> List[str]:
"""
Extraire les liens internes de la page
"""
links = []
base_domain = urlparse(base_url).netloc
for link in soup.find_all('a', href=True):
href = link['href']
full_url = urljoin(base_url, href)
parsed_link = urlparse(full_url)
# Vérifier que c'est un lien interne et pas déjà visité
if (parsed_link.netloc == base_domain and
full_url not in self.visited_urls and
not self._is_excluded_link(full_url)):
links.append(full_url)
return links
def _is_valid_email(self, email: str) -> bool:
"""
Vérifier si l'email est valide et non indésirable
"""
# Filtrer les extensions de fichiers communes
excluded_extensions = ['.jpg', '.png', '.gif', '.pdf', '.doc', '.css', '.js']
for ext in excluded_extensions:
if email.lower().endswith(ext):
return False
# Filtrer les emails génériques indésirables
excluded_patterns = [
'example.com',
'test.com',
'placeholder',
'your-email',
'youremail',
'email@',
'noreply',
'no-reply'
]
for pattern in excluded_patterns:
if pattern in email.lower():
return False
# Vérifier la longueur
if len(email) < 5 or len(email) > 254:
return False
return True
def _is_excluded_link(self, url: str) -> bool:
"""
Vérifier si le lien doit être exclu du scraping
"""
excluded_patterns = [
'#',
'javascript:',
'tel:',
'mailto:',
'.pdf',
'.doc',
'.zip',
'.jpg',
'.png',
'.gif'
]
url_lower = url.lower()
for pattern in excluded_patterns:
if pattern in url_lower:
return True
return False
def save_results(self, results: Dict, filename: str = None) -> str:
"""
Sauvegarder les résultats dans un fichier JSON
"""
if not filename:
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
domain = urlparse(results['url']).netloc.replace('.', '_')
filename = f"scraping_{domain}_{timestamp}.json"
# Créer le dossier s'il n'existe pas
scraping_folder = 'Data/email_scraping'
os.makedirs(scraping_folder, exist_ok=True)
filepath = os.path.join(scraping_folder, filename)
with open(filepath, 'w', encoding='utf-8') as f:
json.dump(results, f, ensure_ascii=False, indent=2)
return filepath
class EmailScrapingHistory:
def __init__(self):
self.history_folder = 'Data/email_scraping'
os.makedirs(self.history_folder, exist_ok=True)
def get_all_scrapings(self) -> List[Dict]:
"""
Récupérer l'historique de tous les scrapings
"""
scrapings = []
for filename in os.listdir(self.history_folder):
if filename.endswith('.json'):
filepath = os.path.join(self.history_folder, filename)
try:
with open(filepath, 'r', encoding='utf-8') as f:
data = json.load(f)
scrapings.append({
'filename': filename,
'url': data.get('url', ''),
'emails_count': len(data.get('contacts', data.get('emails', []))), # Support pour ancienne et nouvelle structure
'pages_count': len(data.get('pages_scraped', [])),
'start_time': data.get('start_time', ''),
'errors_count': len(data.get('errors', []))
})
except Exception as e:
print(f"Erreur lors de la lecture de {filename}: {e}")
# Trier par date (plus récent d'abord)
scrapings.sort(key=lambda x: x.get('start_time', ''), reverse=True)
return scrapings
def get_scraping_details(self, filename: str) -> Dict:
"""
Récupérer les détails d'un scraping spécifique
"""
filepath = os.path.join(self.history_folder, filename)
if os.path.exists(filepath):
with open(filepath, 'r', encoding='utf-8') as f:
return json.load(f)
return None
def delete_scraping(self, filename: str) -> bool:
"""
Supprimer un fichier de scraping
"""
filepath = os.path.join(self.history_folder, filename)
if os.path.exists(filepath):
try:
os.remove(filepath)
return True
except Exception as e:
print(f"Erreur lors de la suppression: {e}")
return False
return False

View file

@ -0,0 +1,67 @@
import re
import uuid
from datetime import datetime
from typing import Optional, Dict, Any
def _slugify(value: str) -> str:
value = value.strip().lower()
value = re.sub(r'[^a-z0-9\-_\s]+', '', value)
value = re.sub(r'[\s]+', '-', value)
return value
class Project:
def __init__(
self,
client_id: str,
name: str,
status: str = 'Nouveau',
start_date: Optional[str] = None,
end_date: Optional[str] = None,
description: str = '',
budget: Optional[float] = None,
id: Optional[str] = None,
created_at: Optional[str] = None,
updated_at: Optional[str] = None
):
self.client_id = client_id
self.name = name
self.status = status
self.start_date = start_date
self.end_date = end_date
self.description = description
self.budget = budget
self.id = id or f"{_slugify(name)}-{uuid.uuid4().hex[:8]}"
now_iso = datetime.utcnow().isoformat()
self.created_at = created_at or now_iso
self.updated_at = updated_at or now_iso
def to_dict(self) -> Dict[str, Any]:
return {
"id": self.id,
"client_id": self.client_id,
"name": self.name,
"status": self.status,
"start_date": self.start_date,
"end_date": self.end_date,
"description": self.description,
"budget": self.budget,
"created_at": self.created_at,
"updated_at": self.updated_at,
}
@staticmethod
def from_dict(data: Dict[str, Any]) -> 'Project':
return Project(
client_id=data.get("client_id"),
name=data.get("name", ""),
status=data.get("status", "Nouveau"),
start_date=data.get("start_date"),
end_date=data.get("end_date"),
description=data.get("description", ""),
budget=data.get("budget"),
id=data.get("id"),
created_at=data.get("created_at"),
updated_at=data.get("updated_at"),
)

View file

@ -0,0 +1,98 @@
import json
import os
from typing import List, Optional
from modules.projects.project import Project
class ProjectHandler:
def __init__(self, base_dir: str = None):
# Par défaut, stocker sous Data/projects
self.base_dir = base_dir or os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), 'Data', 'projects')
os.makedirs(self.base_dir, exist_ok=True)
def _client_dir(self, client_id: str) -> str:
path = os.path.join(self.base_dir, client_id)
os.makedirs(path, exist_ok=True)
return path
def _project_path(self, client_id: str, project_id: str) -> str:
return os.path.join(self._client_dir(client_id), f"{project_id}.json")
def list_projects(self, client_id: str) -> List[Project]:
projects: List[Project] = []
directory = self._client_dir(client_id)
if not os.path.isdir(directory):
return projects
for filename in os.listdir(directory):
if filename.endswith('.json'):
try:
with open(os.path.join(directory, filename), 'r', encoding='utf-8') as f:
data = json.load(f)
projects.append(Project.from_dict(data))
except Exception:
# Ignorer fichiers corrompus
continue
# Tri par date de mise à jour décroissante
projects.sort(key=lambda p: p.updated_at or "", reverse=True)
return projects
def get_project(self, client_id: str, project_id: str) -> Optional[Project]:
path = self._project_path(client_id, project_id)
if not os.path.exists(path):
return None
with open(path, 'r', encoding='utf-8') as f:
data = json.load(f)
return Project.from_dict(data)
def add_project(self, project: Project) -> str:
path = self._project_path(project.client_id, project.id)
with open(path, 'w', encoding='utf-8') as f:
json.dump(project.to_dict(), f, ensure_ascii=False, indent=2)
return project.id
def update_project(self, project: Project) -> bool:
path = self._project_path(project.client_id, project.id)
if not os.path.exists(path):
return False
# mettre à jour updated_at
from datetime import datetime
project.updated_at = datetime.utcnow().isoformat()
with open(path, 'w', encoding='utf-8') as f:
json.dump(project.to_dict(), f, ensure_ascii=False, indent=2)
return True
def delete_project(self, client_id: str, project_id: str) -> bool:
path = self._project_path(client_id, project_id)
if os.path.exists(path):
try:
os.remove(path)
return True
except Exception:
return False
return False
def add_task_for_project(self, project_id: str, title: str, due_date: str, description: str = "", priority: str = "normale", metadata: dict = None):
"""
Crée une tâche liée à un projet.
:param project_id: ID du projet
:param title: Titre de la tâche
:param due_date: Date d'échéance au format ISO (YYYY-MM-DD)
:param description: Description optionnelle
:param priority: 'basse' | 'normale' | 'haute'
:param metadata: métadonnées optionnelles
:return: ID de la tâche créée
"""
from modules.tasks.task import Task
from modules.tasks.task_handler import TaskHandler
task = Task(
title=title,
due_date=due_date,
description=description,
entity_type="project",
entity_id=project_id,
priority=priority,
metadata=metadata or {},
)
return TaskHandler().add_task(task)

111
modules/projects/routes.py Normal file
View file

@ -0,0 +1,111 @@
import os
import json
from typing import Dict, List
from flask import Blueprint, render_template, request
from modules.projects.project_handler import ProjectHandler
projects_bp = Blueprint('projects', __name__)
_handler = ProjectHandler()
def _project_root() -> str:
# 3 niveaux au dessus de modules/projects/ = racine projet
return os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
def _clients_dir() -> str:
return os.path.join(_project_root(), 'Data', 'clients')
def _load_clients_index() -> Dict[str, str]:
"""
Retourne un dict {client_id: client_name lisible}
client_id = nom de fichier sans extension dans Data/clients
"""
idx: Dict[str, str] = {}
cdir = _clients_dir()
if not os.path.isdir(cdir):
return idx
for fname in os.listdir(cdir):
if not fname.endswith('.json'):
continue
client_id = os.path.splitext(fname)[0]
path = os.path.join(cdir, fname)
try:
with open(path, 'r', encoding='utf-8') as f:
data = json.load(f)
name = data.get('client_name') or client_id.replace('_', ' ').title()
idx[client_id] = name
except Exception:
idx[client_id] = client_id.replace('_', ' ').title()
return idx
def _list_all_projects() -> List:
"""
Parcourt Data/projects/<client_id> et retourne la liste des objets Project.
"""
base = _handler.base_dir
projects = []
if os.path.isdir(base):
for client_id in os.listdir(base):
client_path = os.path.join(base, client_id)
if os.path.isdir(client_path):
projects.extend(_handler.list_projects(client_id))
return projects
@projects_bp.route('/projects', methods=['GET'])
def projects_index():
clients_idx = _load_clients_index()
selected_client = request.args.get('client_id', '').strip()
query = request.args.get('q', '').strip().lower()
# Charger tous les projets
projects = _list_all_projects()
# Filtre par client
if selected_client:
projects = [p for p in projects if p.client_id == selected_client]
# Recherche plein texte basique
if query:
def match(p) -> bool:
hay = ' '.join([
p.name or '',
p.status or '',
p.description or ''
]).lower()
return query in hay
projects = [p for p in projects if match(p)]
# Tri par updated_at décroissante
projects.sort(key=lambda p: p.updated_at or '', reverse=True)
# Transformer pour la vue (nom client)
def client_name_for(pid: str) -> str:
return clients_idx.get(pid, pid.replace('_', ' ').title())
view_projects = []
for p in projects:
view_projects.append({
'id': p.id,
'client_id': p.client_id,
'client_name': client_name_for(p.client_id),
'name': p.name,
'status': p.status,
'start_date': p.start_date,
'end_date': p.end_date,
'budget': p.budget,
'updated_at': p.updated_at
})
# Liste triée des clients pour le sélecteur
clients_for_select = sorted(
[{'id': cid, 'name': cname} for cid, cname in clients_idx.items()],
key=lambda c: c['name'].lower()
)
return render_template(
'projects/all_projects.html',
projects=view_projects,
clients=clients_for_select,
selected_client=selected_client,
q=request.args.get('q', '').strip()
)

View file

@ -0,0 +1,27 @@
# Importation des modules nécessaires
from modules.proposition.fields import fields
from core.form import Form
from core.generator import Generator
from core.data import Data
def main():
print("=== Générateur de proposition commerciale ===\n")
form = Form(fields())
form.ask()
data = form.get_data()
# Transformer les fonctionnalités en une liste de dictionnaires
features = data.get("features", "").split(",")
data["features"] = [{"description": feature.strip()} for feature in features if feature.strip()]
client_name = data.get("client_name", "").replace(" ", "_").lower()
data_manager = Data(f"Data/clients/{client_name}.json")
client_data = data_manager.save_data(data)
print("\n✅ Données du client enregistrées avec succès.")
generator = Generator(data)
content = generator.generate_pdf("propositions")
print("\n✅ Proposition générée avec succès.")

View file

@ -0,0 +1,16 @@
def fields() -> list[dict]:
return [
{"name": "client_name", "label": "Nom du client", "type": "text", "required": True},
{"name": "email", "label": "Email", "type": "email"},
{"name": "telephone", "label": "Téléphone", "type": "tel"},
{"name": "adresse", "label": "Adresse", "type": "text"},
{"name": "project_name", "label": "Nom du projet", "type": "text", "required": True},
{"name": "project_type", "label": "Type de projet", "type": "text"},
{"name": "deadline", "label": "Délai de livraison", "type": "date"},
{"name": "project_description", "label": "Description du projet", "type": "textarea"},
{"name": "features", "label": "Fonctionnalités principales (séparées par des virgules)", "type": "textarea"},
{"name": "budget", "label": "Budget estimé", "type": "text"},
{"name": "payment_terms", "label": "Conditions de paiement", "type": "text"},
{"name": "contact_info", "label": "Coordonnées", "type": "text"},
{"name": "additional_info", "label": "Informations complémentaires", "type": "textarea"}
]

View file

@ -0,0 +1,4 @@
from .task import Task
from .task_handler import TaskHandler
__all__ = ["Task", "TaskHandler"]

64
modules/tasks/task.py Normal file
View file

@ -0,0 +1,64 @@
from datetime import datetime
from typing import Optional, Literal, Dict, Any
import uuid
class Task:
def __init__(
self,
title: str,
due_date: str,
description: str = "",
entity_type: Optional[Literal["client", "prospect", "project", "campaign"]] = None,
entity_id: Optional[str] = None,
priority: Literal["basse", "normale", "haute"] = "normale",
status: Literal["todo", "done", "canceled"] = "todo",
id: Optional[str] = None,
created_at: Optional[str] = None,
completed_at: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
):
self.id = id or f"tsk_{uuid.uuid4().hex[:8]}"
self.title = title
self.description = description
# due_date: format ISO "YYYY-MM-DD"
self.due_date = due_date
self.entity_type = entity_type
self.entity_id = entity_id
self.priority = priority
self.status = status
now_iso = datetime.utcnow().isoformat()
self.created_at = created_at or now_iso
self.completed_at = completed_at
self.metadata = metadata or {}
def to_dict(self) -> Dict[str, Any]:
return {
"id": self.id,
"title": self.title,
"description": self.description,
"due_date": self.due_date,
"entity_type": self.entity_type,
"entity_id": self.entity_id,
"priority": self.priority,
"status": self.status,
"created_at": self.created_at,
"completed_at": self.completed_at,
"metadata": self.metadata,
}
@staticmethod
def from_dict(data: Dict[str, Any]) -> "Task":
return Task(
id=data.get("id"),
title=data.get("title", ""),
description=data.get("description", ""),
due_date=data.get("due_date", ""),
entity_type=data.get("entity_type"),
entity_id=data.get("entity_id"),
priority=data.get("priority", "normale"),
status=data.get("status", "todo"),
created_at=data.get("created_at"),
completed_at=data.get("completed_at"),
metadata=data.get("metadata") or {},
)

View file

@ -0,0 +1,207 @@
import json
import os
from datetime import date, datetime, timedelta
from typing import List, Optional, Literal
from modules.tasks.task import Task
class TaskHandler:
def __init__(self, base_dir: Optional[str] = None):
# Par défaut: Data/tasks (un fichier JSON par tâche)
self.base_dir = base_dir or os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
"Data",
"tasks",
)
os.makedirs(self.base_dir, exist_ok=True)
def _task_path(self, task_id: str) -> str:
return os.path.join(self.base_dir, f"{task_id}.json")
def add_task(self, task: Task) -> str:
path = self._task_path(task.id)
with open(path, "w", encoding="utf-8") as f:
json.dump(task.to_dict(), f, ensure_ascii=False, indent=2)
return task.id
def get_task(self, task_id: str) -> Optional[Task]:
path = self._task_path(task_id)
if not os.path.exists(path):
return None
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
return Task.from_dict(data)
def update_task(self, task: Task) -> bool:
path = self._task_path(task.id)
if not os.path.exists(path):
return False
# pas de gestion updated_at pour rester simple
with open(path, "w", encoding="utf-8") as f:
json.dump(task.to_dict(), f, ensure_ascii=False, indent=2)
return True
def delete_task(self, task_id: str) -> bool:
path = self._task_path(task_id)
if os.path.exists(path):
try:
os.remove(path)
return True
except Exception:
return False
return False
def list_tasks(self, status: Optional[Literal["todo", "done", "canceled"]] = None) -> List[Task]:
tasks: List[Task] = []
for filename in os.listdir(self.base_dir):
if filename.endswith(".json"):
try:
with open(os.path.join(self.base_dir, filename), "r", encoding="utf-8") as f:
data = json.load(f)
t = Task.from_dict(data)
if status is None or t.status == status:
tasks.append(t)
except Exception:
continue
# Tri par (due_date, priorité, création)
def _prio_val(p: str) -> int:
return {"haute": 0, "normale": 1, "basse": 2}.get(p, 1)
tasks.sort(key=lambda t: (t.due_date or "", _prio_val(t.priority), t.created_at or ""))
return tasks
def list_tasks_for_date(
self,
day_iso: str,
status: Optional[Literal["todo", "done", "canceled"]] = None,
) -> List[Task]:
return [t for t in self.list_tasks(status=status) if (t.due_date or "") == day_iso]
def list_today_tasks(self, status: Optional[Literal["todo", "done", "canceled"]] = None) -> List[Task]:
return self.list_tasks_for_date(date.today().isoformat(), status=status)
def list_tomorrow_tasks(self, status: Optional[Literal["todo", "done", "canceled"]] = None) -> List[Task]:
tomorrow = (date.today() + timedelta(days=1)).isoformat()
return self.list_tasks_for_date(tomorrow, status=status)
def list_overdue_tasks(self) -> List[Task]:
"""Retourne les tâches en retard (due_date < aujourd'hui) encore à faire."""
today_iso = date.today().isoformat()
tasks = self.list_tasks(status="todo")
return [t for t in tasks if (t.due_date or "") < today_iso]
def list_tasks_by_entity(
self,
entity_type: Literal["client", "prospect", "project", "campaign"],
entity_id: str,
status: Optional[Literal["todo", "done", "canceled"]] = None,
) -> List[Task]:
return [
t
for t in self.list_tasks(status=status)
if t.entity_type == entity_type and t.entity_id == entity_id
]
def complete_task(self, task_id: str) -> bool:
t = self.get_task(task_id)
if not t:
return False
t.status = "done"
t.completed_at = datetime.utcnow().isoformat()
return self.update_task(t)
def cancel_task(self, task_id: str) -> bool:
t = self.get_task(task_id)
if not t:
return False
t.status = "canceled"
return self.update_task(t)
def postpone_task(self, task_id: str, new_due_date: str) -> bool:
t = self.get_task(task_id)
if not t:
return False
t.due_date = new_due_date
return self.update_task(t)
# Raccourcis pour créer des tâches liées
def add_task_for_client(
self,
client_id: str,
title: str,
due_date: str,
description: str = "",
priority: Literal["basse", "normale", "haute"] = "normale",
metadata: Optional[dict] = None,
) -> str:
task = Task(
title=title,
description=description,
due_date=due_date,
entity_type="client",
entity_id=client_id,
priority=priority,
metadata=metadata or {},
)
return self.add_task(task)
def add_task_for_prospect(
self,
prospect_id: str,
title: str,
due_date: str,
description: str = "",
priority: Literal["basse", "normale", "haute"] = "normale",
metadata: Optional[dict] = None,
) -> str:
task = Task(
title=title,
description=description,
due_date=due_date,
entity_type="prospect",
entity_id=prospect_id,
priority=priority,
metadata=metadata or {},
)
return self.add_task(task)
def add_task_for_project(
self,
project_id: str,
title: str,
due_date: str,
description: str = "",
priority: Literal["basse", "normale", "haute"] = "normale",
metadata: Optional[dict] = None,
) -> str:
task = Task(
title=title,
description=description,
due_date=due_date,
entity_type="project",
entity_id=project_id,
priority=priority,
metadata=metadata or {},
)
return self.add_task(task)
def add_task_for_campaign(
self,
campaign_id: str,
title: str,
due_date: str,
description: str = "",
priority: Literal["basse", "normale", "haute"] = "normale",
metadata: Optional[dict] = None,
) -> str:
task = Task(
title=title,
description=description,
due_date=due_date,
entity_type="campaign",
entity_id=campaign_id,
priority=priority,
metadata=metadata or {},
)
return self.add_task(task)

275
modules/tasks/web.py Normal file
View file

@ -0,0 +1,275 @@
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify
from datetime import date, datetime
from modules.tasks.task_handler import TaskHandler
from modules.tasks.task import Task
from modules.email.draft_handler import DraftHandler
from modules.email.email_manager import EmailSender
from jobs.daily_reminder_job import main as generate_email_drafts_today
from html import escape
tasks_bp = Blueprint("tasks", __name__, url_prefix="/tasks")
@tasks_bp.get("/")
def tasks_index():
"""
Page de gestion des tâches: liste + formulaire de création.
Filtres optionnels via query string: status, entity_type, entity_id
"""
handler = TaskHandler()
status = request.args.get("status")
entity_type = request.args.get("entity_type")
entity_id = request.args.get("entity_id")
tasks = handler.list_tasks(status=status) if status else handler.list_tasks()
if entity_type and entity_id:
tasks = [t for t in tasks if t.entity_type == entity_type and t.entity_id == entity_id]
return render_template(
"tasks/manage.html",
tasks=tasks,
status=status,
entity_type=entity_type,
entity_id=entity_id,
today=date.today().isoformat(),
)
@tasks_bp.get("/today/fragment")
def today_fragment():
"""
Renvoie le fragment HTML du bloc 'tâches du jour'
"""
handler = TaskHandler()
today_tasks = handler.list_today_tasks(status="todo")
return render_template("partials/tasks_today_block.html", today_tasks=today_tasks)
@tasks_bp.get('/tomorrow/fragment')
def tomorrow_fragment():
"""
Renvoie le fragment HTML du bloc 'tâches du lendemain'
"""
handler = TaskHandler()
tomorrow_tasks = handler.list_tomorrow_tasks(status="todo")
return render_template("partials/tasks_today_block.html", today_tasks=tomorrow_tasks)
@tasks_bp.get("/today/count")
def today_count():
"""
Renvoie le nombre de tâches du jour (todo) au format JSON, pour affichage dans un badge.
"""
handler = TaskHandler()
count = len(handler.list_today_tasks(status="todo"))
return jsonify({"count": count})
@tasks_bp.post("/create")
def create_task():
"""
Création d'une tâche depuis un formulaire.
Champs attendus: title, due_date (YYYY-MM-DD), description, priority, entity_type, entity_id
"""
title = (request.form.get("title") or "").strip()
due_date = (request.form.get("due_date") or "").strip()
description = (request.form.get("description") or "").strip()
priority = (request.form.get("priority") or "normale").strip()
entity_type = (request.form.get("entity_type") or "").strip() or None
entity_id = (request.form.get("entity_id") or "").strip() or None
if not title or not due_date:
flash("Titre et échéance sont obligatoires.", "warning")
return redirect(request.referrer or url_for("tasks.tasks_index"))
handler = TaskHandler()
task = Task(
title=title,
due_date=due_date,
description=description,
priority=priority,
entity_type=entity_type,
entity_id=entity_id,
)
handler.add_task(task)
flash("Tâche créée avec succès.", "success")
return redirect(request.referrer or url_for("tasks.tasks_index"))
@tasks_bp.post("/status")
def set_status():
"""
Met à jour le statut d'une tâche (todo|done|canceled).
Form data: task_id, status
"""
task_id = (request.form.get("task_id") or "").strip()
status = (request.form.get("status") or "todo").strip()
if not task_id or status not in ("todo", "done", "canceled"):
flash("Requête invalide pour le changement de statut.", "warning")
return redirect(request.referrer or url_for("tasks.tasks_index"))
handler = TaskHandler()
task = handler.get_task(task_id)
if not task:
flash("Tâche introuvable.", "danger")
return redirect(request.referrer or url_for("tasks.tasks_index"))
task.status = status
task.completed_at = datetime.utcnow().isoformat() if status == "done" else None
if handler.update_task(task):
flash("Statut de la tâche mis à jour.", "success")
else:
flash("Échec de la mise à jour du statut.", "danger")
return redirect(request.referrer or url_for("tasks.tasks_index"))
@tasks_bp.post("/delete")
def delete_task():
"""
Supprime une tâche.
Form data: task_id
"""
task_id = (request.form.get("task_id") or "").strip()
if not task_id:
flash("Requête invalide pour la suppression.", "warning")
return redirect(request.referrer or url_for("tasks.tasks_index"))
handler = TaskHandler()
if handler.delete_task(task_id):
flash("Tâche supprimée.", "success")
else:
flash("Échec de la suppression.", "danger")
return redirect(request.referrer or url_for("tasks.tasks_index"))
@tasks_bp.post("/update")
def update_task():
"""
Met à jour les champs d'une tâche.
Form data: task_id, title, due_date, description, priority, entity_type, entity_id
"""
task_id = (request.form.get("task_id") or "").strip()
if not task_id:
flash("Requête invalide pour l'édition.", "warning")
return redirect(request.referrer or url_for("tasks.tasks_index"))
handler = TaskHandler()
task = handler.get_task(task_id)
if not task:
flash("Tâche introuvable.", "danger")
return redirect(request.referrer or url_for("tasks.tasks_index"))
# Mise à jour des champs
task.title = (request.form.get("title") or task.title).strip()
task.due_date = (request.form.get("due_date") or task.due_date).strip()
task.description = (request.form.get("description") or task.description).strip()
task.priority = (request.form.get("priority") or task.priority).strip()
task.entity_type = (request.form.get("entity_type") or task.entity_type or "").strip() or None
task.entity_id = (request.form.get("entity_id") or task.entity_id or "").strip() or None
if handler.update_task(task):
flash("Tâche modifiée.", "success")
else:
flash("Échec de la modification.", "danger")
return redirect(request.referrer or url_for("tasks.tasks_index"))
@tasks_bp.get("/email-drafts")
def email_drafts_list():
"""
Liste des brouillons d'emails à envoyer + actions UI (via template).
"""
dh = DraftHandler()
drafts = dh.list_pending()
return render_template("tasks/email_drafts.html", drafts=drafts)
@tasks_bp.post("/email-drafts/generate")
def email_drafts_generate():
"""
Déclenche la génération des brouillons d'aujourd'hui (équivalent au job quotidien).
"""
try:
count = generate_email_drafts_today()
if count:
flash(f"{count} brouillon(s) généré(s) pour aujourd'hui.", "success")
else:
flash("Aucun brouillon créé. Vérifiez: tâches 'todo' dues aujourd'hui, liées à des prospects avec un email.", "info")
except Exception as e:
flash(f"Erreur lors de la génération: {e}", "danger")
return redirect(url_for("tasks.email_drafts_list"))
@tasks_bp.post("/email-drafts/send")
def email_drafts_send():
"""
Envoie un brouillon d'email sélectionné puis met à jour son statut.
"""
draft_id = (request.form.get("draft_id") or "").strip()
if not draft_id:
flash("Brouillon invalide", "warning")
return redirect(url_for("tasks.email_drafts_list"))
dh = DraftHandler()
draft = dh.get_draft(draft_id)
if not draft:
flash("Brouillon introuvable", "danger")
return redirect(url_for("tasks.email_drafts_list"))
sender = EmailSender()
try:
res = sender.send_tracked_email(
to_email=draft.to_email,
subject=draft.subject,
body=draft.content,
prospect_id=draft.prospect_id,
template_id=draft.template_id,
)
if res.get("success"):
dh.mark_sent(draft.id, success=True)
flash("Email envoyé.", "success")
else:
dh.mark_sent(draft.id, success=False, error_message=res.get("error"))
flash("Échec de l'envoi de l'email.", "danger")
except Exception as e:
dh.mark_sent(draft.id, success=False, error_message=str(e))
flash("Erreur lors de l'envoi de l'email.", "danger")
return redirect(url_for("tasks.email_drafts_list"))
@tasks_bp.get("/quick-add")
def quick_add_page():
"""
Page complète 'Quick Add Task' pour une entité donnée.
Paramètres query: entity_type (client|prospect|project), entity_id
"""
entity_type = (request.args.get("entity_type") or "").strip()
entity_id = (request.args.get("entity_id") or "").strip()
if not entity_type or not entity_id:
flash("Paramètres manquants pour lier la tâche à une entité.", "warning")
return redirect(url_for("tasks.tasks_index"))
return render_template(
"tasks/quick_add.html",
entity_type=entity_type,
entity_id=entity_id,
today=date.today().isoformat(),
)
@tasks_bp.get("/quick-add/fragment")
def quick_add_fragment():
"""
Fragment HTML du formulaire 'Quick Add Task' intégré dans d'autres pages.
Paramètres query: entity_type, entity_id
"""
entity_type = (request.args.get("entity_type") or "").strip()
entity_id = (request.args.get("entity_id") or "").strip()
if not entity_type or not entity_id:
return "<p class='text-warning'>Paramètres manquants pour le formulaire de tâche liée.</p>"
return render_template(
"partials/task_quick_add_form.html",
entity_type=entity_type,
entity_id=entity_id,
today=date.today().isoformat(),
)

57
modules/tracking/store.py Normal file
View file

@ -0,0 +1,57 @@
import os
import json
from datetime import datetime
from typing import Optional, Dict, Any, List
class TrackingStore:
"""
Stocke les enregistrements d'emails trackés et leurs événements.
Répertoire: Data/email_tracking
Fichier par tracking_id: Data/email_tracking/<tracking_id>.json
"""
def __init__(self, base_dir: Optional[str] = None):
base_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
self.base_dir = base_dir or os.path.join(base_root, "Data", "email_tracking")
os.makedirs(self.base_dir, exist_ok=True)
def _path(self, tracking_id: str) -> str:
return os.path.join(self.base_dir, f"{tracking_id}.json")
def create_record(self, tracking_id: str, record: Dict[str, Any]) -> None:
record = dict(record)
record.setdefault("created_at", datetime.utcnow().isoformat())
record.setdefault("events", []) # list of {"type": "...","ts": "...", ...}
with open(self._path(tracking_id), "w", encoding="utf-8") as f:
json.dump(record, f, ensure_ascii=False, indent=2)
def get_record(self, tracking_id: str) -> Optional[Dict[str, Any]]:
p = self._path(tracking_id)
if not os.path.exists(p):
return None
with open(p, "r", encoding="utf-8") as f:
return json.load(f)
def update_record(self, tracking_id: str, updater) -> Optional[Dict[str, Any]]:
rec = self.get_record(tracking_id)
if rec is None:
return None
new_rec = updater(dict(rec)) or rec
with open(self._path(tracking_id), "w", encoding="utf-8") as f:
json.dump(new_rec, f, ensure_ascii=False, indent=2)
return new_rec
def add_event(self, tracking_id: str, event_type: str, meta: Optional[Dict[str, Any]] = None) -> Optional[Dict[str, Any]]:
meta = meta or {}
def _upd(rec: Dict[str, Any]):
events: List[Dict[str, Any]] = rec.get("events") or []
events.append({"type": event_type, "ts": datetime.utcnow().isoformat(), **meta})
rec["events"] = events
# counters
if event_type == "open":
rec["opens"] = int(rec.get("opens") or 0) + 1
if event_type == "click":
rec["clicks"] = int(rec.get("clicks") or 0) + 1
return rec
return self.update_record(tracking_id, _upd)